845 lines
21 KiB
Markdown

# Settings Module
## Overview
The Settings module provides site-wide configuration management with a singleton pattern. It handles organization branding, theme customization, SMTP configuration, and feature toggles. The module implements field-level encryption for sensitive data (SMTP passwords) and provides separate endpoints for public and admin access.
**Key Features:**
- Singleton pattern (one settings record per installation)
- Field-level encryption (SMTP passwords encrypted at rest)
- Public vs. admin endpoints (strips credentials from public responses)
- SMTP configuration with test connection/send
- Email service integration (auto-rebuild transporter on changes)
- Organization branding (name, logo, favicon)
- Theme customization (admin + public color schemes)
- Feature toggles (Influence, Map, Newsletter, Landing Pages)
## File Paths
| File | Purpose |
|------|---------|
| `api/src/modules/settings/settings.routes.ts` | Express router with 5 endpoints |
| `api/src/modules/settings/settings.service.ts` | Settings business logic with encryption |
| `api/src/modules/settings/settings.schemas.ts` | Zod validation schema |
| `api/src/utils/crypto.ts` | AES-256-GCM encryption/decryption |
## Database Model
```prisma
model SiteSettings {
id String @id @default(cuid())
// Organization
organizationName String @default("Changemaker Lite")
organizationShortName String @default("CM")
organizationLogoUrl String?
organizationFaviconUrl String?
// Admin theme
adminColorPrimary String @default("#1890ff")
adminColorBgBase String @default("#ffffff")
// Public theme
publicColorPrimary String @default("#3498db")
publicColorBgBase String @default("#0d1b2a")
publicColorBgContainer String @default("#1b2838")
publicHeaderGradient String?
// Text
footerText String @default("© 2026 Changemaker Lite")
loginSubtitle String @default("Political Infrastructure Platform")
// Email branding
emailFromName String @default("Changemaker Lite")
// SMTP configuration (encrypted at rest)
smtpHost String @default("mailhog")
smtpPort Int @default(1025)
smtpUser String @default("")
smtpPass String @default("") // Encrypted with ENCRYPTION_KEY
smtpFromAddress String @default("noreply@cmlite.org")
smtpActiveProvider String @default("mailhog")
emailTestMode Boolean @default(true)
testEmailRecipient String?
// Feature toggles
enableInfluence Boolean @default(true)
enableMap Boolean @default(true)
enableNewsletter Boolean @default(false)
enableLandingPages Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
**Singleton Pattern:**
- Only one `SiteSettings` record exists in the database
- Auto-created with defaults on first access if missing
- All updates modify the existing record
**Encryption:**
- `smtpPass` encrypted at rest with AES-256-GCM
- Uses `ENCRYPTION_KEY` environment variable (must NOT reuse JWT secrets)
- Decrypted on read, re-encrypted on write
## API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/settings` | None | Get public settings (strips SMTP credentials) |
| GET | `/api/settings/admin` | SUPER_ADMIN | Get full settings (includes SMTP credentials) |
| PUT | `/api/settings` | SUPER_ADMIN | Update settings |
| POST | `/api/settings/email/test-connection` | SUPER_ADMIN | Test SMTP connection |
| POST | `/api/settings/email/test-send` | SUPER_ADMIN | Send test email |
## Endpoint Details
### GET /api/settings
Get public-safe settings (no authentication required). Used by login page and public pages.
**Security:** Strips SMTP credentials before returning:
- `smtpHost`
- `smtpPort`
- `smtpUser`
- `smtpPass`
- `smtpFromAddress`
- `testEmailRecipient`
**Example Request:**
```bash
curl http://api.cmlite.org/api/settings
```
**Response (200 OK):**
```json
{
"id": "clx1234567890",
"organizationName": "Changemaker Lite",
"organizationShortName": "CM",
"organizationLogoUrl": "https://example.com/logo.png",
"organizationFaviconUrl": "https://example.com/favicon.ico",
"adminColorPrimary": "#1890ff",
"adminColorBgBase": "#ffffff",
"publicColorPrimary": "#3498db",
"publicColorBgBase": "#0d1b2a",
"publicColorBgContainer": "#1b2838",
"publicHeaderGradient": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"footerText": "© 2026 Changemaker Lite",
"loginSubtitle": "Political Infrastructure Platform",
"emailFromName": "Changemaker Lite",
"enableInfluence": true,
"enableMap": true,
"enableNewsletter": false,
"enableLandingPages": true,
"createdAt": "2026-02-01T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
}
```
**Implementation:**
```typescript
router.get(
'/',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const settings = await siteSettingsService.getPublic();
res.json(settings);
} catch (err) {
next(err);
}
}
);
```
**Service Logic:**
```typescript
async getPublic(): Promise<Omit<SiteSettings, typeof SENSITIVE_FIELDS[number]>> {
const settings = await this.get();
const result = { ...settings } as Record<string, unknown>;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
return result as Omit<SiteSettings, typeof SENSITIVE_FIELDS[number]>;
}
```
---
### GET /api/settings/admin
Get full settings including SMTP credentials (SUPER_ADMIN only).
**Request Headers:**
```
Authorization: Bearer <access_token>
```
**Example Request:**
```bash
curl -H "Authorization: Bearer <token>" \
http://api.cmlite.org/api/settings/admin
```
**Response (200 OK):**
```json
{
"id": "clx1234567890",
"organizationName": "Changemaker Lite",
"organizationShortName": "CM",
"organizationLogoUrl": "https://example.com/logo.png",
"organizationFaviconUrl": "https://example.com/favicon.ico",
"adminColorPrimary": "#1890ff",
"adminColorBgBase": "#ffffff",
"publicColorPrimary": "#3498db",
"publicColorBgBase": "#0d1b2a",
"publicColorBgContainer": "#1b2838",
"publicHeaderGradient": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"footerText": "© 2026 Changemaker Lite",
"loginSubtitle": "Political Infrastructure Platform",
"emailFromName": "Changemaker Lite",
"smtpHost": "smtp.sendgrid.net",
"smtpPort": 587,
"smtpUser": "apikey",
"smtpPass": "SG.xxxxxxxxxxxx",
"smtpFromAddress": "noreply@cmlite.org",
"smtpActiveProvider": "production",
"emailTestMode": false,
"testEmailRecipient": "admin@example.com",
"enableInfluence": true,
"enableMap": true,
"enableNewsletter": false,
"enableLandingPages": true,
"createdAt": "2026-02-01T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
}
```
**Error Responses:**
- `401 Unauthorized`: Missing or invalid access token
- `403 Forbidden`: Non-SUPER_ADMIN user
**Implementation:**
```typescript
router.get(
'/admin',
authenticate,
requireRole(UserRole.SUPER_ADMIN),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const settings = await siteSettingsService.get();
res.json(settings);
} catch (err) {
next(err);
}
}
);
```
**Decryption:**
```typescript
/** Full settings with encrypted fields decrypted (admin use) */
async get() {
let settings = await prisma.siteSettings.findFirst();
if (!settings) {
settings = await prisma.siteSettings.create({ data: {} });
}
return decryptSettings(settings);
}
function decryptSettings(settings: SiteSettings): SiteSettings {
for (const field of ENCRYPTED_FIELDS) {
const value = settings[field];
if (typeof value === 'string' && value) {
(settings as Record<string, unknown>)[field] = decrypt(value);
}
}
return settings;
}
```
---
### PUT /api/settings
Update site settings (SUPER_ADMIN only). Automatically rebuilds email transporter if SMTP fields change.
**Request Headers:**
```
Authorization: Bearer <access_token>
Content-Type: application/json
```
**Request Body (Partial Update):**
```json
{
"organizationName": "My Campaign",
"organizationShortName": "MC",
"organizationLogoUrl": "https://example.com/new-logo.png",
"publicColorPrimary": "#ff6b6b",
"smtpHost": "smtp.sendgrid.net",
"smtpPort": 587,
"smtpUser": "apikey",
"smtpPass": "SG.new_api_key",
"smtpFromAddress": "hello@mycampaign.org",
"smtpActiveProvider": "production",
"emailTestMode": false,
"enableNewsletter": true
}
```
**All fields are optional (partial updates supported).**
**Response (200 OK):**
Returns updated settings (same format as GET /api/settings/admin).
**Error Responses:**
- `401 Unauthorized`: Missing or invalid access token
- `403 Forbidden`: Non-SUPER_ADMIN user
- `400 Bad Request`: Invalid field values
**Implementation:**
```typescript
router.put(
'/',
authenticate,
requireRole(UserRole.SUPER_ADMIN),
validate(updateSiteSettingsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const settings = await siteSettingsService.update(req.body);
// If SMTP-related fields were updated, rebuild the transporter
const smtpFields = ['smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFromAddress', 'smtpActiveProvider', 'emailTestMode', 'testEmailRecipient'];
const hasSmtpChanges = smtpFields.some((f) => f in req.body);
if (hasSmtpChanges) {
await emailService.rebuildTransporter();
}
res.json(settings);
} catch (err) {
next(err);
}
}
);
```
**Encryption on Write:**
```typescript
async update(data: UpdateSiteSettingsInput) {
// Encrypt sensitive fields before writing to DB
const toWrite = { ...data };
for (const field of ENCRYPTED_FIELDS) {
if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {
(toWrite as Record<string, unknown>)[field] = encrypt(toWrite[field] as string);
}
}
const existing = await prisma.siteSettings.findFirst();
let settings: SiteSettings;
if (existing) {
settings = await prisma.siteSettings.update({
where: { id: existing.id },
data: toWrite,
});
} else {
settings = await prisma.siteSettings.create({ data: toWrite });
}
return decryptSettings(settings);
}
```
**Email Transporter Rebuild:**
When SMTP settings change, the email service transporter is automatically rebuilt:
```typescript
const smtpFields = ['smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFromAddress', 'smtpActiveProvider', 'emailTestMode', 'testEmailRecipient'];
const hasSmtpChanges = smtpFields.some((f) => f in req.body);
if (hasSmtpChanges) {
await emailService.rebuildTransporter();
}
```
---
### POST /api/settings/email/test-connection
Test SMTP connection (SUPER_ADMIN only).
**Request Headers:**
```
Authorization: Bearer <access_token>
```
**Example Request:**
```bash
curl -X POST \
-H "Authorization: Bearer <token>" \
http://api.cmlite.org/api/settings/email/test-connection
```
**Response (200 OK):**
```json
{
"success": true,
"message": "SMTP connection verified"
}
```
**Response (Failure):**
```json
{
"success": false,
"message": "SMTP connection failed"
}
```
**Implementation:**
```typescript
router.post(
'/email/test-connection',
authenticate,
requireRole(UserRole.SUPER_ADMIN),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const success = await emailService.testConnection();
res.json({ success, message: success ? 'SMTP connection verified' : 'SMTP connection failed' });
} catch (err) {
next(err);
}
}
);
```
---
### POST /api/settings/email/test-send
Send test email to verify SMTP configuration (SUPER_ADMIN only).
**Request Headers:**
```
Authorization: Bearer <access_token>
Content-Type: application/json
```
**Request Body (Optional):**
```json
{
"to": "test@example.com"
}
```
**If `to` is not provided, uses `testEmailRecipient` from settings or defaults to `admin@cmlite.org`.**
**Example Request:**
```bash
curl -X POST \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"to":"test@example.com"}' \
http://api.cmlite.org/api/settings/email/test-send
```
**Response (200 OK):**
```json
{
"success": true,
"messageId": "<20260211120000.1.abcd1234@cmlite.org>",
"testMode": false,
"recipient": "test@example.com"
}
```
**Response (Test Mode):**
```json
{
"success": true,
"messageId": "test-mode-1234567890",
"testMode": true,
"recipient": "test@example.com"
}
```
**Implementation:**
```typescript
router.post(
'/email/test-send',
authenticate,
requireRole(UserRole.SUPER_ADMIN),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { to } = req.body as { to?: string };
const settings = await siteSettingsService.get();
const recipient = to || settings.testEmailRecipient || 'admin@cmlite.org';
const result = await emailService.sendEmail({
to: recipient,
subject: 'Changemaker Lite — Test Email',
html: `<h2>SMTP Test Successful</h2><p>This email confirms that your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`,
text: `SMTP Test Successful\n\nThis email confirms that your SMTP configuration is working correctly.\n\nSent at: ${new Date().toISOString()}`,
});
res.json({
success: result.success,
messageId: result.messageId,
testMode: result.testMode,
recipient,
});
} catch (err) {
next(err);
}
}
);
```
**Test Mode:**
If `emailTestMode` is `true`, emails are sent to MailHog instead of actual SMTP server:
- **Development:** MailHog captures emails at http://localhost:8025
- **Production:** Should set `emailTestMode: false` to use real SMTP
## Service Functions
### siteSettingsService.get()
**Purpose:** Get full settings with decrypted SMTP password (admin use).
**Auto-Creation:**
```typescript
let settings = await prisma.siteSettings.findFirst();
if (!settings) {
settings = await prisma.siteSettings.create({ data: {} });
}
return decryptSettings(settings);
```
---
### siteSettingsService.getPublic()
**Purpose:** Get settings without sensitive SMTP fields (public use).
**Stripped Fields:**
- `smtpHost`
- `smtpPort`
- `smtpUser`
- `smtpPass`
- `smtpFromAddress`
- `testEmailRecipient`
---
### siteSettingsService.update(data)
**Purpose:** Update settings with encryption for sensitive fields.
**Encryption:**
```typescript
const toWrite = { ...data };
for (const field of ENCRYPTED_FIELDS) {
if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {
(toWrite as Record<string, unknown>)[field] = encrypt(toWrite[field] as string);
}
}
```
**Upsert Logic:**
```typescript
const existing = await prisma.siteSettings.findFirst();
let settings: SiteSettings;
if (existing) {
settings = await prisma.siteSettings.update({
where: { id: existing.id },
data: toWrite,
});
} else {
settings = await prisma.siteSettings.create({ data: toWrite });
}
return decryptSettings(settings);
```
## Code Examples
### Frontend: Load Public Settings
```typescript
import axios from 'axios';
const loadSettings = async () => {
const { data } = await axios.get('/api/settings');
// Apply theme to Ant Design ConfigProvider
document.title = data.organizationName;
if (data.organizationFaviconUrl) {
const link = document.querySelector("link[rel='icon']") as HTMLLinkElement;
if (link) link.href = data.organizationFaviconUrl;
}
return data;
};
```
### Admin: Update Settings
```typescript
import { api } from '@/lib/api';
const updateSettings = async (updates: Partial<SiteSettings>) => {
const { data } = await api.put('/api/settings', updates);
message.success('Settings updated successfully');
return data;
};
// Usage
await updateSettings({
organizationName: 'My Campaign',
publicColorPrimary: '#ff6b6b',
enableNewsletter: true,
});
```
### Admin: Test SMTP Connection
```typescript
import { api } from '@/lib/api';
const testSmtpConnection = async () => {
try {
const { data } = await api.post('/api/settings/email/test-connection');
if (data.success) {
message.success('SMTP connection verified');
} else {
message.error('SMTP connection failed');
}
return data.success;
} catch (error) {
message.error('Failed to test SMTP connection');
return false;
}
};
```
### Admin: Send Test Email
```typescript
import { api } from '@/lib/api';
const sendTestEmail = async (recipient?: string) => {
try {
const { data } = await api.post('/api/settings/email/test-send', {
to: recipient,
});
if (data.success) {
if (data.testMode) {
message.success(`Test email sent (MailHog mode) to ${data.recipient}`);
} else {
message.success(`Test email sent to ${data.recipient}`);
}
}
return data;
} catch (error) {
message.error('Failed to send test email');
throw error;
}
};
```
## Validation Schema
```typescript
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color (e.g. #ff00ff)');
export const updateSiteSettingsSchema = z.object({
// Organization
organizationName: z.string().min(1).max(100).optional(),
organizationShortName: z.string().min(1).max(10).optional(),
organizationLogoUrl: z.string().url().nullable().optional().or(z.literal('')),
organizationFaviconUrl: z.string().url().nullable().optional().or(z.literal('')),
// Admin theme
adminColorPrimary: hexColor.optional(),
adminColorBgBase: hexColor.optional(),
// Public theme
publicColorPrimary: hexColor.optional(),
publicColorBgBase: hexColor.optional(),
publicColorBgContainer: hexColor.optional(),
publicHeaderGradient: z.string().max(500).optional(),
// Text
footerText: z.string().max(200).optional(),
loginSubtitle: z.string().max(50).optional(),
// Email branding
emailFromName: z.string().min(1).max(100).optional(),
// SMTP configuration
smtpHost: z.string().max(255).optional(),
smtpPort: z.number().int().min(0).max(65535).optional(),
smtpUser: z.string().max(255).optional(),
smtpPass: z.string().max(500).optional(),
smtpFromAddress: z.string().max(255).optional(),
smtpActiveProvider: z.enum(['mailhog', 'production']).optional(),
emailTestMode: z.boolean().optional(),
testEmailRecipient: z.string().max(255).optional(),
// Feature toggles
enableInfluence: z.boolean().optional(),
enableMap: z.boolean().optional(),
enableNewsletter: z.boolean().optional(),
enableLandingPages: z.boolean().optional(),
});
```
## Encryption
### AES-256-GCM Encryption
The `smtpPass` field is encrypted at rest using AES-256-GCM (authenticated encryption).
**Environment Configuration:**
```env
ENCRYPTION_KEY=<32-byte-hex> # Must NOT reuse JWT secrets
```
**Generate Encryption Key:**
```bash
openssl rand -hex 32
```
**Encryption Utility:**
```typescript
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const SALT_LENGTH = 32;
const KEY_LENGTH = 32;
export function encrypt(plaintext: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const salt = crypto.randomBytes(SALT_LENGTH);
const key = crypto.pbkdf2Sync(env.ENCRYPTION_KEY, salt, 100000, KEY_LENGTH, 'sha256');
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
const tag = cipher.getAuthTag();
// Format: iv:salt:tag:ciphertext
return `${iv.toString('base64')}:${salt.toString('base64')}:${tag.toString('base64')}:${encrypted}`;
}
export function decrypt(ciphertext: string): string {
const parts = ciphertext.split(':');
if (parts.length !== 4) throw new Error('Invalid ciphertext format');
const [ivB64, saltB64, tagB64, encrypted] = parts;
const iv = Buffer.from(ivB64, 'base64');
const salt = Buffer.from(saltB64, 'base64');
const tag = Buffer.from(tagB64, 'base64');
const key = crypto.pbkdf2Sync(env.ENCRYPTION_KEY, salt, 100000, KEY_LENGTH, 'sha256');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
```
## Feature Toggles
| Toggle | Default | Description |
|--------|---------|-------------|
| `enableInfluence` | `true` | Advocacy campaigns + response wall |
| `enableMap` | `true` | Location mapping + canvassing |
| `enableNewsletter` | `false` | Listmonk integration |
| `enableLandingPages` | `true` | GrapesJS page builder |
**Frontend Usage:**
```typescript
const settings = await loadSettings();
if (settings.enableInfluence) {
// Show Influence menu items
}
if (settings.enableMap) {
// Show Map menu items
}
```
## Environment Configuration
Required environment variables:
```env
# Encryption (for smtpPass field)
ENCRYPTION_KEY=<32-byte-hex> # Must differ from JWT secrets
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/changemaker_v2
```
## Related Documentation
- [Email Service](/v2/backend/services/email.service.md) - SMTP email sending
- [Crypto Utilities](/v2/backend/utilities/crypto.md) - Encryption/decryption
- [Frontend: SettingsPage](/v2/frontend/pages/admin/settings-page.md) - Settings management UI
- [API Reference: Settings](/v2/api-reference/settings.md) - Complete endpoint reference
- [User Guide: Admin](/v2/user-guides/admin-guide.md) - Configuring site settings