RBAC and Doc updates

This commit is contained in:
James Pattinson
2025-10-13 14:05:01 +00:00
parent f721be7280
commit d4b88e0952
13 changed files with 1400 additions and 208 deletions

View File

@@ -1,97 +1,169 @@
# Copilot Instructions: Mail List Manager (Postfix + SES + Web Interface) # Copilot Instructions: Mail List Manager (Postfix + SES + MySQL + API + Web)
## Architecture Overview ## Architecture Overview
This is a containerized mailing list management system built around Postfix as an SMTP relay through Amazon SES. **Currently in Phase 1** with static configuration; **planned expansion** includes web frontend and SQL database for dynamic member management. This is a complete containerized mailing list management system with four services:
**Current Components (Phase 1):** 1. **Web Frontend** (Port 3000) - Nginx serving modern HTML/CSS/JS interface
- `docker-compose.yaml`: Single-service orchestration with SES credentials 2. **REST API** (Port 8000) - FastAPI backend with token authentication
- `postfix/`: Complete Postfix container configuration 3. **MySQL Database** (Internal) - Stores lists, members, and subscriptions
- Static virtual aliases system for mailing list distribution 4. **Postfix Mail Server** (Port 25) - SMTP server with native MySQL integration
**Planned Architecture (Phase 2+):** **Key Architecture Principles:**
- Web frontend for list management (view/add/remove members) - Real-time operation: Changes take effect immediately without Postfix reload
- SQL database backend for member storage - Native integration: Postfix queries MySQL directly (no Python scripts)
- Dynamic Postfix configuration generation - Security-first: Private Docker network, token auth, sender whitelist
- Multi-service Docker Compose setup - User-friendly: Web interface for easy management by non-technical users
## Configuration Pattern ## Configuration Pattern
**Current (Static):** Environment variable substitution for secure credential management: **Dynamic Database-Driven Architecture:**
1. **Credentials Flow**: SES credentials passed via environment → `sasl_passwd.template` → runtime generation in `entrypoint.sh` 1. **Data Flow**: Web UI → REST API → MySQL ← Postfix (real-time queries)
2. **Config Files**: Static configs (`main.cf`, `virtual_aliases.cf`) + dynamic SASL auth file 2. **Credentials**: All sensitive data in `.env` file (SES, MySQL, API_TOKEN)
3. **Postfix Maps**: Hash databases generated at build time (virtual aliases) and runtime (SASL) 3. **Mail Processing**:
- Email arrives for `list@lists.sasalliance.org`
- Postfix queries MySQL via `mysql_virtual_alias_maps.cf`
- MySQL returns comma-separated member emails
- Postfix expands alias and delivers via SES
4. **Security**: Private Docker network (maillist-internal), sender whitelist (@sasalliance.org)
**Future (Dynamic):** Database-driven configuration: **No Static Configuration:**
- Member lists stored in SQL database, with members able to join multiple lists - Virtual aliases are stored in MySQL database, not config files
- Web interface for CRUD operations on members - Changes take effect immediately without container restarts
- `virtual_aliases.cf` generated from database at runtime - Postfix caches query results for performance
- Postfix reload triggered by configuration changes
## Key Files and Their Roles ## Key Files and Their Roles
- `main.cf`: Core Postfix config - relay through SES, domain settings, security ### Docker & Environment
- `sasl_passwd.template`: Template for SES authentication (uses `${SES_USER}:${SES_PASS}`) - `docker-compose.yaml`: Multi-service orchestration (mysql, postfix, api, web)
- `virtual_aliases.cf`: Static email forwarding rules (one mailing list currently) - `.env`: All credentials and configuration (SES, MySQL, API_TOKEN)
- `entrypoint.sh`: Runtime credential processing and Postfix startup
### Web Frontend (`web/`)
- `index.html`: Single-page application interface
- `static/js/api.js`: API client with authentication
- `static/js/ui.js`: UI helpers and modal management
- `static/js/app.js`: Main application controller
- `static/css/style.css`: Complete styling system
- `nginx.conf`: Nginx configuration for static file serving
### REST API (`api/`)
- `main.py`: FastAPI application with all CRUD endpoints
- `requirements.txt`: Python dependencies (fastapi, mysql-connector-python, etc.)
- `Dockerfile`: Python 3.11 container build
### Database (`database/`)
- `schema.sql`: Database initialization with tables and sample data
- Tables: `lists`, `members`, `list_members` (many-to-many junction)
### Postfix (`postfix/`)
- `main.cf.template`: Core Postfix config with MySQL virtual alias maps
- `mysql_virtual_alias_maps.cf`: Native Postfix MySQL query configuration
- `sender_access`: Sender whitelist (sasalliance.org domain allowed)
- `sasl_passwd.template`: SES authentication template
- `entrypoint.sh`: Container startup with MySQL health check and config generation
## Development Workflows ## Development Workflows
**Building and Running:** **Building and Running:**
Always use sudo for Docker commands. Don't bother opening the simple browser UI for Docker as it is not very useful.
```bash ```bash
docker-compose up --build # Build and start mail server sudo docker-compose up --build # Build and start all services
docker-compose logs -f # Monitor mail delivery logs sudo docker-compose logs -f # Monitor all service logs
sudo docker-compose logs -f api # Monitor specific service
``` ```
**Adding Mailing Lists (Current):** **Managing Lists and Members:**
1. Edit `virtual_aliases.cf` with new list → recipients mapping
2. Rebuild container (postmap runs at build time)
3. No restart needed for existing virtual alias changes
**Future Web Interface Workflow:** **Via Web Interface (Recommended):**
1. Access web frontend at configured port 1. Open http://localhost:3000
2. Use CRUD interface to manage mailing lists and members 2. Enter API_TOKEN from .env file
3. Changes automatically update database and regenerate Postfix configs 3. Use intuitive interface to manage lists, members, and subscriptions
4. Click "Subscriptions" button on any member for toggle-based subscription management
**Via REST API:**
```bash
# Get all lists
curl -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/lists
# Create new list
curl -X POST http://localhost:8000/lists \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"list_name":"Test","list_email":"test@lists.sasalliance.org","active":true}'
# Subscribe member to list
curl -X POST http://localhost:8000/subscriptions \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"list_email":"test@lists.sasalliance.org","member_email":"user@example.com"}'
```
**Via Direct Database Access:**
```bash
docker-compose exec mysql mysql -u maillist -p maillist
# See database/README.md for SQL examples
```
**Testing Mail Delivery:** **Testing Mail Delivery:**
```bash ```bash
# From inside container # From inside container
docker-compose exec postfix bash
echo "Test message" | mail -s "Subject" community@lists.sasalliance.org echo "Test message" | mail -s "Subject" community@lists.sasalliance.org
# Check logs for SES relay status # Check logs for SES relay status
docker-compose logs postfix | grep -E "(sent|bounced|deferred)" docker-compose logs postfix | grep -E "(sent|bounced|deferred)"
# Verify MySQL query works
docker-compose exec postfix postmap -q "community@lists.sasalliance.org" \
mysql:/etc/postfix/mysql_virtual_alias_maps.cf
``` ```
## Security Considerations ## Security Considerations
- SES credentials exposed in docker-compose.yaml (consider using Docker secrets) - All credentials stored in `.env` file (git-ignored)
- MySQL on private Docker network (no host port mapping)
- API requires Bearer token authentication
- Sender whitelist restricts who can send to lists (@sasalliance.org only)
- SASL password file permissions set to 600 in entrypoint - SASL password file permissions set to 600 in entrypoint
- TLS encryption enforced for SES relay (`smtp_tls_security_level = encrypt`) - TLS encryption enforced for SES relay (`smtp_tls_security_level = encrypt`)
- Only localhost and configured hostname accepted for local delivery - Web interface requires token for all operations
## Configuration Conventions ## Configuration Conventions
- **Hostname Pattern**: `lists.sasalliance.org` for mailing lists, origin domain `sasalliance.org` - **Hostname Pattern**: `lists.sasalliance.org` for mailing lists, origin domain `sasalliance.org`
- **Virtual Aliases**: Simple format `listname@lists.domain → recipient1, recipient2` - **Virtual Aliases**: Dynamically queried from MySQL database in real-time
- **SES Region**: EU West 2 (`email-smtp.eu-west-2.amazonaws.com`) - **SES Region**: EU West 2 (`email-smtp.eu-west-2.amazonaws.com`)
- **Port Mapping**: Standard SMTP port 25 exposed to host - **Port Mapping**: Standard SMTP port 25 exposed to host
- **Database Tables**: `lists`, `members`, `list_members` (many-to-many design)
- **API Port**: 8000 for REST API
- **Web Port**: 3000 for web interface
## Common Modifications ## Common Modifications
**Adding Recipients to Existing List (Current):** **Adding Recipients to a List:**
Edit `virtual_aliases.cf`, add comma-separated emails, rebuild container. Use web interface or API - changes are immediate!
**New Mailing List (Current):** **Creating New Mailing List:**
Add line to `virtual_aliases.cf`: `newlist@lists.sasalliance.org recipients...` 1. Via Web: Click "Add List" button, fill in form
2. Via API: POST to /lists endpoint with list details
3. Via MySQL: INSERT into lists table
**Adding New Member:**
1. Via Web: Click "Add Member" button in Members tab
2. Via API: POST to /members endpoint
3. Via MySQL: INSERT into members table
**Managing Subscriptions:**
1. Via Web: Click "Subscriptions" button on member, toggle lists
2. Via API: POST/DELETE to /subscriptions endpoint
3. Via MySQL: INSERT/DELETE in list_members table
**Credential Updates:** **Credential Updates:**
Update `SES_USER`/`SES_PASS` in docker-compose.yaml, restart container. 1. Edit `.env` file with new credentials
2. Restart affected services:
## Migration Considerations - SES: `docker-compose restart postfix`
- MySQL: `docker-compose restart mysql`
When implementing the web frontend and database backend: - API: `docker-compose restart api`
- Preserve existing `community@lists.sasalliance.org` list during migration
- Consider migration script to import current virtual aliases into database
- Plan for zero-downtime deployment with database-driven config generation
- Web interface should validate email addresses and handle duplicate members

