// Admin account route registration and profile helpers. const { fetchAppSettings } = require('#src/data/app-settings'); const { renderAccountEmailTemplate } = require('#src/data/account-email-templates'); module.exports = function registerAccountRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const pages = deps.pages; const formatDashboardDate = deps.formatDashboardDate; const getAuditUserId = deps.getAuditUserId; const verifyPassword = deps.verifyPassword; const hashPassword = deps.hashPassword; const validatePasswordStrength = deps.validatePasswordStrength; const createUserSession = deps.createUserSession; const setSessionCookie = deps.setSessionCookie; const getSessionMaxAgeMs = deps.getSessionMaxAgeMs; const parseCookies = deps.parseCookies; const hashSessionToken = deps.hashSessionToken; const sessionCookieName = deps.sessionCookieName; const recordRequestAuditEvent = deps.recordRequestAuditEvent; const sendAccountEmail = deps.sendAccountEmail; const createOneTimeToken = deps.createOneTimeToken; async function getPasswordRequirements() { const settings = await fetchAppSettings(pool); return buildPasswordRequirements(settings); } function buildPasswordRequirements(settings) { return { minimumLength: settings['security.password_min_length'], minimumCategories: settings['security.password_min_categories'], requireLowercase: settings['security.password_require_lowercase'], requireUppercase: settings['security.password_require_uppercase'], requireNumber: settings['security.password_require_number'], requireSymbol: settings['security.password_require_symbol'] }; } function getSessionMetadata(req) { const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim(); return { ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim(), userAgent: req && req.headers && req.headers['user-agent'] }; } function getRequestOrigin(req) { const forwardedProto = String(req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim(); const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim(); return forwardedHost ? forwardedProto + '://' + forwardedHost : ''; } app.post('/account/email', async function (req, res, next) { try { const email = String(req.body.email || '').trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.'); const settings = await fetchAppSettings(pool); if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') return res.status(400).send('Email delivery is not configured.'); const token = createOneTimeToken(); await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE id = ?', [email, hashSessionToken(token), req.currentUser.id]); const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token); await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] }))); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.email_change_requested', actorUserId: req.currentUser.id, targetType: 'user', targetId: req.currentUser.id, targetLabel: email }); res.redirect('/account?message=' + encodeURIComponent('Check your new email address for a verification link.')); } catch (error) { next(error); } }); app.get('/account', function (req, res) { fetchAppSettings(pool).then(function (settings) { const passwordRequirements = buildPasswordRequirements(settings); if (!settings['security.allow_user_session_revocation']) { return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements)); } const cookies = parseCookies(req.headers.cookie || ''); const tokenHash = cookies[sessionCookieName] ? hashSessionToken(cookies[sessionCookieName]) : ''; return pool.query( 'SELECT id, session_hash, ip_address, user_agent, created_at, last_used_at, expires_at FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at DESC', [req.currentUser.id] ).then(function (result) { const rows = result[0] || []; const sessions = rows.map(function (session) { return { id: Number(session.id), isCurrent: Boolean(tokenHash && session.session_hash === tokenHash), ipAddress: String(session.ip_address || 'Unknown'), userAgent: String(session.user_agent || 'Unknown browser'), createdAtLabel: formatDashboardDate(session.created_at), lastUsedAtLabel: formatDashboardDate(session.last_used_at), expiresAtLabel: formatDashboardDate(session.expires_at) }; }); res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements)); }); }).catch(function (error) { res.status(500).send(error.message || 'Unable to load account settings.'); }); }); app.post('/account/profile', async function (req, res, next) { try { const name = String(req.body.name || '').trim(); const username = common.validateMaxLength(req.body.username || '', 64, 'Username'); const email = String(req.body.email || '').trim().toLowerCase(); const currentPassword = String(req.body.current_password || ''); if (!name) return res.status(400).send('Name is required.'); if (!username) return res.status(400).send('Username is required.'); if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.'); if (!currentPassword) return res.status(400).send('Current password is required.'); const [rows] = await pool.query('SELECT id, username, name, email, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]); const user = rows[0] || null; if (!user) return res.status(404).send('User not found.'); if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.'); if (username !== user.username && await common.fetchDuplicateName(pool, 'a_users', username, user.id, 'username')) return res.status(400).send('That username already exists.'); if (name !== user.name && await common.fetchDuplicateName(pool, 'a_users', name, user.id)) return res.status(400).send('That name already exists.'); const confirmedEmail = String(user.email || '').trim().toLowerCase(); const pendingEmail = String(user.pending_email || '').trim().toLowerCase(); const emailChanged = email !== confirmedEmail && email !== pendingEmail; const pendingEmailCleared = Boolean(pendingEmail && email === confirmedEmail); const settings = emailChanged ? await fetchAppSettings(pool) : null; if (emailChanged && email && (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function')) return res.status(400).send('Email delivery is not configured.'); const actorId = getAuditUserId(req); await pool.query('UPDATE a_users SET username = ?, name = ?, modified_by = ? WHERE id = ?', [username, name, actorId, user.id]); if (emailChanged || pendingEmailCleared) { if (!email) { await pool.query('UPDATE a_users SET email = NULL, email_verified_at = NULL, pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]); } else if (pendingEmailCleared) { await pool.query('UPDATE a_users SET pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]); } else { const token = createOneTimeToken(); await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE id = ?', [email, hashSessionToken(token), user.id]); const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token); await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] }))); } } if (emailChanged && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: email ? 'user.email_change_requested' : 'user.email_cleared', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: email || user.email, details: { previousEmail: confirmedEmail || null, email: email || null, verificationRequired: Boolean(email) } }); res.redirect('/account?message=' + encodeURIComponent(emailChanged && email ? 'Account details updated. Check your new email address for a verification link.' : 'Account details updated.')); } catch (error) { next(error); } }); app.post('/account/sessions/:id/revoke', async function (req, res, next) { try { const sessionId = Number(req.params.id); if (!Number.isInteger(sessionId) || sessionId <= 0) { return res.status(400).send('Invalid session.'); } const settings = await fetchAppSettings(pool); if (!settings['security.allow_user_session_revocation']) { return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.')); } const cookies = parseCookies(req.headers.cookie || ''); const token = cookies[sessionCookieName]; if (!token) { return res.redirect('/account?message=' + encodeURIComponent('Current session could not be identified.')); } const [result] = await pool.query( 'DELETE FROM a_sessions WHERE id = ? AND user_id = ? AND session_hash <> ?', [sessionId, req.currentUser.id, hashSessionToken(token)] ); if (result && result.affectedRows && typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'sessions', eventType: 'session.revoked', actorUserId: req.currentUser.id, targetType: 'user', targetId: req.currentUser.id, targetLabel: req.currentUser.username, details: { scope: 'single' } }); } res.redirect('/account?message=' + encodeURIComponent(result && result.affectedRows ? 'Session signed out.' : 'The current session cannot be signed out.')); } catch (error) { next(error); } }); app.post('/account/sessions/revoke', async function (req, res, next) { try { const settings = await fetchAppSettings(pool); if (!settings['security.allow_user_session_revocation']) { return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.')); } const cookies = parseCookies(req.headers.cookie || ''); const token = cookies[sessionCookieName]; if (token) { await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, hashSessionToken(token)]); } if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'sessions', eventType: 'session.revoked', actorUserId: req.currentUser.id, targetType: 'user', targetId: req.currentUser.id, targetLabel: req.currentUser.username, details: { scope: 'other_sessions' } }); } res.redirect('/account?message=' + encodeURIComponent('Other active sessions were signed out.')); } catch (error) { next(error); } }); app.post('/account/name', async function (req, res, next) { try { const name = String(req.body.name || '').trim(); if (!name) { return res.status(400).send('Name is required.'); } if (await common.fetchDuplicateName(pool, 'a_users', name, req.currentUser.id)) { return res.redirect('/account?message=' + encodeURIComponent('That name already exists.')); } const actorId = getAuditUserId(req); const [result] = await pool.query('UPDATE a_users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]); if (!result.affectedRows) { return res.status(404).send('User not found.'); } res.redirect('/dashboard?message=' + encodeURIComponent('Name updated.')); } catch (error) { next(error); } }); app.post('/account/username', async function (req, res, next) { try { const username = common.validateMaxLength(req.body.username || '', 255, 'Username'); const currentPassword = String(req.body.current_password || ''); if (!username) return res.status(400).send('Username is required.'); if (!currentPassword) return res.status(400).send('Current password is required.'); const [rows] = await pool.query('SELECT id, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]); const user = rows[0] || null; if (!user) return res.status(404).send('User not found.'); if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.'); const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, user.id]); if (existingRows.length) return res.status(400).send('That username already exists.'); await pool.query('UPDATE a_users SET username = ?, modified_by = ? WHERE id = ?', [username, getAuditUserId(req), user.id]); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.username_updated', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: username, details: { previousUsername: user.username, username: username } }); res.redirect('/account?message=' + encodeURIComponent('Username updated.')); } catch (error) { next(error); } }); app.post('/account/password', async function (req, res, next) { try { const currentPassword = String(req.body.current_password || ''); const newPassword = String(req.body.new_password || ''); const confirmPassword = String(req.body.confirm_password || ''); const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]); const user = rows[0] || null; if (!user) { return res.status(404).send('User not found.'); } if (!verifyPassword(currentPassword, user)) { return res.status(400).send('Current password is incorrect.'); } const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements()); if (passwordStrengthMessage) { return res.status(400).send(passwordStrengthMessage); } if (newPassword !== confirmPassword) { return res.status(400).send('New passwords do not match.'); } const passwordRecord = hashPassword(newPassword); await pool.query( 'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_by = ? WHERE id = ?', [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id] ); await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]); if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.changed', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: user.username, details: { source: 'account' } }); } const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined; const token = await createUserSession(pool, user.id, sessionMaxAgeMs, getSessionMetadata(req)); setSessionCookie(res, token, sessionMaxAgeMs); res.redirect('/account?message=' + encodeURIComponent('Password updated.')); } catch (error) { next(error); } }); };