# 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> { const settings = await this.get(); const result = { ...settings } as Record; for (const field of SENSITIVE_FIELDS) { delete result[field]; } return result as Omit; } ``` --- ### GET /api/settings/admin Get full settings including SMTP credentials (SUPER_ADMIN only). **Request Headers:** ``` Authorization: Bearer ``` **Example Request:** ```bash curl -H "Authorization: Bearer " \ 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)[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 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)[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 ``` **Example Request:** ```bash curl -X POST \ -H "Authorization: Bearer " \ 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 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 " \ -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: `

SMTP Test Successful

This email confirms that your SMTP configuration is working correctly.

Sent at: ${new Date().toISOString()}

`, 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)[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) => { 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