325
README.md
View File

@@ -1,18 +1,23 @@
# Mail List Manager # Mail List Manager
A containerized mailing list management system built around Postfix as an SMTP relay through Amazon SES. A complete containerized mailing list management system with web interface, REST API, MySQL database, and Postfix mail server configured as an SMTP relay through Amazon SES.
## Architecture ## Architecture
**Current (Phase 1):** Static configuration with environment-based credentials **Multi-Service Architecture:**
- Postfix container configured as SES relay - **Web Frontend** - Modern responsive interface for managing lists and members (Port 3000)
- Static virtual aliases for mailing list distribution - **REST API** - FastAPI backend with token authentication (Port 8000)
- Environment variable configuration for security - **MySQL Database** - Stores lists, members, and subscriptions with real-time integration
- **Postfix Mail Server** - SMTP server with native MySQL integration for dynamic list expansion (Port 25)
- **Amazon SES** - Outbound mail relay for reliable delivery
**Planned (Phase 2+):** Web interface with SQL backend **Key Features:**
- Web frontend for list management (view/add/remove members) - Real-time list updates (no Postfix reload needed)
- SQL database for member storage - Native Postfix MySQL queries for instant list expansion
- Dynamic Postfix configuration generation - Token-based API authentication
- Sender whitelisting for authorized board members
- Private Docker network for security
- Complete web-based management interface
## Quick Start ## Quick Start
@@ -21,72 +26,192 @@ A containerized mailing list management system built around Postfix as an SMTP r
cp .env.example .env cp .env.example .env
``` ```
2. Edit `.env` with your SES credentials and configuration: 2. Edit `.env` with your credentials:
```bash ```bash
# Required: Your SES credentials # SES Credentials (Required)
SES_USER=your_ses_access_key SES_USER=your_ses_access_key
SES_PASS=your_ses_secret_key SES_PASS=your_ses_secret_key
# MySQL Credentials (Required)
MYSQL_ROOT_PASSWORD=secure_root_password
MYSQL_PASSWORD=secure_maillist_password
# API Authentication (Required)
API_TOKEN=your_secure_api_token
# Optional: SMTP configuration (defaults to EU West 2) # Optional: SMTP configuration (defaults to EU West 2)
SMTP_HOST=email-smtp.eu-west-2.amazonaws.com SMTP_HOST=email-smtp.eu-west-2.amazonaws.com
SMTP_PORT=587 SMTP_PORT=587
``` ```
3. Build and start the mail server: 3. Build and start all services:
```bash ```bash
docker-compose up --build sudo docker-compose up --build
``` ```
4. Test mail delivery: 4. Access the web interface:
```bash
# From inside container
docker-compose exec postfix bash
echo "Test message" | mail -s "Subject" community@lists.sasalliance.org
# Check logs
docker-compose logs -f postfix
``` ```
http://localhost:3000
```
Enter your API_TOKEN to authenticate and start managing lists!
5. Alternative: Use the REST API directly:
```bash
# Health check
curl http://localhost:8000/health
# Get all lists (requires authentication)
curl -H "Authorization: Bearer your_api_token" http://localhost:8000/lists
```
## Services
### Web Interface (Port 3000)
- Modern, responsive interface for managing mailing lists and members
- Token-based authentication
- Real-time updates and feedback
- See `web/README.md` for details
### REST API (Port 8000)
- Complete CRUD operations for lists, members, and subscriptions
- Bearer token authentication
- Interactive documentation at http://localhost:8000/docs
- See `api/README.md` for API reference
### MySQL Database (Internal Only)
- Stores all mailing list and member data
- Automatically initialized with schema
- Native Postfix integration for real-time queries
- See `database/README.md` for schema and management
### Postfix Mail Server (Port 25)
- Accepts mail for lists.sasalliance.org
- Queries MySQL for list expansion
- Relays through Amazon SES
- Sender whitelist: @sasalliance.org domain
- Changes take effect immediately (no restart needed)
## Configuration ## Configuration
### Adding Mailing Lists (Current) ### Managing Lists and Members
Edit `postfix/virtual_aliases.cf`: **Via Web Interface (Recommended):**
``` 1. Open http://localhost:3000
newlist@lists.sasalliance.org recipient1@domain.com, recipient2@domain.com 2. Enter your API token
``` 3. Use the intuitive interface to:
- Create/edit/delete mailing lists
- Add/edit/remove members
- Manage subscriptions with toggle switches
- View member counts and list details
Then rebuild the container: **Via REST API:**
```bash ```bash
docker-compose up --build # Create a new list
curl -X POST http://localhost:8000/lists \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{
"list_name": "Developers",
"list_email": "dev@lists.sasalliance.org",
"description": "Developer discussions",
"active": true
}'
# Add a new member
curl -X POST http://localhost:8000/members \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john@example.com",
"active": true
}'
# Subscribe member to list
curl -X POST http://localhost:8000/subscriptions \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{
"list_email": "dev@lists.sasalliance.org",
"member_email": "john@example.com"
}'
``` ```
### Domain Configuration **Via Direct Database Access:**
```bash
# Connect to MySQL
docker-compose exec mysql mysql -u maillist -p maillist
The system is configured for: # Run SQL queries (see database/README.md for examples)
- **Hostname**: `lists.sasalliance.org` (mailing lists) ```
### Mail Server Configuration
### Mail Server Configuration
The Postfix mail server is configured for:
- **Hostname**: `lists.sasalliance.org` (accepts mail for this domain)
- **Origin Domain**: `sasalliance.org` - **Origin Domain**: `sasalliance.org`
- **SES Region**: EU West 2 (configurable via `SMTP_HOST`) - **SES Region**: EU West 2 (configurable via `SMTP_HOST`)
- **Sender Whitelist**: @sasalliance.org (only authorized board members can send to lists)
- **Dynamic Lists**: Postfix queries MySQL in real-time for list members
### Testing Mail Delivery
```bash
# From inside Postfix container
docker-compose exec postfix bash
echo "Test message" | mail -s "Test Subject" community@lists.sasalliance.org
# Check delivery logs
docker-compose logs -f postfix | grep -E "(sent|bounced|deferred)"
# Verify MySQL query works
docker-compose exec postfix postmap -q "community@lists.sasalliance.org" \
mysql:/etc/postfix/mysql_virtual_alias_maps.cf
```
## Security ## Security
- SES credentials are stored in `.env` (git-ignored) - **Environment Variables**: All credentials stored in `.env` (git-ignored)
- SASL password files have restricted permissions (600) - **Private Network**: MySQL communicates with Postfix/API on internal Docker network only
- TLS encryption enforced for SES relay - **Token Authentication**: API requires Bearer token for all write operations
- Only localhost and configured hostname accepted for local delivery - **Sender Whitelist**: Only @sasalliance.org addresses can send to mailing lists
- **TLS Encryption**: Enforced for SES relay connections
- **File Permissions**: SASL password files have restricted permissions (600)
- **No External MySQL**: Database is not exposed to host (no port mapping)
## Development ## Development
### Project Structure ### Project Structure
``` ```
├── docker-compose.yaml # Service orchestration ├── docker-compose.yaml # Multi-service orchestration
├── .env # Environment configuration (not in git) ├── .env # Environment configuration (not in git)
├── postfix/ ├── web/ # Web frontend (Nginx + HTML/CSS/JS)
│ ├── Dockerfile # Postfix container build │ ├── index.html
│ ├── entrypoint.sh # Runtime configuration processing │ ├── static/
├── main.cf.template # Postfix main configuration template │ ├── css/style.css
├── sasl_passwd.template # SES authentication template │ └── js/
└── virtual_aliases.cf # Static mailing list definitions │ ├── api.js
│ │ ├── ui.js
│ │ └── app.js
│ ├── nginx.conf
│ └── Dockerfile
├── api/ # REST API (FastAPI)
│ ├── main.py
│ ├── requirements.txt
│ └── Dockerfile
├── database/ # MySQL schema and docs
│ ├── schema.sql
│ └── README.md
├── postfix/ # Mail server configuration
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── main.cf.template
│ ├── mysql_virtual_alias_maps.cf
│ ├── sasl_passwd.template
│ └── sender_access
└── .github/ └── .github/
└── copilot-instructions.md # AI agent guidance └── copilot-instructions.md # AI agent guidance
``` ```
@@ -96,28 +221,128 @@ The system is configured for:
- `SES_PASS`: AWS SES secret access key - `SES_PASS`: AWS SES secret access key
- `SMTP_HOST`: SMTP server hostname (default: email-smtp.eu-west-2.amazonaws.com) - `SMTP_HOST`: SMTP server hostname (default: email-smtp.eu-west-2.amazonaws.com)
- `SMTP_PORT`: SMTP server port (default: 587) - `SMTP_PORT`: SMTP server port (default: 587)
- `MYSQL_ROOT_PASSWORD`: MySQL root password
- `MYSQL_DATABASE`: Database name (default: maillist)
- `MYSQL_USER`: MySQL user (default: maillist)
- `MYSQL_PASSWORD`: MySQL user password
- `API_TOKEN`: Bearer token for API authentication
### Service Dependencies
```
mysql (healthcheck) → postfix, api → web
```
- MySQL must be healthy before Postfix/API start
- Web frontend depends on API being available
- All services communicate on private Docker network
### Debugging ### Debugging
Monitor mail delivery: Monitor services:
```bash ```bash
# View all logs # View all service logs
docker-compose logs -f docker-compose logs -f
# Filter for delivery status # View specific service
docker-compose logs -f postfix
docker-compose logs -f api
docker-compose logs -f web
docker-compose logs -f mysql
# Filter for mail delivery status
docker-compose logs postfix | grep -E "(sent|bounced|deferred)" docker-compose logs postfix | grep -E "(sent|bounced|deferred)"
# Check Postfix queue # Check Postfix queue
docker-compose exec postfix postqueue -p docker-compose exec postfix postqueue -p
# Test MySQL connectivity from Postfix
docker-compose exec postfix postmap -q "community@lists.sasalliance.org" \
mysql:/etc/postfix/mysql_virtual_alias_maps.cf
# Check API health
curl http://localhost:8000/health
# Access MySQL directly
docker-compose exec mysql mysql -u maillist -p maillist
``` ```
### Development Workflow
1. **Make changes** to code or configuration
2. **Rebuild affected service**:
```bash
sudo docker-compose up --build -d web # Just web frontend
sudo docker-compose up --build -d api # Just API
sudo docker-compose up --build -d postfix # Just mail server
```
3. **Check logs** for errors:
```bash
docker-compose logs -f [service-name]
```
4. **Test changes** via web interface or API
### Common Tasks
**Reset database completely:**
```bash
docker-compose down -v # Removes volumes
docker-compose up -d # Reinitializes from schema.sql
```
**Update API token:**
1. Edit `.env` and change `API_TOKEN`
2. Restart API: `docker-compose restart api`
3. Use new token in web interface
**Add authorized sender domain:**
1. Edit `postfix/sender_access`
2. Add line: `newdomain.com OK`
3. Rebuild: `docker-compose up --build -d postfix`
## How It Works
1. **User manages lists** via web interface (http://localhost:3000)
2. **Web frontend** makes authenticated API calls to FastAPI backend
3. **API updates MySQL** database with new lists/members/subscriptions
4. **Email arrives** at Postfix for `someone@lists.sasalliance.org`
5. **Postfix queries MySQL** in real-time using native MySQL support
6. **MySQL returns** comma-separated list of active member emails
7. **Postfix expands** the alias and delivers to all members via SES
8. **Changes take effect immediately** - no restart or reload needed!
## Features
- ✅ **Real-time Updates** - Changes to lists/members take effect immediately
- ✅ **Native MySQL Integration** - Postfix queries database directly (no scripts)
- ✅ **Web Interface** - Modern, responsive UI for easy management
- ✅ **REST API** - Complete programmatic access with token auth
- ✅ **Sender Whitelist** - Only authorized domains can send to lists
- ✅ **SES Integration** - Reliable email delivery through Amazon SES
- ✅ **Secure** - Private Docker network, token auth, environment-based credentials
- ✅ **Flexible** - Manage via web, API, or direct database access
- ✅ **Scalable** - Database-driven architecture supports many lists and members
## Documentation
- **Web Interface**: See `web/README.md` for frontend features and usage
- **REST API**: See `api/README.md` for complete API reference
- **Database**: See `database/README.md` for schema and SQL examples
- **AI Agents**: See `.github/copilot-instructions.md` for development guidance
## Roadmap ## Roadmap
- [ ] Web frontend for mailing list management - [x] Web frontend for mailing list management
- [ ] SQL database backend for member storage - [x] SQL database backend for member storage
- [ ] Dynamic configuration generation from database - [x] Dynamic configuration with native Postfix MySQL
- [ ] Multi-service Docker Compose architecture - [x] Multi-service Docker Compose architecture
- [ ] Migration tools for static → dynamic configuration - [x] REST API with authentication
- [x] Sender whitelist for authorized domains
- [ ] Email verification workflow for new members
- [ ] Subscription confirmation (double opt-in)
- [ ] List archive functionality
- [ ] Unsubscribe links in emails
- [ ] Rate limiting and anti-spam measures
- [ ] HTTPS support for web interface
- [ ] Admin roles and permissions
## License ## License

View File

@@ -2,6 +2,15 @@
REST API for managing mailing lists and members with token-based authentication. REST API for managing mailing lists and members with token-based authentication.
## Overview
This FastAPI-based REST API provides programmatic access to the mailing list system. It's designed for:
- **Automation**: Scripts and integrations that need to manage lists/members
- **Web Interface**: Powers the frontend at http://localhost:3000
- **Third-party Apps**: Any application that needs mailing list management
**For most users**, the web interface (http://localhost:3000) is more convenient. Use this API when you need programmatic access or automation.
## Base URL ## Base URL
``` ```

View File

@@ -2,26 +2,40 @@
Mailing List Management API Mailing List Management API
FastAPI-based REST API for managing mailing lists and members FastAPI-based REST API for managing mailing lists and members
""" """
from fastapi import FastAPI, HTTPException, Depends, Header from fastapi import FastAPI, HTTPException, Depends, Header, status, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import List, Optional from typing import List, Optional, Annotated
import mysql.connector import mysql.connector
from mysql.connector import Error from mysql.connector import Error
import os import os
import csv import csv
import io import io
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timedelta
import secrets
import bcrypt
from jose import JWTError, jwt
from passlib.context import CryptContext
from enum import Enum
# Configuration # Configuration
API_TOKEN = os.getenv('API_TOKEN', 'change-this-token') API_TOKEN = os.getenv('API_TOKEN', 'change-this-token') # Keep for backward compatibility during transition
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'your-secret-key-change-this-in-production')
JWT_ALGORITHM = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30
SESSION_EXPIRE_HOURS = 24
MYSQL_HOST = os.getenv('MYSQL_HOST', 'mysql') MYSQL_HOST = os.getenv('MYSQL_HOST', 'mysql')
MYSQL_PORT = int(os.getenv('MYSQL_PORT', 3306)) MYSQL_PORT = int(os.getenv('MYSQL_PORT', 3306))
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'maillist') MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'maillist')
MYSQL_USER = os.getenv('MYSQL_USER', 'maillist') MYSQL_USER = os.getenv('MYSQL_USER', 'maillist')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '') MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '')
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# FastAPI app # FastAPI app
app = FastAPI( app = FastAPI(
title="Mailing List Manager API", title="Mailing List Manager API",
@@ -60,14 +74,144 @@ def get_db():
if connection and connection.is_connected(): if connection and connection.is_connected():
connection.close() connection.close()
# Authentication # Role-based access control
class UserRole(str, Enum):
ADMINISTRATOR = "administrator"
OPERATOR = "operator"
READ_ONLY = "read-only"
class CurrentUser(BaseModel):
user_id: int
username: str
role: UserRole
active: bool
# Authentication and authorization
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create a JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
return encoded_jwt
def create_session_id() -> str:
"""Create a secure session ID"""
return secrets.token_urlsafe(32)
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser:
"""Get current authenticated user from JWT token"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# Check if it's the old API token (for backward compatibility during transition)
if credentials.credentials == API_TOKEN:
# Return a fake admin user for legacy API token
return CurrentUser(
user_id=0,
username="legacy_admin",
role=UserRole.ADMINISTRATOR,
active=True
)
# Try to decode JWT token
payload = jwt.decode(credentials.credentials, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
# Get user from database
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM users WHERE username = %s AND active = 1", (username,))
user = cursor.fetchone()
cursor.close()
if user is None:
raise credentials_exception
return CurrentUser(
user_id=user["user_id"],
username=user["username"],
role=UserRole(user["role"]),
active=user["active"]
)
def require_role(required_roles: List[UserRole]):
"""Decorator factory for role-based access control"""
def role_checker(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if current_user.role not in required_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions. Required roles: {[role.value for role in required_roles]}"
)
return current_user
return role_checker
# Convenience functions for common role requirements
def require_admin() -> CurrentUser:
return Depends(require_role([UserRole.ADMINISTRATOR]))
def require_write_access() -> CurrentUser:
return Depends(require_role([UserRole.ADMINISTRATOR, UserRole.OPERATOR]))
def require_read_access() -> CurrentUser:
return Depends(require_role([UserRole.ADMINISTRATOR, UserRole.OPERATOR, UserRole.READ_ONLY]))
# Legacy authentication (for backward compatibility)
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Verify API token""" """Legacy API token verification - deprecated, use get_current_user instead"""
if credentials.credentials != API_TOKEN: if credentials.credentials != API_TOKEN:
raise HTTPException(status_code=401, detail="Invalid authentication token") raise HTTPException(status_code=401, detail="Invalid authentication token")
return credentials.credentials return credentials.credentials
# Pydantic models # Pydantic models for authentication
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
user: dict
class CreateUserRequest(BaseModel):
username: str
password: str
role: UserRole
class UpdateUserRequest(BaseModel):
password: Optional[str] = None
role: Optional[UserRole] = None
active: Optional[bool] = None
class UserResponse(BaseModel):
user_id: int
username: str
role: UserRole
created_at: datetime
last_login: Optional[datetime] = None
active: bool
# Pydantic models for mailing list functionality
class MailingList(BaseModel): class MailingList(BaseModel):
list_id: Optional[int] = None list_id: Optional[int] = None
list_name: str list_name: str
@@ -107,6 +251,179 @@ class BulkImportResult(BaseModel):
subscriptions_added: int subscriptions_added: int
errors: List[str] errors: List[str]
# Authentication routes
@app.post("/auth/login", response_model=TokenResponse)
async def login(login_request: LoginRequest, request: Request):
"""Authenticate user and return JWT token"""
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM users WHERE username = %s AND active = 1", (login_request.username,))
user = cursor.fetchone()
if not user or not verify_password(login_request.password, user["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password"
)
# Update last login
cursor.execute("UPDATE users SET last_login = %s WHERE user_id = %s",
(datetime.utcnow(), user["user_id"]))
conn.commit()
# Create session record
session_id = create_session_id()
expires_at = datetime.utcnow() + timedelta(hours=SESSION_EXPIRE_HOURS)
client_ip = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
cursor.execute("""
INSERT INTO user_sessions (session_id, user_id, expires_at, ip_address, user_agent)
VALUES (%s, %s, %s, %s, %s)
""", (session_id, user["user_id"], expires_at, client_ip, user_agent))
conn.commit()
cursor.close()
# Create JWT token
access_token_expires = timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user["username"]}, expires_delta=access_token_expires
)
return TokenResponse(
access_token=access_token,
expires_in=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
user={
"user_id": user["user_id"],
"username": user["username"],
"role": user["role"],
"active": user["active"]
}
)
@app.post("/auth/logout")
async def logout(current_user: CurrentUser = Depends(get_current_user)):
"""Logout current user (invalidate sessions)"""
if current_user.user_id == 0: # Legacy admin user
return {"message": "Logged out successfully"}
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE user_sessions SET active = 0 WHERE user_id = %s", (current_user.user_id,))
conn.commit()
cursor.close()
return {"message": "Logged out successfully"}
@app.get("/users", response_model=List[UserResponse])
async def get_users(current_user: CurrentUser = require_admin()):
"""Get all users (admin only)"""
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT user_id, username, role, created_at, last_login, active FROM users ORDER BY username")
users = cursor.fetchall()
cursor.close()
return users
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user_request: CreateUserRequest, current_user: CurrentUser = require_admin()):
"""Create a new user (admin only)"""
# Check if username already exists
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT user_id FROM users WHERE username = %s", (user_request.username,))
existing_user = cursor.fetchone()
if existing_user:
raise HTTPException(status_code=400, detail="Username already exists")
# Hash password and create user
password_hash = get_password_hash(user_request.password)
cursor.execute("""
INSERT INTO users (username, password_hash, role)
VALUES (%s, %s, %s)
""", (user_request.username, password_hash, user_request.role.value))
conn.commit()
user_id = cursor.lastrowid
# Return created user
cursor.execute("""
SELECT user_id, username, role, created_at, last_login, active
FROM users WHERE user_id = %s
""", (user_id,))
new_user = cursor.fetchone()
cursor.close()
return new_user
@app.patch("/users/{user_id}", response_model=UserResponse)
async def update_user(user_id: int, updates: UpdateUserRequest, current_user: CurrentUser = require_admin()):
"""Update a user (admin only)"""
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
# Build update query dynamically
update_fields = []
values = []
if updates.password is not None:
update_fields.append("password_hash = %s")
values.append(get_password_hash(updates.password))
if updates.role is not None:
update_fields.append("role = %s")
values.append(updates.role.value)
if updates.active is not None:
update_fields.append("active = %s")
values.append(updates.active)
if not update_fields:
raise HTTPException(status_code=400, detail="No fields to update")
values.append(user_id)
query = f"UPDATE users SET {', '.join(update_fields)} WHERE user_id = %s"
cursor.execute(query, values)
conn.commit()
# Return updated user
cursor.execute("""
SELECT user_id, username, role, created_at, last_login, active
FROM users WHERE user_id = %s
""", (user_id,))
updated_user = cursor.fetchone()
cursor.close()
if not updated_user:
raise HTTPException(status_code=404, detail="User not found")
return updated_user
@app.delete("/users/{user_id}", status_code=204)
async def delete_user(user_id: int, current_user: CurrentUser = require_admin()):
"""Delete a user (admin only)"""
if user_id == current_user.user_id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE user_id = %s", (user_id,))
conn.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="User not found")
cursor.close()
@app.get("/auth/me", response_model=dict)
async def get_current_user_info(current_user: CurrentUser = Depends(get_current_user)):
"""Get current user information"""
return {
"user_id": current_user.user_id,
"username": current_user.username,
"role": current_user.role.value,
"active": current_user.active
}
# Routes # Routes
@app.get("/") @app.get("/")
async def root(): async def root():
@@ -132,7 +449,7 @@ async def health():
# Mailing Lists endpoints # Mailing Lists endpoints
@app.get("/lists", response_model=List[MailingList]) @app.get("/lists", response_model=List[MailingList])
async def get_lists(token: str = Depends(verify_token)): async def get_lists(current_user: CurrentUser = require_read_access()):
"""Get all mailing lists""" """Get all mailing lists"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@@ -142,7 +459,7 @@ async def get_lists(token: str = Depends(verify_token)):
return lists return lists
@app.get("/lists/{list_id}", response_model=MailingList) @app.get("/lists/{list_id}", response_model=MailingList)
async def get_list(list_id: int, token: str = Depends(verify_token)): async def get_list(list_id: int, current_user: CurrentUser = require_read_access()):
"""Get a specific mailing list""" """Get a specific mailing list"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@@ -155,7 +472,7 @@ async def get_list(list_id: int, token: str = Depends(verify_token)):
return mailing_list return mailing_list
@app.post("/lists", response_model=MailingList, status_code=201) @app.post("/lists", response_model=MailingList, status_code=201)
async def create_list(mailing_list: MailingList, token: str = Depends(verify_token)): async def create_list(mailing_list: MailingList, current_user: CurrentUser = require_write_access()):
"""Create a new mailing list""" """Create a new mailing list"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -172,7 +489,7 @@ async def create_list(mailing_list: MailingList, token: str = Depends(verify_tok
raise HTTPException(status_code=400, detail=f"Failed to create list: {str(e)}") raise HTTPException(status_code=400, detail=f"Failed to create list: {str(e)}")
@app.patch("/lists/{list_id}", response_model=MailingList) @app.patch("/lists/{list_id}", response_model=MailingList)
async def update_list(list_id: int, updates: MailingListUpdate, token: str = Depends(verify_token)): async def update_list(list_id: int, updates: MailingListUpdate, current_user: CurrentUser = require_write_access()):
"""Update a mailing list""" """Update a mailing list"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@@ -210,7 +527,7 @@ async def update_list(list_id: int, updates: MailingListUpdate, token: str = Dep
return updated_list return updated_list
@app.delete("/lists/{list_id}", status_code=204) @app.delete("/lists/{list_id}", status_code=204)
async def delete_list(list_id: int, token: str = Depends(verify_token)): async def delete_list(list_id: int, current_user: CurrentUser = require_write_access()):
"""Delete a mailing list""" """Delete a mailing list"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -223,7 +540,7 @@ async def delete_list(list_id: int, token: str = Depends(verify_token)):
# Members endpoints # Members endpoints
@app.get("/members", response_model=List[Member]) @app.get("/members", response_model=List[Member])
async def get_members(token: str = Depends(verify_token)): async def get_members(current_user: CurrentUser = require_read_access()):
"""Get all members""" """Get all members"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@@ -233,7 +550,7 @@ async def get_members(token: str = Depends(verify_token)):
return members return members
@app.get("/members/{member_id}", response_model=Member) @app.get("/members/{member_id}", response_model=Member)
async def get_member(member_id: int, token: str = Depends(verify_token)): async def get_member(member_id: int, current_user: CurrentUser = require_read_access()):
"""Get a specific member""" """Get a specific member"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@@ -246,7 +563,7 @@ async def get_member(member_id: int, token: str = Depends(verify_token)):
return member return member
@app.post("/members", response_model=Member, status_code=201) @app.post("/members", response_model=Member, status_code=201)
async def create_member(member: Member, token: str = Depends(verify_token)): async def create_member(member: Member, current_user: CurrentUser = require_write_access()):
"""Create a new member""" """Create a new member"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -263,7 +580,7 @@ async def create_member(member: Member, token: str = Depends(verify_token)):
raise HTTPException(status_code=400, detail=f"Failed to create member: {str(e)}") raise HTTPException(status_code=400, detail=f"Failed to create member: {str(e)}")
@app.patch("/members/{member_id}", response_model=Member) @app.patch("/members/{member_id}", response_model=Member)
async def update_member(member_id: int, updates: MemberUpdate, token: str = Depends(verify_token)): async def update_member(member_id: int, updates: MemberUpdate, current_user: CurrentUser = require_write_access()):
"""Update a member""" """Update a member"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@@ -296,7 +613,7 @@ async def update_member(member_id: int, updates: MemberUpdate, token: str = Depe
return updated_member return updated_member
@app.delete("/members/{member_id}", status_code=204) @app.delete("/members/{member_id}", status_code=204)
async def delete_member(member_id: int, token: str = Depends(verify_token)): async def delete_member(member_id: int, current_user: CurrentUser = require_write_access()):
"""Delete a member""" """Delete a member"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -309,7 +626,7 @@ async def delete_member(member_id: int, token: str = Depends(verify_token)):
# Subscription endpoints # Subscription endpoints
@app.get("/lists/{list_id}/members", response_model=List[Member]) @app.get("/lists/{list_id}/members", response_model=List[Member])
async def get_list_members(list_id: int, token: str = Depends(verify_token)): async def get_list_members(list_id: int, current_user: CurrentUser = require_read_access()):
"""Get all members of a specific list""" """Get all members of a specific list"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@@ -325,7 +642,7 @@ async def get_list_members(list_id: int, token: str = Depends(verify_token)):
return members return members
@app.post("/subscriptions", status_code=201) @app.post("/subscriptions", status_code=201)
async def subscribe_member(subscription: Subscription, token: str = Depends(verify_token)): async def subscribe_member(subscription: Subscription, current_user: CurrentUser = require_write_access()):
"""Subscribe a member to a list""" """Subscribe a member to a list"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -359,7 +676,7 @@ async def subscribe_member(subscription: Subscription, token: str = Depends(veri
raise HTTPException(status_code=400, detail=f"Failed to create subscription: {str(e)}") raise HTTPException(status_code=400, detail=f"Failed to create subscription: {str(e)}")
@app.delete("/subscriptions") @app.delete("/subscriptions")
async def unsubscribe_member(list_email: EmailStr, member_email: EmailStr, token: str = Depends(verify_token)): async def unsubscribe_member(list_email: EmailStr, member_email: EmailStr, current_user: CurrentUser = require_write_access()):
"""Unsubscribe a member from a list""" """Unsubscribe a member from a list"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -380,7 +697,7 @@ async def unsubscribe_member(list_email: EmailStr, member_email: EmailStr, token
return {"message": "Unsubscribed successfully"} return {"message": "Unsubscribed successfully"}
@app.post("/bulk-import", response_model=BulkImportResult) @app.post("/bulk-import", response_model=BulkImportResult)
async def bulk_import_members(bulk_request: BulkImportRequest, token: str = Depends(verify_token)): async def bulk_import_members(bulk_request: BulkImportRequest, current_user: CurrentUser = require_write_access()):
"""Bulk import members from CSV data and subscribe them to specified lists""" """Bulk import members from CSV data and subscribe them to specified lists"""
result = BulkImportResult( result = BulkImportResult(

View File

@@ -5,3 +5,6 @@ pydantic==2.5.0
pydantic-settings==2.1.0 pydantic-settings==2.1.0
python-multipart==0.0.6 python-multipart==0.0.6
email-validator==2.1.0 email-validator==2.1.0
bcrypt==4.0.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4

View File

@@ -2,6 +2,26 @@
This mailing list system uses MySQL with **Postfix's native MySQL support** for real-time dynamic list management. Postfix queries the database directly for each email - no scripts or reloads needed. This mailing list system uses MySQL with **Postfix's native MySQL support** for real-time dynamic list management. Postfix queries the database directly for each email - no scripts or reloads needed.
## Management Options
**1. Web Interface (Recommended for Non-Technical Users)**
- Access: http://localhost:3000
- Visual interface with tables and forms
- Toggle-based subscription management
- No SQL knowledge required
**2. REST API (Recommended for Automation)**
- Access: http://localhost:8000/docs
- Full CRUD operations via HTTP
- Token authentication
- Perfect for scripts and integrations
**3. Direct MySQL (Recommended for Advanced Users)**
- Full SQL access for complex queries
- Bulk operations and reporting
- Database administration tasks
- Described in detail below
## Database Schema ## Database Schema
Three-table design with many-to-many relationships: Three-table design with many-to-many relationships:
@@ -36,7 +56,38 @@ Three-table design with many-to-many relationships:
## Managing Lists and Members ## Managing Lists and Members
### Via MySQL Client ### Via Web Interface (Easiest)
1. Open http://localhost:3000 in your browser
2. Enter your API_TOKEN (from .env file)
3. Use the tabs to:
- **Lists Tab**: View, create, edit, delete mailing lists
- **Members Tab**: View, add, edit, remove members
- **Subscriptions**: Click "Subscriptions" button on any member to toggle their list memberships
### Via REST API (For Automation)
See `api/README.md` for complete API documentation, or visit http://localhost:8000/docs for interactive docs.
Quick examples:
```bash
# Get all lists
curl -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/lists
# Create member
curl -X POST http://localhost:8000/members \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","email":"john@example.com","active":true}'
# Subscribe to list
curl -X POST http://localhost:8000/subscriptions \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"list_email":"community@lists.sasalliance.org","member_email":"john@example.com"}'
```
### Via MySQL Client (Advanced)
Connect to the database: Connect to the database:

View File

@@ -1,5 +1,37 @@
-- Mail List Manager Database Schema -- Mail List Manager Database Schema
-- Table: users
-- Stores user authentication and authorization information
CREATE TABLE IF NOT EXISTS users (
user_id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, -- bcrypt hash
role ENUM('administrator', 'operator', 'read-only') NOT NULL DEFAULT 'read-only',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL,
active BOOLEAN DEFAULT TRUE,
INDEX idx_username (username),
INDEX idx_role (role),
INDEX idx_active (active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table: user_sessions
-- Stores active user sessions for authentication
CREATE TABLE IF NOT EXISTS user_sessions (
session_id VARCHAR(64) PRIMARY KEY,
user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
ip_address VARCHAR(45), -- Supports both IPv4 and IPv6
user_agent TEXT,
active BOOLEAN DEFAULT TRUE,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at),
INDEX idx_active (active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table: lists -- Table: lists
-- Stores mailing list information -- Stores mailing list information
CREATE TABLE IF NOT EXISTS lists ( CREATE TABLE IF NOT EXISTS lists (
@@ -44,6 +76,12 @@ CREATE TABLE IF NOT EXISTS list_members (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert sample data -- Insert sample data
-- Create default admin user (password: 'password')
-- $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKv0AhDoLlZ7G.i is bcrypt hash of 'password'
INSERT INTO users (username, password_hash, role) VALUES
('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKv0AhDoLlZ7G.i', 'administrator');
INSERT INTO lists (list_name, list_email, description) VALUES INSERT INTO lists (list_name, list_email, description) VALUES
('Community', 'community@lists.sasalliance.org', 'General community announcements'), ('Community', 'community@lists.sasalliance.org', 'General community announcements'),
('Board', 'board@lists.sasalliance.org', 'Board members only'), ('Board', 'board@lists.sasalliance.org', 'Board members only'),

View File

@@ -38,23 +38,29 @@ web/
## Usage ## Usage
### Development ### Accessing the Interface
1. Ensure the API is running on port 8000
2. Open `index.html` in a web browser
3. Enter your API token to authenticate
4. Start managing your mailing lists!
### Production (Docker)
The web frontend is served using Nginx and included in the Docker Compose setup.
**Via Docker (Recommended):**
```bash ```bash
# Build and start all services # Ensure all services are running
docker-compose up --build sudo docker-compose up -d
# Access the web interface # Access the web interface
open http://localhost:3000 open http://localhost:3000
``` ```
**For Development:**
You can also open `index.html` directly in a browser, but the API must be running on port 8000.
### Getting Started
1. **Authenticate**: Enter your API_TOKEN (from `.env` file) when prompted
2. **Manage Lists**: Click the "Lists" tab to view and manage mailing lists
3. **Manage Members**: Click the "Members" tab to add and edit member information
4. **Manage Subscriptions**: Click "Subscriptions" button on any member to toggle their list memberships
The interface saves your token in browser storage, so you won't need to re-enter it on subsequent visits.
## Features Overview ## Features Overview
### Authentication ### Authentication
@@ -65,24 +71,39 @@ open http://localhost:3000
### Subscription Management (New & Improved!) ### Subscription Management (New & Improved!)
#### Member-Centric Subscription Management #### Member-Centric Subscription Management
The subscription management has been completely overhauled for a much better user experience: The subscription management has been completely redesigned for the best user experience:
1. **Access Member Subscriptions**: In the Members tab, click the "Subscriptions" button next to any member **How to Use:**
2. **Visual Toggle Interface**: See all available mailing lists with modern toggle switches 1. Navigate to the **Members** tab
3. **Intuitive Controls**: 2. Find the member you want to manage
- Green toggle = Member is subscribed 3. Click the **"Subscriptions"** button next to their name
- Gray toggle = Member is not subscribed 4. A modal appears showing all available mailing lists with toggle switches
- Click anywhere on a list item to toggle subscription
4. **Batch Operations**: Make multiple changes and save them all at once
5. **Real-time Feedback**: The save button shows how many changes you've made
#### Benefits Over Previous System **Visual Interface:**
- **Much faster** - No need to add subscriptions one by one - **Green toggle** = Member is subscribed to this list
- **Gray toggle** = Member is not subscribed to this list
- **Click anywhere** on a list item to toggle subscription on/off
**Batch Operations:**
- Make multiple changes (subscribe to some lists, unsubscribe from others)
- The "Save Changes" button shows how many changes you've made (e.g., "Save 3 Changes")
- All changes are saved together when you click "Save Changes"
- Click "Cancel" to discard all changes
**Benefits:**
-**Fast** - Toggle multiple subscriptions at once
-**Visual** - See all subscriptions at a glance with color coding -**Visual** - See all subscriptions at a glance with color coding
-**Intuitive** - Toggle switches work like modern mobile apps -**Intuitive** - Works like modern mobile app switches
-**Batch operations** - Change multiple subscriptions simultaneously -**Smart** - Only saves actual changes, not unchanged items
-**Less error-prone** - Clear visual feedback prevents mistakes -**Clear** - Shows exactly how many changes you're about to save
-**Change tracking** - Only saves actual changes, not unchanged items
#### Alternative: Legacy Bulk Subscription
For power users who prefer the old approach:
1. Go to the **Subscriptions** tab
2. Select list and member from dropdowns
3. Click "Add Subscription"
This is still available but the member-centric approach is much more efficient for managing multiple subscriptions.
### Mailing Lists ### Mailing Lists
- View all mailing lists in a clean table - View all mailing lists in a clean table

View File

@@ -19,14 +19,15 @@
</h1> </h1>
<div class="auth-section"> <div class="auth-section">
<div class="auth-controls" id="authControls"> <div class="auth-controls" id="authControls">
<input type="password" id="apiToken" placeholder="Enter API Token" class="token-input"> <input type="text" id="username" placeholder="Username" class="token-input">
<input type="password" id="password" placeholder="Password" class="token-input">
<button class="btn btn-primary" id="loginBtn">Login</button> <button class="btn btn-primary" id="loginBtn">Login</button>
</div> </div>
<div class="user-info" id="userInfo" style="display: none;"> <div class="user-info" id="userInfo" style="display: none;">
<span class="status-indicator"> <div class="user-details">
<i class="fas fa-check-circle"></i> <span class="user-name" id="currentUsername">User</span>
Connected <span class="user-role" id="currentUserRole">role</span>
</span> </div>
<button class="btn btn-secondary" id="logoutBtn">Logout</button> <button class="btn btn-secondary" id="logoutBtn">Logout</button>
</div> </div>
</div> </div>
@@ -38,7 +39,7 @@
<main class="main-content" id="mainContent" style="display: none;"> <main class="main-content" id="mainContent" style="display: none;">
<div class="container"> <div class="container">
<!-- Navigation Tabs --> <!-- Navigation Tabs -->
<nav class="tab-nav"> <nav class="tab-nav" id="tabNav">
<button class="tab-btn active" data-tab="lists"> <button class="tab-btn active" data-tab="lists">
<i class="fas fa-list"></i> <i class="fas fa-list"></i>
Mailing Lists Mailing Lists
@@ -47,6 +48,10 @@
<i class="fas fa-users"></i> <i class="fas fa-users"></i>
Members Members
</button> </button>
<button class="tab-btn" data-tab="users" id="usersTab" style="display: none;">
<i class="fas fa-user-shield"></i>
Users
</button>
</nav> </nav>
<!-- Notification Area --> <!-- Notification Area -->
@@ -126,6 +131,35 @@
</div> </div>
</div> </div>
<!-- Users Tab (Admin Only) -->
<div class="tab-content" id="users-tab">
<div class="section-header">
<h2>User Management</h2>
<button class="btn btn-primary" id="addUserBtn">
<i class="fas fa-plus"></i>
Add User
</button>
</div>
<div class="data-table">
<table class="table" id="usersTable">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Last Login</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersTableBody">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div> </div>
</main> </main>
@@ -462,6 +496,51 @@
</div> </div>
</div> </div>
<!-- Add/Edit User Modal -->
<div class="modal" id="userModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="userModalTitle">Add User</h3>
<button class="modal-close" id="userModalClose">
<i class="fas fa-times"></i>
</button>
</div>
<form id="userForm">
<div class="form-group">
<label for="userName">Username *</label>
<input type="text" id="userName" name="userName" required>
</div>
<div class="form-group">
<label for="userPassword">Password *</label>
<input type="password" id="userPassword" name="userPassword" required>
<div class="form-help" id="passwordHelp">
Leave blank to keep current password (when editing)
</div>
</div>
<div class="form-group">
<label for="userRole">Role *</label>
<select id="userRole" name="userRole" required>
<option value="">Select a role...</option>
<option value="administrator">Administrator - Full access including user management</option>
<option value="operator">Operator - Read/write access to lists and members</option>
<option value="read-only">Read-Only - View-only access</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="userActive" name="userActive" checked>
<span class="checkmark"></span>
Active
</label>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="userCancelBtn">Cancel</button>
<button type="submit" class="btn btn-primary" id="userSubmitBtn">Save</button>
</div>
</form>
</div>
</div>
<!-- Confirmation Modal --> <!-- Confirmation Modal -->
<div class="modal" id="confirmModal"> <div class="modal" id="confirmModal">
<div class="modal-content"> <div class="modal-content">

View File

@@ -145,6 +145,25 @@ body {
gap: var(--space-3); gap: var(--space-3);
} }
.user-details {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-1);
}
.user-name {
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--gray-900);
}
.user-role {
font-size: var(--font-size-xs);
color: var(--gray-500);
text-transform: capitalize;
}
.status-indicator { .status-indicator {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -402,6 +421,32 @@ body {
color: var(--gray-600); color: var(--gray-600);
} }
/* Role badges */
.role-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: capitalize;
}
.role-badge.role-administrator {
background: #fef3c7;
color: #92400e;
}
.role-badge.role-operator {
background: #dbeafe;
color: #1e40af;
}
.role-badge.role-read-only {
background: var(--gray-100);
color: var(--gray-600);
}
/* Action buttons in tables */ /* Action buttons in tables */
.action-buttons { .action-buttons {
display: flex; display: flex;

View File

@@ -104,6 +104,63 @@ class APIClient {
return this.request('/'); return this.request('/');
} }
// Authentication API
async login(username, password) {
const response = await this.request('/auth/login', {
method: 'POST',
body: JSON.stringify({
username: username,
password: password
})
});
if (response.access_token) {
this.setToken(response.access_token);
}
return response;
}
async logout() {
try {
await this.request('/auth/logout', {
method: 'POST'
});
} catch (error) {
// Ignore logout errors, we'll clear the token anyway
}
this.clearToken();
}
async getCurrentUser() {
return this.request('/auth/me');
}
// User management API
async getUsers() {
return this.request('/users');
}
async createUser(userData) {
return this.request('/users', {
method: 'POST',
body: JSON.stringify(userData)
});
}
async updateUser(userId, userData) {
return this.request(`/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(userData)
});
}
async deleteUser(userId) {
return this.request(`/users/${userId}`, {
method: 'DELETE'
});
}
// Mailing Lists API // Mailing Lists API
async getLists() { async getLists() {
return this.request('/lists'); return this.request('/lists');

View File

@@ -6,8 +6,10 @@
class MailingListApp { class MailingListApp {
constructor() { constructor() {
this.isAuthenticated = false; this.isAuthenticated = false;
this.currentUser = null;
this.lists = []; this.lists = [];
this.members = []; this.members = [];
this.users = [];
this.subscriptions = new Map(); // list_id -> members[] this.subscriptions = new Map(); // list_id -> members[]
this.initializeApp(); this.initializeApp();
@@ -20,9 +22,10 @@ class MailingListApp {
this.setupEventListeners(); this.setupEventListeners();
// Check for saved token // Check for saved token
const savedToken = localStorage.getItem('apiToken'); const savedToken = localStorage.getItem('authToken');
if (savedToken) { if (savedToken) {
await this.login(savedToken, false); apiClient.setToken(savedToken);
await this.checkCurrentUser();
} }
} }
@@ -39,8 +42,14 @@ class MailingListApp {
this.logout(); this.logout();
}); });
// Enter key in token input // Enter key in login inputs
document.getElementById('apiToken').addEventListener('keypress', (e) => { document.getElementById('username').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleLogin();
}
});
document.getElementById('password').addEventListener('keypress', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
this.handleLogin(); this.handleLogin();
} }
@@ -50,54 +59,72 @@ class MailingListApp {
document.getElementById('showBulkImportBtn').addEventListener('click', () => { document.getElementById('showBulkImportBtn').addEventListener('click', () => {
uiManager.showBulkImportModal(); uiManager.showBulkImportModal();
}); });
// Add user button (admin only)
document.getElementById('addUserBtn').addEventListener('click', () => {
uiManager.showUserModal();
});
}
/**
* Check if current user is still valid
*/
async checkCurrentUser() {
try {
const user = await apiClient.getCurrentUser();
this.currentUser = user;
this.isAuthenticated = true;
this.showAuthenticatedUI();
await this.loadData();
} catch (error) {
// Token is invalid, clear it
this.logout();
}
} }
/** /**
* Handle login button click * Handle login button click
*/ */
async handleLogin() { async handleLogin() {
const tokenInput = document.getElementById('apiToken'); const usernameInput = document.getElementById('username');
const token = tokenInput.value.trim(); const passwordInput = document.getElementById('password');
const username = usernameInput.value.trim();
const password = passwordInput.value.trim();
if (!token) { if (!username || !password) {
uiManager.showNotification('Please enter an API token', 'error'); uiManager.showNotification('Please enter both username and password', 'error');
return; return;
} }
await this.login(token, true); await this.login(username, password);
} }
/** /**
* Authenticate with API * Authenticate with API
*/ */
async login(token, saveToken = true) { async login(username, password) {
try { try {
uiManager.setLoading(true); uiManager.setLoading(true);
// Set token and test authentication // Login and get token
apiClient.setToken(token); const response = await apiClient.login(username, password);
await apiClient.testAuth(); this.currentUser = response.user;
// Authentication successful
this.isAuthenticated = true; this.isAuthenticated = true;
if (saveToken) { // Save token
localStorage.setItem('apiToken', token); localStorage.setItem('authToken', response.access_token);
}
this.showAuthenticatedUI(); this.showAuthenticatedUI();
await this.loadData(); await this.loadData();
uiManager.showNotification('Successfully connected to API', 'success'); uiManager.showNotification(`Welcome back, ${this.currentUser.username}!`, 'success');
} catch (error) { } catch (error) {
this.isAuthenticated = false; this.isAuthenticated = false;
this.currentUser = null;
apiClient.clearToken(); apiClient.clearToken();
localStorage.removeItem('authToken');
if (saveToken) { uiManager.handleError(error, 'Login failed');
localStorage.removeItem('apiToken');
}
uiManager.handleError(error, 'Authentication failed');
} finally { } finally {
uiManager.setLoading(false); uiManager.setLoading(false);
} }
@@ -106,10 +133,17 @@ class MailingListApp {
/** /**
* Logout * Logout
*/ */
logout() { async logout() {
try {
await apiClient.logout();
} catch (error) {
// Ignore logout errors
}
this.isAuthenticated = false; this.isAuthenticated = false;
this.currentUser = null;
apiClient.clearToken(); apiClient.clearToken();
localStorage.removeItem('apiToken'); localStorage.removeItem('authToken');
this.showUnauthenticatedUI(); this.showUnauthenticatedUI();
uiManager.showNotification('Logged out successfully', 'info'); uiManager.showNotification('Logged out successfully', 'info');
@@ -123,8 +157,23 @@ class MailingListApp {
document.getElementById('userInfo').style.display = 'flex'; document.getElementById('userInfo').style.display = 'flex';
document.getElementById('mainContent').style.display = 'block'; document.getElementById('mainContent').style.display = 'block';
// Clear token input // Clear login inputs
document.getElementById('apiToken').value = ''; document.getElementById('username').value = '';
document.getElementById('password').value = '';
// Update user info display
if (this.currentUser) {
document.getElementById('currentUsername').textContent = this.currentUser.username;
document.getElementById('currentUserRole').textContent = this.currentUser.role;
// Show/hide admin-only features
const isAdmin = this.currentUser.role === 'administrator';
document.getElementById('usersTab').style.display = isAdmin ? 'block' : 'none';
// Show/hide write access features
const hasWriteAccess = this.currentUser.role === 'administrator' || this.currentUser.role === 'operator';
this.updateUIForPermissions(hasWriteAccess);
}
} }
/** /**
@@ -136,6 +185,22 @@ class MailingListApp {
document.getElementById('mainContent').style.display = 'none'; document.getElementById('mainContent').style.display = 'none';
} }
/**
* Update UI elements based on user permissions
*/
updateUIForPermissions(hasWriteAccess) {
// Disable/enable write action buttons
const writeButtons = document.querySelectorAll('[data-requires-write]');
writeButtons.forEach(button => {
button.style.display = hasWriteAccess ? '' : 'none';
});
// Update button attributes for later reference
document.getElementById('addListBtn').setAttribute('data-requires-write', '');
document.getElementById('addMemberBtn').setAttribute('data-requires-write', '');
document.getElementById('showBulkImportBtn').setAttribute('data-requires-write', '');
}
/** /**
* Load all data from API * Load all data from API
*/ */
@@ -154,12 +219,25 @@ class MailingListApp {
this.lists = lists; this.lists = lists;
this.members = members; this.members = members;
// Load users if admin
if (this.currentUser && this.currentUser.role === 'administrator') {
try {
this.users = await apiClient.getUsers();
} catch (error) {
console.warn('Failed to load users:', error);
this.users = [];
}
}
// Load subscriptions for each list // Load subscriptions for each list
await this.loadSubscriptions(); await this.loadSubscriptions();
// Render all views // Render all views
this.renderLists(); this.renderLists();
this.renderMembers(); this.renderMembers();
if (this.currentUser && this.currentUser.role === 'administrator') {
this.renderUsers();
}
} catch (error) { } catch (error) {
uiManager.handleError(error, 'Failed to load data'); uiManager.handleError(error, 'Failed to load data');
@@ -210,6 +288,8 @@ class MailingListApp {
return; return;
} }
const hasWriteAccess = this.currentUser && (this.currentUser.role === 'administrator' || this.currentUser.role === 'operator');
this.lists.forEach(list => { this.lists.forEach(list => {
const row = document.createElement('tr'); const row = document.createElement('tr');
const memberCount = this.subscriptions.get(list.list_id)?.length || 0; const memberCount = this.subscriptions.get(list.list_id)?.length || 0;
@@ -242,24 +322,27 @@ class MailingListApp {
const statusCell = row.cells[4]; const statusCell = row.cells[4];
statusCell.appendChild(uiManager.createStatusBadge(list.active)); statusCell.appendChild(uiManager.createStatusBadge(list.active));
// Add action buttons // Add action buttons only for users with write access
const actionsCell = row.cells[5].querySelector('.action-buttons'); const actionsCell = row.cells[5].querySelector('.action-buttons');
const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => { if (hasWriteAccess) {
uiManager.showListModal(list); const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => {
}); uiManager.showListModal(list);
});
const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => {
uiManager.showConfirmation(
`Are you sure you want to delete the mailing list "${list.list_name}"? This action cannot be undone.`,
async () => {
await this.deleteList(list.list_id);
}
);
});
actionsCell.appendChild(editBtn);
actionsCell.appendChild(deleteBtn);
}
const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => {
uiManager.showConfirmation(
`Are you sure you want to delete the mailing list "${list.list_name}"? This action cannot be undone.`,
async () => {
await this.deleteList(list.list_id);
}
);
});
actionsCell.appendChild(editBtn);
actionsCell.appendChild(deleteBtn);
tbody.appendChild(row); tbody.appendChild(row);
}); });
} }
@@ -287,6 +370,8 @@ class MailingListApp {
return; return;
} }
const hasWriteAccess = this.currentUser && (this.currentUser.role === 'administrator' || this.currentUser.role === 'operator');
this.members.forEach(member => { this.members.forEach(member => {
const row = document.createElement('tr'); const row = document.createElement('tr');
@@ -340,23 +425,108 @@ class MailingListApp {
uiManager.showMemberSubscriptionsModal(member); uiManager.showMemberSubscriptionsModal(member);
}); });
// Only show edit/delete buttons for users with write access
if (hasWriteAccess) {
const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => {
uiManager.showMemberModal(member);
});
const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => {
uiManager.showConfirmation(
`Are you sure you want to delete the member "${member.name}"? This will remove them from all mailing lists.`,
async () => {
await this.deleteMember(member.member_id);
}
);
});
actionsCell.appendChild(subscriptionsBtn);
actionsCell.appendChild(editBtn);
actionsCell.appendChild(deleteBtn);
} else {
actionsCell.appendChild(subscriptionsBtn);
}
tbody.appendChild(row);
});
}
/**
* Render users table (admin only)
*/
renderUsers() {
const tbody = document.getElementById('usersTableBody');
tbody.innerHTML = '';
if (this.users.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="text-center text-muted">
No users found. <a href="#" id="createFirstUser">Create your first user</a>
</td>
</tr>
`;
document.getElementById('createFirstUser').addEventListener('click', (e) => {
e.preventDefault();
uiManager.showUserModal();
});
return;
}
this.users.forEach(user => {
const row = document.createElement('tr');
// Format dates
const createdAt = new Date(user.created_at).toLocaleDateString();
const lastLogin = user.last_login ? new Date(user.last_login).toLocaleDateString() : 'Never';
row.innerHTML = `
<td>
<div class="font-medium">${uiManager.escapeHtml(user.username)}</div>
</td>
<td>
<span class="role-badge role-${user.role}">${user.role.replace('-', ' ')}</span>
</td>
<td>
<div class="text-sm text-muted">${createdAt}</div>
</td>
<td>
<div class="text-sm text-muted">${lastLogin}</div>
</td>
<td></td>
<td>
<div class="action-buttons"></div>
</td>
`;
// Add status badge
const statusCell = row.cells[4];
statusCell.appendChild(uiManager.createStatusBadge(user.active));
// Add action buttons
const actionsCell = row.cells[5].querySelector('.action-buttons');
const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => { const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => {
uiManager.showMemberModal(member); uiManager.showUserModal(user);
}); });
const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => { // Don't allow deletion of current user
uiManager.showConfirmation( if (user.user_id !== this.currentUser.user_id) {
`Are you sure you want to delete the member "${member.name}"? This will remove them from all mailing lists.`, const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => {
async () => { uiManager.showConfirmation(
await this.deleteMember(member.member_id); `Are you sure you want to delete the user "${user.username}"? This action cannot be undone.`,
} async () => {
); await this.deleteUser(user.user_id);
}); }
);
});
actionsCell.appendChild(editBtn);
actionsCell.appendChild(deleteBtn);
} else {
actionsCell.appendChild(editBtn);
}
// Append all buttons
actionsCell.appendChild(subscriptionsBtn);
actionsCell.appendChild(editBtn);
actionsCell.appendChild(deleteBtn);
tbody.appendChild(row); tbody.appendChild(row);
}); });
} }
@@ -410,6 +580,22 @@ class MailingListApp {
uiManager.setLoading(false); uiManager.setLoading(false);
} }
} }
/**
* Delete a user
*/
async deleteUser(userId) {
try {
uiManager.setLoading(true);
await apiClient.deleteUser(userId);
uiManager.showNotification('User deleted successfully', 'success');
await this.loadData();
} catch (error) {
uiManager.handleError(error, 'Failed to delete user');
} finally {
uiManager.setLoading(false);
}
}
} }
// Initialize the application when DOM is loaded // Initialize the application when DOM is loaded

View File

@@ -57,6 +57,10 @@ class UIManager {
this.showMemberModal(); this.showMemberModal();
}); });
document.getElementById('addUserBtn').addEventListener('click', () => {
this.showUserModal();
});
// Member subscriptions modal // Member subscriptions modal
@@ -88,6 +92,11 @@ class UIManager {
this.handleSubscriptionFormSubmit(); this.handleSubscriptionFormSubmit();
}); });
document.getElementById('userForm').addEventListener('submit', (e) => {
e.preventDefault();
this.handleUserFormSubmit();
});
// Confirmation modal // Confirmation modal
document.getElementById('confirmOkBtn').addEventListener('click', () => { document.getElementById('confirmOkBtn').addEventListener('click', () => {
if (this.confirmCallback) { if (this.confirmCallback) {
@@ -245,6 +254,42 @@ class UIManager {
this.showModal(modal); this.showModal(modal);
} }
/**
* Show user modal (add/edit)
*/
showUserModal(userData = null) {
const modal = document.getElementById('userModal');
const title = document.getElementById('userModalTitle');
const form = document.getElementById('userForm');
const passwordHelp = document.getElementById('passwordHelp');
const passwordField = document.getElementById('userPassword');
if (userData) {
// Edit mode
title.textContent = 'Edit User';
document.getElementById('userName').value = userData.username;
document.getElementById('userName').readOnly = true; // Can't change username
passwordField.placeholder = 'Leave blank to keep current password';
passwordField.required = false;
passwordHelp.style.display = 'block';
document.getElementById('userRole').value = userData.role;
document.getElementById('userActive').checked = userData.active;
this.currentEditingItem = userData;
} else {
// Add mode
title.textContent = 'Add User';
form.reset();
document.getElementById('userName').readOnly = false;
passwordField.placeholder = 'Password';
passwordField.required = true;
passwordHelp.style.display = 'none';
document.getElementById('userActive').checked = true;
this.currentEditingItem = null;
}
this.showModal(modal);
}
/** /**
* Show subscription modal * Show subscription modal
*/ */
@@ -400,6 +445,50 @@ class UIManager {
} }
} }
/**
* Handle user form submission
*/
async handleUserFormSubmit() {
const form = document.getElementById('userForm');
const formData = new FormData(form);
const userData = {
username: formData.get('userName'),
role: formData.get('userRole'),
active: formData.get('userActive') === 'on'
};
// Only include password if it's provided (for updates, empty means no change)
const password = formData.get('userPassword');
if (password) {
userData.password = password;
}
try {
this.setLoading(true);
if (this.currentEditingItem) {
// Update existing user
await apiClient.updateUser(this.currentEditingItem.user_id, userData);
this.showNotification('User updated successfully', 'success');
} else {
// Create new user
if (!userData.password) {
throw new Error('Password is required for new users');
}
await apiClient.createUser(userData);
this.showNotification('User created successfully', 'success');
}
this.closeModal(document.getElementById('userModal'));
await window.app.loadData();
} catch (error) {
this.handleError(error, 'Failed to save user');
} finally {
this.setLoading(false);
}
}
/** /**
* Show member subscriptions modal * Show member subscriptions modal
*/ */