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

This is a test email sent from the ${orgName} admin panel to confirm that your SMTP settings are working correctly.

Host: ${host}:${port}
From: ${from || user}

`, }); } 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 };