Files
pulse-signage/src/web/routes/admin/account.js
T

392 lines
20 KiB
JavaScript

// Admin account route registration and profile helpers.
const { fetchAppSettings } = require('#src/data/app-settings');
const { formatUserAgentLabel } = require('#src/data/audit-log');
const { createSyntheticAuthEmail, toVisibleEmail, mapAuthMethod } = require('#src/data/auth-accounts');
module.exports = function registerAccountRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const betterAuth = deps.betterAuth;
const fromNodeHeaders = deps.fromNodeHeaders;
const formatDashboardDate = deps.formatDashboardDate;
const getAuditUserId = deps.getAuditUserId;
const validatePasswordStrength = deps.validatePasswordStrength;
const clearSessionCookie = deps.clearSessionCookie;
const parseCookies = deps.parseCookies;
const sessionCookieName = deps.sessionCookieName;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
async function verifyCurrentPassword(req, password) {
try {
const result = await betterAuth.api.verifyPassword({
body: { password: password },
headers: fromNodeHeaders(req.headers)
});
return !result || result.status !== false;
} catch (_error) {
return false;
}
}
async function getPasswordRequirements() {
const settings = await fetchAppSettings(pool);
return buildPasswordRequirements(settings);
}
async function getAuthMethods(userId) {
const [rows] = await pool.query('SELECT provider_id FROM a_accounts WHERE user_id = ? ORDER BY created_at ASC', [userId]);
return (rows || []).map(function (row) { return mapAuthMethod(row.provider_id); }).filter(Boolean);
}
async function getPasskeys(userId) {
const [rows] = await pool.query('SELECT id, name, created_at FROM a_user_passkeys WHERE user_id = ? ORDER BY created_at ASC', [userId]);
return rows || [];
}
function isPasswordChangeRequired(req) {
return Boolean(req && (req.authenticatedUser && req.authenticatedUser.mustChangePassword || req.currentUser && req.currentUser.mustChangePassword));
}
async function sendPasswordError(req, res, message) {
if (!isPasswordChangeRequired(req)) {
return res.status(400).send(message);
}
const requirements = await getPasswordRequirements();
return res.status(400).send(pages.renderRequiredPasswordPage(req.currentUser, requirements, message));
}
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']
};
}
async function clearPendingEmailWithoutActiveOtp(user) {
const pendingEmail = String(user && user.pending_email || '').trim().toLowerCase();
if (!pendingEmail) return;
const email = String(user.email || '').trim().toLowerCase();
const identifier = pendingEmail === email
? 'email-verification-otp-' + email
: 'change-email-otp-' + email + '-' + pendingEmail;
await pool.query('DELETE FROM a_verifications WHERE identifier = ? AND expires_at <= NOW()', [identifier]);
const [result] = await pool.query(
`UPDATE a_users
SET pending_email = NULL
WHERE id = ?
AND pending_email = ?
AND NOT EXISTS (
SELECT 1 FROM a_verifications
WHERE identifier = ? AND expires_at > NOW()
)`,
[user.id, user.pending_email, identifier]
);
if (result && result.affectedRows) user.pending_email = null;
}
app.post('/account/email', async function (req, res, next) {
try {
const [rows] = await pool.query('SELECT pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const email = String(rows && rows[0] && rows[0].pending_email || '').trim().toLowerCase();
if (!email) return res.redirect('/account?message=' + encodeURIComponent('There is no pending email change.'));
const settings = await fetchAppSettings(pool);
if (!settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
await betterAuth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: fromNodeHeaders(req.headers) });
res.redirect('/account?message=' + encodeURIComponent('A verification code was sent to your new email address.'));
} catch (error) {
next(error);
}
});
app.post('/account/email/send-verification', async function (req, res, next) {
try {
const email = String(req.currentUser.email || '').trim().toLowerCase();
if (!email) return res.redirect('/account?message=' + encodeURIComponent('Add an email address before requesting a verification code.'));
const settings = await fetchAppSettings(pool);
if (!settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
await pool.query('UPDATE a_users SET pending_email = ? WHERE id = ?', [email, req.currentUser.id]);
await betterAuth.api.sendVerificationOTP({ body: { email: email, type: 'email-verification' } });
res.redirect('/account?message=' + encodeURIComponent('A verification code was sent to your email address.'));
} catch (error) {
next(error);
}
});
app.post('/account/email/verify', async function (req, res, next) {
try {
const otp = String(req.body.otp || '').trim();
const [rows] = await pool.query('SELECT email, email_verified, pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const user = rows && rows[0];
if (!user) return res.status(404).send('User not found.');
const pendingEmail = String(user.pending_email || '').trim().toLowerCase();
if (pendingEmail) {
if (pendingEmail === String(user.email || '').trim().toLowerCase()) {
await betterAuth.api.verifyEmailOTP({ body: { email: pendingEmail, otp: otp } });
} else {
await betterAuth.api.changeEmailEmailOTP({ body: { newEmail: pendingEmail, otp: otp }, headers: fromNodeHeaders(req.headers) });
}
await pool.query('UPDATE a_users SET email = ?, email_verified = 1, pending_email = NULL WHERE id = ?', [pendingEmail, req.currentUser.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'email.verification_completed', targetType: 'user', targetId: req.currentUser.id, targetLabel: pendingEmail });
return res.redirect('/account?message=' + encodeURIComponent('Email address verified.'));
}
res.status(400).send('No email change is awaiting verification. Request a new code and try again.');
} catch (error) {
res.status(400).send('The email code is invalid or expired. Request a new code and try again.');
}
});
app.get('/account', async function (req, res) {
try {
await clearPendingEmailWithoutActiveOtp(req.currentUser);
const settings = await fetchAppSettings(pool);
const passwordRequirements = buildPasswordRequirements(settings);
const authMethods = await getAuthMethods(req.currentUser.id);
const passkeys = await getPasskeys(req.currentUser.id);
if (!settings['security.allow_user_session_revocation']) {
return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements, authMethods, passkeys));
}
const cookies = parseCookies(req.headers.cookie || '');
const sessionToken = String(req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName] || '');
const result = await 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]
);
const rows = result[0] || [];
const sessions = rows.map(function (session) {
return {
id: Number(session.id),
isCurrent: Boolean(sessionToken && session.session_hash === sessionToken),
ipAddress: String(session.ip_address || 'Unknown'),
userAgent: formatUserAgentLabel(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, authMethods, passkeys));
} catch (error) {
res.status(500).send(error.message || 'Unable to load account settings.');
}
});
app.get('/account/password', async function (req, res, next) {
if (!isPasswordChangeRequired(req)) {
return res.redirect('/account');
}
try {
const requirements = await getPasswordRequirements();
res.send(pages.renderRequiredPasswordPage(req.currentUser, requirements));
} catch (error) {
next(error);
}
});
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, pending_email 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 (!(await verifyCurrentPassword(req, currentPassword))) 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 = toVisibleEmail(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']) 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 = ?, email_verified = 0, pending_email = NULL WHERE id = ?', [createSyntheticAuthEmail(user.id), user.id]);
} else if (pendingEmailCleared) {
await pool.query('UPDATE a_users SET pending_email = NULL WHERE id = ?', [user.id]);
} else {
await pool.query('UPDATE a_users SET pending_email = ? WHERE id = ?', [email, user.id]);
try {
await betterAuth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: fromNodeHeaders(req.headers) });
} catch (error) {
await pool.query('UPDATE a_users SET pending_email = NULL WHERE id = ?', [user.id]);
throw error;
}
}
}
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. A verification code was sent to your new email address.' : '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 = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.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, 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 = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName];
if (token) {
await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, 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 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 (!(await verifyCurrentPassword(req, currentPassword))) 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 || '');
if (!currentPassword) {
return sendPasswordError(req, res, 'Current password is incorrect.');
}
const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements());
if (passwordStrengthMessage) {
return sendPasswordError(req, res, passwordStrengthMessage);
}
if (newPassword !== confirmPassword) {
return sendPasswordError(req, res, 'New passwords do not match.');
}
try {
await betterAuth.api.changePassword({
body: { currentPassword: currentPassword, newPassword: newPassword, revokeOtherSessions: true },
headers: fromNodeHeaders(req.headers)
});
} catch (_error) {
return sendPasswordError(req, res, 'Current password is incorrect.');
}
await pool.query('UPDATE a_users SET must_change_password = 0, modified_by = ? WHERE id = ?', [getAuditUserId(req), req.currentUser.id]);
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [req.currentUser.id]);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: 'password.changed',
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: req.currentUser.id,
targetLabel: req.currentUser.username,
details: { source: 'account' }
});
}
if (typeof clearSessionCookie === 'function') clearSessionCookie(res);
res.redirect('/login?message=' + encodeURIComponent('Password updated. Sign in again.'));
} catch (error) {
next(error);
}
});
};