Add admin-configurable branding (colors, logo, favicon) and generic default fallbacks

Site Settings -> Branding now supports a Primary/Secondary/Accent brand color
system applied site-wide (buttons, nav, hover states, links) and to outgoing
email header/CTA colors, plus a favicon upload alongside the existing logo
upload, a live preview panel (website/email x desktop/mobile), and
logo-based color suggestions. The setup wizard's Branding step got the same
treatment. Fixes two related bugs found along the way: the setup wizard's
logo/favicon upload was missing its auth token, and a static favicon.ico in
Next's special app/ convention path was silently overriding the dynamic one.

Also replaces every "Hope Events"/"Hope Family Church" default (org name,
email subjects, WhatsApp messages, report metadata, API docs) with a neutral
"Cross Code" placeholder, and the optional legal settings (operator name, IO
details, website URL, effective date) with obviously-generic placeholders
instead of defaulting to real personal/organisational details -- since this
platform is deployed for multiple organisations. Adds SETTINGS.md documenting
every setting's default behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 14:49:51 +02:00
co-authored by Claude Sonnet 5
parent 2d99b7cafe
commit 86af7093ac
33 changed files with 1174 additions and 198 deletions
+2 -1
View File
@@ -250,7 +250,8 @@ async function renderReportPdfToFile(payload) {
async function buildReportWorkbook(payload) {
const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {};
const wb = new ExcelJS.Workbook();
wb.creator = 'Hope Family Church Events';
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
wb.creator = `${orgName} Events`;
wb.created = new Date();
const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report';
const ws = wb.addWorksheet(sheetName);
+12 -5
View File
@@ -8,7 +8,13 @@ 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',
// 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',
@@ -293,12 +299,13 @@ const testSmtp = async (req, res) => {
// 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@hope-events.local',
from: from || user || 'no-reply@crosscode.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>`,
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>`,
});
}
@@ -70,6 +70,34 @@ const uploadLogo = multer({
}
});
// Favicon storage (same subfolder as the logo — both are "branding" assets)
const faviconStorage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding');
try {
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
} catch (error) {
cb(new Error(`Cannot access upload directory: ${error.message}`));
}
},
filename: function (req, file, cb) {
cb(null, `favicon-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
}
});
const uploadFavicon = multer({
storage: faviconStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
fileFilter: function (req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.ico', '.png', '.svg'].includes(ext)) {
return cb(new Error('Only .ico, .png, or .svg files are allowed for the favicon'), false);
}
cb(null, true);
}
});
// Controller function
const uploadEventImage = (req, res) => {
// Check for multer errors which would be passed in req.multerError
@@ -95,9 +123,21 @@ const uploadLogoImage = (req, res) => {
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
};
const uploadFaviconImage = (req, res) => {
if (req.multerError) {
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
}
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
};
module.exports = {
upload,
uploadEventImage,
uploadLogo,
uploadLogoImage,
uploadFavicon,
uploadFaviconImage,
};
+14 -8
View File
@@ -3,10 +3,17 @@ const { generateToken, hashPassword, comparePassword } = require('../config/auth
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const { getSettingSync } = require('../utils/settingsCache');
const axios = require('axios');
// ─── Helpers ────────────────────────────────────────────────────────────────
// Org name for email subjects — falls back to Cross Code (the platform vendor)
// rather than any specific customer's name when unconfigured.
function getOrgName() {
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
}
// Resolve a client IP from the request (works behind proxies)
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
@@ -41,7 +48,7 @@ async function sendLoginNotification(user, req) {
const { buildWALogin } = require('../utils/waMessages');
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
// Security: always email; also WhatsApp if preferred
await sendMail({ to: user.email, subject: 'New login to your Hope Events account', ...content });
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
const { waText } = require('../utils/notify');
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
} catch (e) {
@@ -63,7 +70,7 @@ async function sendWelcomeEmail(user) {
const content = buildWelcomeEmail({ name: user.name, events });
const { shouldEmail, waText } = require('../utils/notify');
// Welcome is always sent via email; also via WhatsApp if preferred
await sendMail({ to: user.email, subject: 'Welcome to Hope Events!', ...content });
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
} catch (e) {
console.warn('[welcome email] Failed:', e?.message || e);
@@ -185,7 +192,7 @@ const loginUser = async (req, res) => {
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
sendMail({ to: user.email, subject: 'Activate your Hope Events account', ...content })
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
} catch (e) {
console.warn('[activation token] Failed to create activation token:', e?.message || e);
@@ -207,7 +214,7 @@ const loginUser = async (req, res) => {
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events');
const orgName = getOrgName();
const waMessage = [
`🔓 *Activate your ${orgName} account*`,
'',
@@ -394,10 +401,9 @@ const updateUserProfile = async (req, res) => {
// If the password was changed, send a security alert email (fire-and-forget)
if (newHashedPassword) {
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const { getSettingSync } = require('../utils/settingsCache');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
sendMail({ to: updatedUser.email, subject: 'Your Hope Events password was changed', ...content })
sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify');
waText(updatedUser, content.text).catch(() => {});
@@ -903,7 +909,7 @@ const closeAccount = async (req, res) => {
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
// WhatsApp while we still have phone (send before data wipe completes in-flight)
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
}
@@ -924,7 +930,7 @@ const closeAccount = async (req, res) => {
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: false });
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {});
}
return res.json({ message: 'Your account has been deactivated.' });
@@ -195,13 +195,14 @@ const handleWebhook = async (req, res) => {
const { getSettingSync } = require('../utils/settingsCache');
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`;
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
await sendMail({
to: adminEmail,
subject: 'WhatsApp session is down — action required',
text: `The Hope Events WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
html: `<p>The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
text: `The ${orgName} WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
html: `<p>The ${orgName} WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
}).catch(() => {});
}
} catch (e) {