Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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');
|
||||
|
||||
// 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',
|
||||
'accent_color', 'logo_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
|
||||
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) {
|
||||
await transporter.sendMail({
|
||||
from: from || user || 'no-reply@hope-events.local',
|
||||
to: adminEmail,
|
||||
subject: 'SMTP test — Hope Events',
|
||||
text: `This is a test email sent from the Hope Events 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>Hope Events</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 };
|
||||
Reference in New Issue
Block a user