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 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:

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 token
  • 403 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 token
  • 403 Forbidden: Non-SUPER_ADMIN user
  • 400 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: false to 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:

  • smtpHost
  • smtpPort
  • smtpUser
  • smtpPass
  • smtpFromAddress
  • testEmailRecipient

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