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>
334 lines
14 KiB
JavaScript
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 }; |