RBAC and Doc updates
This commit is contained in:
172
.github/copilot-instructions.md
vendored
172
.github/copilot-instructions.md
vendored
@@ -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
325
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
357
api/main.py
357
api/main.py
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user