21 KiB
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
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
SiteSettingsrecord exists in the database - Auto-created with defaults on first access if missing
- All updates modify the existing record
Encryption:
smtpPassencrypted at rest with AES-256-GCM- Uses
ENCRYPTION_KEYenvironment 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:
smtpHostsmtpPortsmtpUsersmtpPasssmtpFromAddresstestEmailRecipient
Example Request:
curl http://api.cmlite.org/api/settings
Response (200 OK):
{
"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:
router.get(
'/',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const settings = await siteSettingsService.getPublic();
res.json(settings);
} catch (err) {
next(err);
}
}
);
Service Logic:
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:
curl -H "Authorization: Bearer <token>" \
http://api.cmlite.org/api/settings/admin
Response (200 OK):
{
"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 token403 Forbidden: Non-SUPER_ADMIN user
Implementation:
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:
/** 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):
{
"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 token403 Forbidden: Non-SUPER_ADMIN user400 Bad Request: Invalid field values
Implementation:
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:
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:
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:
curl -X POST \
-H "Authorization: Bearer <token>" \
http://api.cmlite.org/api/settings/email/test-connection
Response (200 OK):
{
"success": true,
"message": "SMTP connection verified"
}
Response (Failure):
{
"success": false,
"message": "SMTP connection failed"
}
Implementation:
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):
{
"to": "test@example.com"
}
If to is not provided, uses testEmailRecipient from settings or defaults to admin@cmlite.org.
Example Request:
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):
{
"success": true,
"messageId": "<20260211120000.1.abcd1234@cmlite.org>",
"testMode": false,
"recipient": "test@example.com"
}
Response (Test Mode):
{
"success": true,
"messageId": "test-mode-1234567890",
"testMode": true,
"recipient": "test@example.com"
}
Implementation:
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: falseto use real SMTP
Service Functions
siteSettingsService.get()
Purpose: Get full settings with decrypted SMTP password (admin use).
Auto-Creation:
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:
smtpHostsmtpPortsmtpUsersmtpPasssmtpFromAddresstestEmailRecipient
siteSettingsService.update(data)
Purpose: Update settings with encryption for sensitive fields.
Encryption:
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:
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
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
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
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
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
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:
ENCRYPTION_KEY=<32-byte-hex> # Must NOT reuse JWT secrets
Generate Encryption Key:
openssl rand -hex 32
Encryption Utility:
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:
const settings = await loadSettings();
if (settings.enableInfluence) {
// Show Influence menu items
}
if (settings.enableMap) {
// Show Map menu items
}
Environment Configuration
Required environment variables:
# 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 - SMTP email sending
- Crypto Utilities - Encryption/decryption
- Frontend: SettingsPage - Settings management UI
- API Reference: Settings - Complete endpoint reference
- User Guide: Admin - Configuring site settings