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
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):**
- `docker-compose.yaml`: Single-service orchestration with SES credentials
- `postfix/`: Complete Postfix container configuration
- Static virtual aliases system for mailing list distribution
1. **Web Frontend** (Port 3000) - Nginx serving modern HTML/CSS/JS interface
2. **REST API** (Port 8000) - FastAPI backend with token authentication
3. **MySQL Database** (Internal) - Stores lists, members, and subscriptions
4. **Postfix Mail Server** (Port 25) - SMTP server with native MySQL integration
**Planned Architecture (Phase 2+):**
- Web frontend for list management (view/add/remove members)
- SQL database backend for member storage
- Dynamic Postfix configuration generation
- Multi-service Docker Compose setup
**Key Architecture Principles:**
- Real-time operation: Changes take effect immediately without Postfix reload
- Native integration: Postfix queries MySQL directly (no Python scripts)
- Security-first: Private Docker network, token auth, sender whitelist
- User-friendly: Web interface for easy management by non-technical users
## 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`
2. **Config Files**: Static configs (`main.cf`, `virtual_aliases.cf`) + dynamic SASL auth file
3. **Postfix Maps**: Hash databases generated at build time (virtual aliases) and runtime (SASL)
1. **Data Flow**: Web UI → REST API → MySQL ← Postfix (real-time queries)
2. **Credentials**: All sensitive data in `.env` file (SES, MySQL, API_TOKEN)
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:
- Member lists stored in SQL database, with members able to join multiple lists
- Web interface for CRUD operations on members
- `virtual_aliases.cf` generated from database at runtime
- Postfix reload triggered by configuration changes
**No Static Configuration:**
- Virtual aliases are stored in MySQL database, not config files
- Changes take effect immediately without container restarts
- Postfix caches query results for performance
## Key Files and Their Roles
- `main.cf`: Core Postfix config - relay through SES, domain settings, security
- `sasl_passwd.template`: Template for SES authentication (uses `${SES_USER}:${SES_PASS}`)
- `virtual_aliases.cf`: Static email forwarding rules (one mailing list currently)
- `entrypoint.sh`: Runtime credential processing and Postfix startup
### Docker & Environment
- `docker-compose.yaml`: Multi-service orchestration (mysql, postfix, api, web)
- `.env`: All credentials and configuration (SES, MySQL, API_TOKEN)
### 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
**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
docker-compose up --build # Build and start mail server
docker-compose logs -f # Monitor mail delivery logs
sudo docker-compose up --build # Build and start all services
sudo docker-compose logs -f # Monitor all service logs
sudo docker-compose logs -f api # Monitor specific service
```
**Adding Mailing Lists (Current):**
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
**Managing Lists and Members:**
**Future Web Interface Workflow:**
1. Access web frontend at configured port
2. Use CRUD interface to manage mailing lists and members
3. Changes automatically update database and regenerate Postfix configs
**Via Web Interface (Recommended):**
1. Open http://localhost:3000
2. Enter API_TOKEN from .env file
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:**
```bash
# From inside container
docker-compose exec postfix bash
echo "Test message" | mail -s "Subject" community@lists.sasalliance.org
# Check logs for SES relay status
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
- 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
- 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
- **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`)
- **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
**Adding Recipients to Existing List (Current):**
Edit `virtual_aliases.cf`, add comma-separated emails, rebuild container.
**Adding Recipients to a List:**
Use web interface or API - changes are immediate!
**New Mailing List (Current):**
Add line to `virtual_aliases.cf`: `newlist@lists.sasalliance.org recipients...`
**Creating New Mailing List:**
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:**
Update `SES_USER`/`SES_PASS` in docker-compose.yaml, restart container.
## Migration Considerations
When implementing the web frontend and database backend:
- 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
1. Edit `.env` file with new credentials
2. Restart affected services:
- SES: `docker-compose restart postfix`
- MySQL: `docker-compose restart mysql`
- API: `docker-compose restart api`

325
README.md
View File

@@ -1,18 +1,23 @@
# 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
**Current (Phase 1):** Static configuration with environment-based credentials
- Postfix container configured as SES relay
- Static virtual aliases for mailing list distribution
- Environment variable configuration for security
**Multi-Service Architecture:**
- **Web Frontend** - Modern responsive interface for managing lists and members (Port 3000)
- **REST API** - FastAPI backend with token authentication (Port 8000)
- **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
- Web frontend for list management (view/add/remove members)
- SQL database for member storage
- Dynamic Postfix configuration generation
**Key Features:**
- Real-time list updates (no Postfix reload needed)
- Native Postfix MySQL queries for instant list expansion
- Token-based API authentication
- Sender whitelisting for authorized board members
- Private Docker network for security
- Complete web-based management interface
## Quick Start
@@ -21,72 +26,192 @@ A containerized mailing list management system built around Postfix as an SMTP r
cp .env.example .env
```
2. Edit `.env` with your SES credentials and configuration:
2. Edit `.env` with your credentials:
```bash
# Required: Your SES credentials
# SES Credentials (Required)
SES_USER=your_ses_access_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)
SMTP_HOST=email-smtp.eu-west-2.amazonaws.com
SMTP_PORT=587
```
3. Build and start the mail server:
3. Build and start all services:
```bash
docker-compose up --build
sudo docker-compose up --build
```
4. Test mail delivery:
```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
4. Access the web interface:
```
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
### Adding Mailing Lists (Current)
### Managing Lists and Members
Edit `postfix/virtual_aliases.cf`:
```
newlist@lists.sasalliance.org recipient1@domain.com, recipient2@domain.com
```
**Via Web Interface (Recommended):**
1. Open http://localhost:3000
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
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:
- **Hostname**: `lists.sasalliance.org` (mailing lists)
# Run SQL queries (see database/README.md for examples)
```
### 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`
- **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
- SES credentials are stored in `.env` (git-ignored)
- SASL password files have restricted permissions (600)
- TLS encryption enforced for SES relay
- Only localhost and configured hostname accepted for local delivery
- **Environment Variables**: All credentials stored in `.env` (git-ignored)
- **Private Network**: MySQL communicates with Postfix/API on internal Docker network only
- **Token Authentication**: API requires Bearer token for all write operations
- **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
### Project Structure
```
├── docker-compose.yaml # Service orchestration
├── docker-compose.yaml # Multi-service orchestration
├── .env # Environment configuration (not in git)
├── postfix/
│ ├── Dockerfile # Postfix container build
│ ├── entrypoint.sh # Runtime configuration processing
├── main.cf.template # Postfix main configuration template
├── sasl_passwd.template # SES authentication template
└── virtual_aliases.cf # Static mailing list definitions
├── web/ # Web frontend (Nginx + HTML/CSS/JS)
│ ├── index.html
│ ├── static/
│ ├── css/style.css
│ └── js/
│ ├── 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/
└── copilot-instructions.md # AI agent guidance
```
@@ -96,28 +221,128 @@ The system is configured for:
- `SES_PASS`: AWS SES secret access key
- `SMTP_HOST`: SMTP server hostname (default: email-smtp.eu-west-2.amazonaws.com)
- `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
Monitor mail delivery:
Monitor services:
```bash
# View all logs
# View all service logs
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)"
# Check Postfix queue
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
- [ ] Web frontend for mailing list management
- [ ] SQL database backend for member storage
- [ ] Dynamic configuration generation from database
- [ ] Multi-service Docker Compose architecture
- [ ] Migration tools for static → dynamic configuration
- [x] Web frontend for mailing list management
- [x] SQL database backend for member storage
- [x] Dynamic configuration with native Postfix MySQL
- [x] Multi-service Docker Compose architecture
- [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

View File

@@ -2,6 +2,15 @@
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
```

View File

@@ -2,26 +2,40 @@
Mailing List Management API
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.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from typing import List, Optional, Annotated
import mysql.connector
from mysql.connector import Error
import os
import csv
import io
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
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_PORT = int(os.getenv('MYSQL_PORT', 3306))
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'maillist')
MYSQL_USER = os.getenv('MYSQL_USER', 'maillist')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '')
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# FastAPI app
app = FastAPI(
title="Mailing List Manager API",
@@ -60,14 +74,144 @@ def get_db():
if connection and connection.is_connected():
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)):
"""Verify API token"""
"""Legacy API token verification - deprecated, use get_current_user instead"""
if credentials.credentials != API_TOKEN:
raise HTTPException(status_code=401, detail="Invalid authentication token")
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):
list_id: Optional[int] = None
list_name: str
@@ -107,6 +251,179 @@ class BulkImportResult(BaseModel):
subscriptions_added: int
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
@app.get("/")
async def root():
@@ -132,7 +449,7 @@ async def health():
# Mailing Lists endpoints
@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"""
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
@@ -142,7 +459,7 @@ async def get_lists(token: str = Depends(verify_token)):
return lists
@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"""
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
@@ -155,7 +472,7 @@ async def get_list(list_id: int, token: str = Depends(verify_token)):
return mailing_list
@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"""
with get_db() as conn:
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)}")
@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"""
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
@@ -210,7 +527,7 @@ async def update_list(list_id: int, updates: MailingListUpdate, token: str = Dep
return updated_list
@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"""
with get_db() as conn:
cursor = conn.cursor()
@@ -223,7 +540,7 @@ async def delete_list(list_id: int, token: str = Depends(verify_token)):
# Members endpoints
@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"""
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
@@ -233,7 +550,7 @@ async def get_members(token: str = Depends(verify_token)):
return members
@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"""
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
@@ -246,7 +563,7 @@ async def get_member(member_id: int, token: str = Depends(verify_token)):
return member
@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"""
with get_db() as conn:
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)}")
@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"""
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
@@ -296,7 +613,7 @@ async def update_member(member_id: int, updates: MemberUpdate, token: str = Depe
return updated_member
@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"""
with get_db() as conn:
cursor = conn.cursor()
@@ -309,7 +626,7 @@ async def delete_member(member_id: int, token: str = Depends(verify_token)):
# Subscription endpoints
@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"""
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
@@ -325,7 +642,7 @@ async def get_list_members(list_id: int, token: str = Depends(verify_token)):
return members
@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"""
with get_db() as conn:
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)}")
@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"""
with get_db() as conn:
cursor = conn.cursor()
@@ -380,7 +697,7 @@ async def unsubscribe_member(list_email: EmailStr, member_email: EmailStr, token
return {"message": "Unsubscribed successfully"}
@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"""
result = BulkImportResult(

View File

@@ -5,3 +5,6 @@ pydantic==2.5.0
pydantic-settings==2.1.0
python-multipart==0.0.6
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.
## 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
Three-table design with many-to-many relationships:
@@ -36,7 +56,38 @@ Three-table design with many-to-many relationships:
## 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:

View File

@@ -1,5 +1,37 @@
-- 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
-- Stores mailing list information
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;
-- 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
('Community', 'community@lists.sasalliance.org', 'General community announcements'),
('Board', 'board@lists.sasalliance.org', 'Board members only'),

View File

@@ -38,23 +38,29 @@ web/
## Usage
### Development
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.
### Accessing the Interface
**Via Docker (Recommended):**
```bash
# Build and start all services
docker-compose up --build
# Ensure all services are running
sudo docker-compose up -d
# Access the web interface
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
### Authentication
@@ -65,24 +71,39 @@ open http://localhost:3000
### Subscription Management (New & Improved!)
#### 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
2. **Visual Toggle Interface**: See all available mailing lists with modern toggle switches
3. **Intuitive Controls**:
- Green toggle = Member is subscribed
- Gray toggle = Member is not subscribed
- 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
**How to Use:**
1. Navigate to the **Members** tab
2. Find the member you want to manage
3. Click the **"Subscriptions"** button next to their name
4. A modal appears showing all available mailing lists with toggle switches
#### Benefits Over Previous System
- **Much faster** - No need to add subscriptions one by one
**Visual Interface:**
- **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
-**Intuitive** - Toggle switches work like modern mobile apps
-**Batch operations** - Change multiple subscriptions simultaneously
-**Less error-prone** - Clear visual feedback prevents mistakes
-**Change tracking** - Only saves actual changes, not unchanged items
-**Intuitive** - Works like modern mobile app switches
-**Smart** - Only saves actual changes, not unchanged items
-**Clear** - Shows exactly how many changes you're about to save
#### 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
- View all mailing lists in a clean table

View File

@@ -19,14 +19,15 @@
</h1>
<div class="auth-section">
<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>
</div>
<div class="user-info" id="userInfo" style="display: none;">
<span class="status-indicator">
<i class="fas fa-check-circle"></i>
Connected
</span>
<div class="user-details">
<span class="user-name" id="currentUsername">User</span>
<span class="user-role" id="currentUserRole">role</span>
</div>
<button class="btn btn-secondary" id="logoutBtn">Logout</button>
</div>
</div>
@@ -38,7 +39,7 @@
<main class="main-content" id="mainContent" style="display: none;">
<div class="container">
<!-- Navigation Tabs -->
<nav class="tab-nav">
<nav class="tab-nav" id="tabNav">
<button class="tab-btn active" data-tab="lists">
<i class="fas fa-list"></i>
Mailing Lists
@@ -47,6 +48,10 @@
<i class="fas fa-users"></i>
Members
</button>
<button class="tab-btn" data-tab="users" id="usersTab" style="display: none;">
<i class="fas fa-user-shield"></i>
Users
</button>
</nav>
<!-- Notification Area -->
@@ -126,6 +131,35 @@
</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>
</main>
@@ -462,6 +496,51 @@
</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 -->
<div class="modal" id="confirmModal">
<div class="modal-content">

View File

@@ -145,6 +145,25 @@ body {
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 {
display: flex;
align-items: center;
@@ -402,6 +421,32 @@ body {
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 {
display: flex;

View File

@@ -104,6 +104,63 @@ class APIClient {
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
async getLists() {
return this.request('/lists');

View File

@@ -6,8 +6,10 @@
class MailingListApp {
constructor() {
this.isAuthenticated = false;
this.currentUser = null;
this.lists = [];
this.members = [];
this.users = [];
this.subscriptions = new Map(); // list_id -> members[]
this.initializeApp();
@@ -20,9 +22,10 @@ class MailingListApp {
this.setupEventListeners();
// Check for saved token
const savedToken = localStorage.getItem('apiToken');
const savedToken = localStorage.getItem('authToken');
if (savedToken) {
await this.login(savedToken, false);
apiClient.setToken(savedToken);
await this.checkCurrentUser();
}
}
@@ -39,8 +42,14 @@ class MailingListApp {
this.logout();
});
// Enter key in token input
document.getElementById('apiToken').addEventListener('keypress', (e) => {
// Enter key in login inputs
document.getElementById('username').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleLogin();
}
});
document.getElementById('password').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleLogin();
}
@@ -50,54 +59,72 @@ class MailingListApp {
document.getElementById('showBulkImportBtn').addEventListener('click', () => {
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
*/
async handleLogin() {
const tokenInput = document.getElementById('apiToken');
const token = tokenInput.value.trim();
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const username = usernameInput.value.trim();
const password = passwordInput.value.trim();
if (!token) {
uiManager.showNotification('Please enter an API token', 'error');
if (!username || !password) {
uiManager.showNotification('Please enter both username and password', 'error');
return;
}
await this.login(token, true);
await this.login(username, password);
}
/**
* Authenticate with API
*/
async login(token, saveToken = true) {
async login(username, password) {
try {
uiManager.setLoading(true);
// Set token and test authentication
apiClient.setToken(token);
await apiClient.testAuth();
// Authentication successful
// Login and get token
const response = await apiClient.login(username, password);
this.currentUser = response.user;
this.isAuthenticated = true;
if (saveToken) {
localStorage.setItem('apiToken', token);
}
// Save token
localStorage.setItem('authToken', response.access_token);
this.showAuthenticatedUI();
await this.loadData();
uiManager.showNotification('Successfully connected to API', 'success');
uiManager.showNotification(`Welcome back, ${this.currentUser.username}!`, 'success');
} catch (error) {
this.isAuthenticated = false;
this.currentUser = null;
apiClient.clearToken();
localStorage.removeItem('authToken');
if (saveToken) {
localStorage.removeItem('apiToken');
}
uiManager.handleError(error, 'Authentication failed');
uiManager.handleError(error, 'Login failed');
} finally {
uiManager.setLoading(false);
}
@@ -106,10 +133,17 @@ class MailingListApp {
/**
* Logout
*/
logout() {
async logout() {
try {
await apiClient.logout();
} catch (error) {
// Ignore logout errors
}
this.isAuthenticated = false;
this.currentUser = null;
apiClient.clearToken();
localStorage.removeItem('apiToken');
localStorage.removeItem('authToken');
this.showUnauthenticatedUI();
uiManager.showNotification('Logged out successfully', 'info');
@@ -123,8 +157,23 @@ class MailingListApp {
document.getElementById('userInfo').style.display = 'flex';
document.getElementById('mainContent').style.display = 'block';
// Clear token input
document.getElementById('apiToken').value = '';
// Clear login inputs
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';
}
/**
* 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
*/
@@ -154,12 +219,25 @@ class MailingListApp {
this.lists = lists;
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
await this.loadSubscriptions();
// Render all views
this.renderLists();
this.renderMembers();
if (this.currentUser && this.currentUser.role === 'administrator') {
this.renderUsers();
}
} catch (error) {
uiManager.handleError(error, 'Failed to load data');
@@ -210,6 +288,8 @@ class MailingListApp {
return;
}
const hasWriteAccess = this.currentUser && (this.currentUser.role === 'administrator' || this.currentUser.role === 'operator');
this.lists.forEach(list => {
const row = document.createElement('tr');
const memberCount = this.subscriptions.get(list.list_id)?.length || 0;
@@ -242,9 +322,10 @@ class MailingListApp {
const statusCell = row.cells[4];
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');
if (hasWriteAccess) {
const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => {
uiManager.showListModal(list);
});
@@ -260,6 +341,8 @@ class MailingListApp {
actionsCell.appendChild(editBtn);
actionsCell.appendChild(deleteBtn);
}
tbody.appendChild(row);
});
}
@@ -287,6 +370,8 @@ class MailingListApp {
return;
}
const hasWriteAccess = this.currentUser && (this.currentUser.role === 'administrator' || this.currentUser.role === 'operator');
this.members.forEach(member => {
const row = document.createElement('tr');
@@ -340,6 +425,8 @@ class MailingListApp {
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);
});
@@ -353,10 +440,93 @@ class MailingListApp {
);
});
// Append all buttons
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', () => {
uiManager.showUserModal(user);
});
// Don't allow deletion of current user
if (user.user_id !== this.currentUser.user_id) {
const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => {
uiManager.showConfirmation(
`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);
}
tbody.appendChild(row);
});
}
@@ -410,6 +580,22 @@ class MailingListApp {
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

View File

@@ -57,6 +57,10 @@ class UIManager {
this.showMemberModal();
});
document.getElementById('addUserBtn').addEventListener('click', () => {
this.showUserModal();
});
// Member subscriptions modal
@@ -88,6 +92,11 @@ class UIManager {
this.handleSubscriptionFormSubmit();
});
document.getElementById('userForm').addEventListener('submit', (e) => {
e.preventDefault();
this.handleUserFormSubmit();
});
// Confirmation modal
document.getElementById('confirmOkBtn').addEventListener('click', () => {
if (this.confirmCallback) {
@@ -245,6 +254,42 @@ class UIManager {
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
*/
@@ -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
*/