845 lines
21 KiB
Markdown
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
|