Files
hope-events/backend/src/controllers/settingsController.js
T
joshuaandClaude Sonnet 5 54b89d4f4b Add calendar export, SEO, error monitoring, backups, audit trail, and a starter test suite
Six site improvements picked from a "what could be better" review, plus a Jest
test suite covering the two areas with the trickiest money-handling history
in this project (early-bird pricing tranches, donation-leg accounting):

- "Add to calendar" .ics download on event pages and in confirmation emails
- sitemap.xml, robots.txt, and Open Graph/Twitter metadata for public pages
- Sentry error monitoring (backend + frontend), a no-op until SENTRY_DSN is set
- Nightly local pg_dump backups with a Site Settings tab to browse/trigger/download
- Admin audit trail for refunds, donations, manual registrations, event and
  settings changes, and staff-initiated cancellations
- Jest tests reproducing and guarding against the 1.8.0 tranche-pricing bug
  and the 1.4.2 donation-balance-inflation bug

Wallet passes (Google/Apple) were scoped out of this round — Apple Wallet
needs a paid Apple Developer account the project doesn't have yet, and the
user preferred shipping both together later rather than Google alone now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:50:11 +02:00

334 lines
14 KiB
JavaScript

const prisma = require('../config/db');
const { hashPassword, generateToken } = require('../config/auth');
const { safeErrorMessage } = require('../utils/errorUtils');
const { v4: uuidv4 } = require('uuid');
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
// system (accent_color's meaning was repurposed from "the one brand color" to
// "tertiary accent color"; primary_color falls back to accent_color's legacy
// value wherever it's consumed, so older data still resolves sensibly until
// the admin re-saves the Branding tab).
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
'setup_complete', 'app_base_url',
// Legal pages
'legal_operator_name', 'legal_io_name', 'legal_io_email',
'legal_website_url', 'legal_effective_date',
];
// @desc Get public app settings
// @route GET /api/settings
// @access Public
const getSettings = async (req, res) => {
try {
const rows = await prisma.appSetting.findMany({ where: { key: { in: PUBLIC_KEYS } } });
const settings = {};
for (const r of rows) settings[r.key] = r.value;
res.json(settings);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Get ALL app settings (admin view — encrypted fields masked)
// @route GET /api/settings/all
// @access Admin
const getAllSettings = async (req, res) => {
try {
const rows = await prisma.appSetting.findMany();
const settings = {};
for (const r of rows) {
if (ENCRYPTED_KEYS.has(r.key)) {
// Return a sentinel so the UI knows the value is set, without exposing it.
// smtp_user (email address) we can safely return as-is after decryption so
// the admin can see what address is configured; smtp_pass we fully mask.
if (r.key === 'smtp_pass') {
settings[r.key] = r.value ? '••••••••' : '';
} else {
// smtp_user — decrypt and return so admin can see/edit it
try {
settings[r.key] = isEncrypted(r.value) ? decrypt(r.value) : r.value;
} catch {
settings[r.key] = '';
}
}
} else {
settings[r.key] = r.value;
}
}
res.json(settings);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Upsert one or more app settings
// @route PUT /api/settings
// @access Admin
const updateSettings = async (req, res) => {
try {
const updates = req.body;
if (!updates || typeof updates !== 'object') {
res.status(400); throw new Error('Body must be a key→value object');
}
const ops = Object.entries(updates)
.filter(([, v]) => v !== undefined && v !== null)
.map(([key, value]) => {
let storedValue = String(value);
// Encrypt sensitive keys before storing
if (ENCRYPTED_KEYS.has(key)) {
// If the frontend sends the masking sentinel back, skip this key (user didn't change it)
if (storedValue === '••••••••') return null;
if (storedValue === '') {
// Blank = clear the setting
return prisma.appSetting.upsert({
where: { key },
update: { value: '' },
create: { key, value: '' },
});
}
storedValue = encrypt(storedValue);
}
return prisma.appSetting.upsert({
where: { key },
update: { value: storedValue },
create: { key, value: storedValue },
});
})
.filter(Boolean); // remove nulls (masked password skips)
if (ops.length) await prisma.$transaction(ops);
invalidateSettingsCache();
await warmCache(); // ensure in-memory cache reflects the new values before responding
// Log which keys changed, never the values — some settings are secrets (e.g. the
// WAWP token) that aren't even encrypted at rest, let alone fit for an audit log.
if (ops.length) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
ip: getClientIp(req),
});
}
res.json({ message: 'Settings saved' });
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
}
};
// @desc Check whether first-time setup is still needed
// @route GET /api/settings/needs-setup
// @access Public
const needsSetup = async (req, res) => {
try {
const done = await prisma.appSetting.findUnique({ where: { key: 'setup_complete' } });
res.json({ needsSetup: done?.value !== 'true' });
} catch {
// DB unreachable — don't block the app
res.json({ needsSetup: false });
}
};
// @desc Register the first admin account during setup — returns a JWT for use in subsequent setup steps
// @route POST /api/setup/register
// @access Public (one-time only — blocked once users exist)
const setupRegister = async (req, res) => {
try {
const count = await prisma.user.count();
if (count > 0) {
res.status(403); throw new Error('Setup has already been completed');
}
const { adminName, adminEmail, adminPassword } = req.body;
if (!adminName?.trim()) { res.status(400); throw new Error('Admin name is required'); }
if (!adminEmail?.trim()) { res.status(400); throw new Error('Admin email is required'); }
if (!adminPassword || adminPassword.length < 8) {
res.status(400); throw new Error('Password must be at least 8 characters');
}
const hashed = await hashPassword(adminPassword);
const user = await prisma.user.create({
data: {
id: uuidv4(),
name: adminName.trim(),
email: adminEmail.trim().toLowerCase(),
password: hashed,
role: 'admin',
isActive: true,
},
});
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0);
res.json({ token, name: user.name, email: user.email });
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
}
};
// @desc Complete first-time setup: persist settings (admin must already be registered via /api/setup/register)
// @route POST /api/setup
// @access Admin (use token returned by /api/setup/register)
const runSetup = async (req, res) => {
try {
const setupDone = await prisma.appSetting.findUnique({ where: { key: 'setup_complete' } });
if (setupDone?.value === 'true') {
res.status(403); throw new Error('Setup has already been completed');
}
const { settings = {} } = req.body;
const toSave = { ...settings, setup_complete: 'true' };
const ops = Object.entries(toSave)
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
.map(([key, value]) => {
let storedValue = String(value);
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
return prisma.appSetting.upsert({
where: { key },
update: { value: storedValue },
create: { key, value: storedValue },
});
});
await prisma.$transaction(ops);
invalidateSettingsCache();
await warmCache();
res.json({ message: 'Setup complete. You can now log in.' });
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
}
};
/**
* Translates raw nodemailer / Node.js network errors into plain-language messages
* suitable for display to an admin who may not know what ECONNREFUSED means.
*/
function friendlySmtpError(e) {
const code = e.code || '';
const msg = (e.message || '').toLowerCase();
const resp = (e.response || '').toLowerCase();
// Authentication failures (530 = Microsoft "Client not authenticated", 535 = standard auth failure)
if (code === 'EAUTH' || e.responseCode === 535 || e.responseCode === 530 || msg.includes('invalid login') || msg.includes('username and password') || msg.includes('not authenticated') || resp.includes('badcredentials') || resp.includes('authentication')) {
return 'Authentication failed — check your SMTP username and password.';
}
// Wrong certificate / TLS mismatch
if (code === 'ESOCKET' && (msg.includes('wrong version') || msg.includes('ssl') || msg.includes('tls'))) {
return 'TLS/SSL error — try toggling the "Use TLS/SSL" option or switching the port between 465 and 587.';
}
if (msg.includes('unable_to_verify') || msg.includes('self signed') || msg.includes('certificate')) {
return 'SSL certificate error — the server\'s certificate could not be verified. Check the port and TLS setting.';
}
// Connection refused or timed out
if (code === 'ECONNREFUSED') {
return 'Connection refused — no mail server responded on that host and port. Check the host and port settings.';
}
if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT' || msg.includes('timed out')) {
return 'Connection timed out — the server did not respond in time. Check the host and port, or try a different port.';
}
// DNS / hostname not found
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
return 'Host not found — the SMTP hostname could not be resolved. Check for typos in the server address.';
}
// Network unreachable
if (code === 'ENETUNREACH' || code === 'EHOSTUNREACH') {
return 'Network unreachable — the server could not be reached. Check your network connection and the host address.';
}
// Connection reset
if (code === 'ECONNRESET') {
return 'Connection was reset by the server — this can indicate a port mismatch or a firewall block.';
}
// Generic SMTP error with a response code
if (e.responseCode) {
return `SMTP error ${e.responseCode}: ${e.response || e.message}`;
}
// Fallback — strip overly long technical strings but keep it readable
const raw = e.message || 'Unknown error';
const trimmed = raw.length > 120 ? raw.slice(0, 120) + '…' : raw;
return `Could not connect: ${trimmed}`;
}
// @desc Test SMTP connection using current settings (DB or env fallbacks)
// Optionally accepts { host, port, secure, user, pass, from } in the request
// body to test unsaved values without saving them first.
// @route POST /api/settings/test-smtp
// @access Admin
const testSmtp = async (req, res) => {
const nodemailer = require('nodemailer');
const { getSettingSync } = require('../utils/settingsCache');
try {
// Prefer values from the request body so admins can test before saving.
// Fall back to cache → env vars.
const host = req.body.host || getSettingSync('smtp_host', process.env.SMTP_HOST || process.env.EMAIL_HOST || '');
const port = req.body.port || getSettingSync('smtp_port', process.env.SMTP_PORT || process.env.EMAIL_PORT || '587');
const secure = req.body.secure !== undefined
? (req.body.secure === true || req.body.secure === 'true')
: (getSettingSync('smtp_secure', process.env.SMTP_SECURE || 'false').toLowerCase() === 'true');
const user = req.body.user || getSettingSync('smtp_user', process.env.SMTP_USER || process.env.EMAIL_USER || '');
// For pass: if a real value is supplied in body use it; if "••••••••" is sent, read from cache.
let pass = req.body.pass || '';
if (!pass || pass === '••••••••') {
pass = getSettingSync('smtp_pass', process.env.SMTP_PASS || process.env.EMAIL_PASS || '');
} else {
// Body supplied a plaintext password — decrypt it if it happens to be encrypted (shouldn't be, but guard anyway)
const { isEncrypted: _ie, decrypt: _d } = require('../utils/encryption');
if (_ie(pass)) pass = _d(pass);
}
const from = req.body.from || getSettingSync('smtp_from', process.env.MAIL_FROM || process.env.EMAIL_FROM || '');
if (!host) {
res.status(400); throw new Error('SMTP host is not configured');
}
const transporter = nodemailer.createTransport({
host,
port: parseInt(port, 10) || 587,
secure: secure || String(port) === '465',
auth: user && pass ? { user, pass } : undefined,
});
// verify() checks connectivity and authentication without sending a message
await transporter.verify();
// Send a real test email to the authenticated user so there's visible proof
const adminEmail = req.user?.email;
if (adminEmail) {
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
await transporter.sendMail({
from: from || user || 'no-reply@crosscode.local',
to: adminEmail,
subject: `SMTP test — ${orgName}`,
text: `This is a test email sent from the ${orgName} admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
html: `<p>This is a test email sent from the <strong>${orgName}</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
});
}
res.json({ message: `SMTP connection verified${adminEmail ? ` — a test email has been sent to ${adminEmail}` : ''}` });
} catch (e) {
res.status(400).json({ message: friendlySmtpError(e), raw: e.message || String(e) });
}
};
module.exports = { getSettings, getAllSettings, updateSettings, needsSetup, setupRegister, runSetup, testSmtp };