// Admin user route registration and user-role management. const { fetchAppSettings } = require('#src/data/app-settings'); const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates'); const { buildAuditChanges, formatUserAgentLabel } = require('#src/data/audit-log'); module.exports = function registerUsersRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const pages = deps.pages; const formatDashboardDate = deps.formatDashboardDate; const getAuditUserId = deps.getAuditUserId; const recordRequestAuditEvent = deps.recordRequestAuditEvent; const sendAccountEmail = deps.sendAccountEmail; const createOneTimeToken = deps.createOneTimeToken; const hashSessionToken = deps.hashSessionToken; const getRequestOrigin = deps.getRequestOrigin; const hashPassword = deps.hashPassword; const validatePasswordStrength = deps.validatePasswordStrength; const readArrayField = deps.readArrayField; const rbacData = deps.rbacData; const { buildPagination } = require('../../lib/pagination'); const requirePermission = deps.requirePermission; const { buildDuplicateUserName, buildDuplicateUser } = require('../settings/users/duplicate'); const USER_NAME_MAX_LENGTH = 255; const USER_USERNAME_MAX_LENGTH = 255; const USER_EMAIL_MAX_LENGTH = 320; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const LIST_PAGE_SIZE = 25; async function fetchRoleOptions() { return rbacData.fetchRoles(pool); } async function shouldRequirePasswordChange(settingKey) { const settings = await fetchAppSettings(pool); return Boolean(settings[settingKey]); } async function getPasswordRequirements() { const settings = await fetchAppSettings(pool); 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 mapRolesForForm(roles, selectedRoleIds) { const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) { return Number(roleId); }).filter(function (roleId) { return Number.isInteger(roleId) && roleId > 0; })); return (Array.isArray(roles) ? roles : []).map(function (role) { return Object.assign({}, role, { isSelected: selectedIds.has(Number(role.id)) }); }); } async function validateRoleIds(roleIds) { const availableRoles = await fetchRoleOptions(); const validRoleIds = new Set(availableRoles.map(function (role) { return Number(role.id); })); const normalizedRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) { return Number(roleId); }).filter(function (roleId) { return Number.isInteger(roleId) && roleId > 0; }))); if (normalizedRoleIds.some(function (roleId) { return !validRoleIds.has(roleId); })) { return { ok: false, message: 'One or more selected roles are invalid.' }; } return { ok: true, roleIds: normalizedRoleIds }; } function mapInvitationForView(invitation, roleNamesById) { let roleIds = []; try { roleIds = JSON.parse(invitation.role_ids_json || '[]'); } catch (error) { roleIds = []; } return Object.assign({}, invitation, { roleNames: roleIds.map(function (roleId) { return roleNamesById.get(Number(roleId)); }).filter(Boolean).join(', ') || 'No roles assigned', createdAtLabel: formatDashboardDate(invitation.created_at), expiresAtLabel: formatDashboardDate(invitation.expires_at), createdByLabel: invitation.created_by_username || 'System' }); } app.get('/settings/users', requirePermission('users.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const search = common.getSearchQuery(req); const sort = common.getSortQuery(req); const direction = common.getSortDirectionQuery(req); const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); const mappedUsers = (data.users || []).map(function (user) { return Object.assign({}, user, { isCurrentUser: Number(user.id) === Number(req.currentUser.id), createdAtLabel: formatDashboardDate(user.created_at), modifiedAtLabel: formatDashboardDate(user.modified_at), roleNames: String(user.roleNames || '').trim() || 'No roles assigned' }); }); res.send(pages.renderUsersPage({ users: mappedUsers, pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'Users pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } }); app.get('/settings/invitations', requirePermission('invitations.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const search = common.getSearchQuery(req); const sort = common.getSortQuery(req); const direction = common.getSortDirectionQuery(req); const data = await rbacData.fetchInvitationsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); const roles = await fetchRoleOptions(); const roleNamesById = new Map((roles || []).map(function (role) { return [Number(role.id), role.name]; })); const invitations = (data.invitations || []).map(function (invitation) { return mapInvitationForView(invitation, roleNamesById); }); res.send(pages.renderInvitationsPage({ invitations: invitations, pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'invitations', 'Invitation pages'), message: req.query.message ? String(req.query.message) : '' }, req.currentUser)); } catch (error) { next(error); } }); app.post('/settings/invitations/:id/delete', requirePermission('invitations.delete'), async function (req, res, next) { try { const invitationId = Number(req.params.id); if (!Number.isInteger(invitationId) || invitationId <= 0) { return res.status(400).send('Invalid invitation.'); } const [result] = await pool.query('DELETE FROM a_user_invitations WHERE id = ? AND used_at IS NULL', [invitationId]); if (!result || !result.affectedRows) { return res.status(404).send('Invitation not found.'); } if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_deleted', actorUserId: getAuditUserId(req), targetType: 'invitation', targetId: invitationId }); res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation deleted.')); } catch (error) { next(error); } }); app.post('/settings/invitations/:id/resend', requirePermission('invitations.allow'), async function (req, res, next) { try { const invitationId = Number(req.params.id); if (!Number.isInteger(invitationId) || invitationId <= 0) { return res.status(400).send('Invalid invitation.'); } if (typeof sendAccountEmail !== 'function' || typeof createOneTimeToken !== 'function' || typeof hashSessionToken !== 'function') { return res.status(503).send('Email delivery is not available.'); } const settings = await fetchAppSettings(pool); if (!settings['email.smtp_enabled']) { return res.status(503).send('Email delivery is not configured.'); } const [rows] = await pool.query('SELECT email, name, role_ids_json FROM a_user_invitations WHERE id = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1', [invitationId]); if (!rows.length) { return res.status(404).send('Invitation not found.'); } const invitation = rows[0]; const token = createOneTimeToken(); const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token); const expiryHours = Number(settings['email.invitation_expiry_hours']); await sendAccountEmail(settings, Object.assign({ to: invitation.email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: invitation.name || 'there', email: invitation.email, expiry_time: formatAccountEmailExpiry(expiryHours, 'hours'), action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] }))); await pool.query('UPDATE a_user_invitations SET token_hash = ?, expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryHours + ' HOUR), created_at = NOW(), created_by = ? WHERE id = ? AND used_at IS NULL', [hashSessionToken(token), getAuditUserId(req), invitationId]); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_resent', actorUserId: getAuditUserId(req), targetType: 'invitation', targetId: invitationId, targetLabel: invitation.email }); res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation resent.')); } catch (error) { next(error); } }); app.get('/settings/users/new', requirePermission('users.create'), function (req, res) { fetchRoleOptions().then(function (roles) { res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary')); }).catch(function (error) { res.status(500).send(error.message || 'Unable to load roles.'); }); }); app.get('/settings/users/invite', requirePermission('invitations.create'), async function (req, res, next) { try { const roles = await fetchRoleOptions(); res.send(pages.renderUsersInvitePage('', req.currentUser, mapRolesForForm(roles, []), {}, 'success')); } catch (error) { next(error); } }); app.post('/settings/users/invite', requirePermission('invitations.create'), async function (req, res, next) { try { const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase(); const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name'); const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']); const roleCheck = await validateRoleIds(selectedRoleIds); const formValues = { email: email, name: name }; async function renderInviteError(message) { const roles = await fetchRoleOptions(); return res.status(400).send(pages.renderUsersInvitePage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning')); } if (!email || !EMAIL_PATTERN.test(email)) return renderInviteError('Email address is invalid.'); if (!name) return renderInviteError('Display name is required.'); if (!roleCheck.ok) return renderInviteError(roleCheck.message); if (typeof sendAccountEmail !== 'function' || typeof createOneTimeToken !== 'function' || typeof hashSessionToken !== 'function') return renderInviteError('Email delivery is not available.'); const settings = await fetchAppSettings(pool); if (!settings['email.smtp_enabled']) return renderInviteError('Email delivery is not configured.'); const [existingUsers] = await pool.query('SELECT id FROM a_users WHERE email = ? OR pending_email = ? LIMIT 1', [email, email]); if (existingUsers.length) return renderInviteError('That email address is already associated with an account.'); const [recentInvites] = await pool.query('SELECT COUNT(*) AS invite_count FROM a_user_invitations WHERE created_by = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)', [getAuditUserId(req)]); if (Number(recentInvites[0] && recentInvites[0].invite_count) >= 25) return renderInviteError('Invitation sending is temporarily limited. Try again later.'); const token = createOneTimeToken(); await pool.query('UPDATE a_user_invitations SET used_at = NOW() WHERE email = ? AND used_at IS NULL', [email]); const expiryHours = Number(settings['email.invitation_expiry_hours']); await pool.query('INSERT INTO a_user_invitations (email, name, role_ids_json, token_hash, expires_at, created_by) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ' + expiryHours + ' HOUR), ?)', [email, name || null, JSON.stringify(roleCheck.roleIds), hashSessionToken(token), getAuditUserId(req)]); const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token); try { await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: name || 'there', email: email, expiry_time: formatAccountEmailExpiry(expiryHours, 'hours'), action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] }))); } catch (mailError) { await pool.query('DELETE FROM a_user_invitations WHERE token_hash = ?', [hashSessionToken(token)]); throw mailError; } if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_sent', actorUserId: getAuditUserId(req), targetType: 'email', targetLabel: email, details: { roleIds: roleCheck.roleIds } }); res.redirect('/settings/users?message=' + encodeURIComponent('Invitation sent.')); } catch (error) { next(error); } }); app.get('/settings/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) { try { const userId = Number(req.params.id); if (!Number.isInteger(userId) || userId <= 0) { return res.status(400).send('Invalid user.'); } const user = await rbacData.fetchUserWithRoles(pool, userId); if (!user) { return res.status(404).send('User not found.'); } if (Number(req.currentUser.id) === userId) { return res.redirect('/settings/users?message=' + encodeURIComponent('Use Add user to create another login for yourself.')); } const roles = await fetchRoleOptions(); const duplicateUser = buildDuplicateUser(user, buildDuplicateUserName(user.name)); res.send(pages.renderUsersAddPage( 'Review the copied values and save when ready.', req.currentUser, mapRolesForForm(roles, user.roleIds), { name: duplicateUser.name }, 'primary' )); } catch (error) { next(error); } }); app.get('/settings/users/:id/edit', requirePermission('users.update'), async function (req, res, next) { try { const userId = Number(req.params.id); if (!Number.isInteger(userId) || userId <= 0) { return res.status(400).send('Invalid user.'); } const user = await rbacData.fetchUserWithRoles(pool, userId); if (!user) { return res.status(404).send('User not found.'); } if (Number(req.currentUser.id) === userId) { return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.')); } const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users'); const canDelete = !countRows.length || Number(countRows[0].user_count) > 1; const roles = await fetchRoleOptions(); const sessions = (await rbacData.fetchActiveUserSessions(pool, userId)).map(function (session) { return { id: Number(session.id), 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.renderUsersEditPage(Object.assign({}, user, { isCurrentUser: false, createdAtLabel: formatDashboardDate(user.created_at), modifiedAtLabel: formatDashboardDate(user.modified_at), roleNames: String(user.roleNames || '').trim() || 'No roles assigned', inUse: !canDelete }), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds), sessions)); } catch (error) { next(error); } }); app.post('/settings/users/:id/sessions/:sessionId/revoke', requirePermission('users.update'), async function (req, res, next) { try { const userId = Number(req.params.id); const sessionId = Number(req.params.sessionId); if (!Number.isInteger(userId) || userId <= 0 || !Number.isInteger(sessionId) || sessionId <= 0) { return res.status(400).send('Invalid session.'); } if (Number(req.currentUser.id) === userId) { return res.redirect('/account?message=' + encodeURIComponent('Use My Account to manage your own sessions.')); } const [result] = await pool.query('DELETE FROM a_sessions WHERE id = ? AND user_id = ?', [sessionId, userId]); res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(result && result.affectedRows ? 'Session signed out.' : 'Session was not found.')); } catch (error) { next(error); } }); app.post('/settings/users/:id/sessions/revoke-all', requirePermission('users.update'), async function (req, res, next) { try { const userId = Number(req.params.id); if (!Number.isInteger(userId) || userId <= 0) { return res.status(400).send('Invalid user.'); } if (Number(req.currentUser.id) === userId) { return res.redirect('/account?message=' + encodeURIComponent('Use My Account to manage your own sessions.')); } await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]); res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('All sessions signed out.')); } catch (error) { next(error); } }); app.post('/settings/users', requirePermission('users.create'), async function (req, res, next) { const connection = await pool.getConnection(); try { const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name'); const username = common.validateMaxLength(req.body.username || '', USER_USERNAME_MAX_LENGTH, 'Username'); const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase(); const password = String(req.body.password || ''); const confirmPassword = String(req.body.confirm_password || ''); const saveAction = String(req.body.save_action || req.body.action || '').trim().toLowerCase(); const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']); const roleCheck = await validateRoleIds(selectedRoleIds); const formValues = { username: username, name: name, email: email }; async function renderValidationError(message) { const roles = await fetchRoleOptions(); return res.status(400).send(pages.renderUsersAddPage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning')); } if (!name) { return renderValidationError('Name is required.'); } if (!username) { return renderValidationError('Username is required.'); } if (email && !EMAIL_PATTERN.test(email)) { return renderValidationError('Email address is invalid.'); } const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements()); if (passwordStrengthMessage) { return renderValidationError(passwordStrengthMessage); } if (password !== confirmPassword) { return renderValidationError('Passwords do not match.'); } if (!roleCheck.ok) { return renderValidationError(roleCheck.message); } if (await common.fetchDuplicateName(pool, 'a_users', name)) { return renderValidationError('That name already exists.'); } const [existingRows] = await connection.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]); if (existingRows.length) { return renderValidationError('That username already exists.'); } const passwordRecord = hashPassword(password); const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_for_new_users'); const actorId = getAuditUserId(req); await connection.beginTransaction(); const [result] = await connection.query( 'INSERT INTO a_users (name, username, email, password_hash, password_salt, password_iterations, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [name, username, email || null, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, actorId, actorId] ); await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds); await connection.commit(); if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.created', actorUserId: actorId, targetType: 'user', targetId: result.insertId, targetLabel: username, details: { roleIds: roleCheck.roleIds } }); } if (saveAction === 'new') { return res.redirect('/settings/users/new?message=' + encodeURIComponent('User created.')); } res.redirect('/settings/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.')); } catch (error) { try { await connection.rollback(); } catch (_rollbackError) { // Ignore rollback failures and surface the original error. } next(error); } finally { connection.release(); } }); app.post('/settings/users/:id/roles', requirePermission('users.update'), async function (req, res, next) { try { const userId = Number(req.params.id); if (!Number.isInteger(userId) || userId <= 0) { return res.status(400).send('Invalid user.'); } if (Number(req.currentUser.id) === userId) { return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.')); } const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]); if (!rows.length) { return res.status(404).send('User not found.'); } const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']); const roleCheck = await validateRoleIds(selectedRoleIds); if (!roleCheck.ok) { return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message)); } const existingUser = await rbacData.fetchUserWithRoles(pool, userId); const changes = buildAuditChanges({ roleIds: existingUser ? existingUser.roleIds : [] }, { roleIds: roleCheck.roleIds }); await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds); if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.roles_updated', actorUserId: req.currentUser.id, targetType: 'user', targetId: userId, targetLabel: String(userId), details: { changes: changes } }); } res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.')); } catch (error) { next(error); } }); app.post('/settings/users/:id/username', requirePermission('users.update'), async function (req, res, next) { const connection = await pool.getConnection(); try { const userId = Number(req.params.id); const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name'); const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']); const password = String(req.body.password || ''); const confirmPassword = String(req.body.confirm_password || ''); const shouldUpdatePassword = Boolean(password || confirmPassword); const saveAction = String(req.body.save_action || req.body.action || '').trim().toLowerCase(); if (!Number.isInteger(userId) || userId <= 0) { return res.status(400).send('Invalid user.'); } if (Number(req.currentUser.id) === userId) { return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.')); } const user = await rbacData.fetchUserWithRoles(pool, userId); if (!user) { return res.status(404).send('User not found.'); } const username = common.validateMaxLength(req.body.username || user.username || '', USER_USERNAME_MAX_LENGTH, 'Username'); const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase(); const accountLocked = req.body.account_locked === '1' || (Array.isArray(req.body.account_locked) && req.body.account_locked.includes('1')); const emailVerified = req.body.email_verified === '1' || (Array.isArray(req.body.email_verified) && req.body.email_verified.includes('1')); const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users'); const canDelete = !countRows.length || Number(countRows[0].user_count) > 1; const roleCheck = await validateRoleIds(selectedRoleIds); async function renderValidationError(message) { const roles = await fetchRoleOptions(); return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, { name: name || user.name, username: username || user.username, roleIds: selectedRoleIds, inUse: !canDelete }), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds))); } if (!name) { return renderValidationError('Name is required.'); } if (!username) { return renderValidationError('Username is required.'); } if (email && !EMAIL_PATTERN.test(email)) { return renderValidationError('Email address is invalid.'); } if (shouldUpdatePassword) { const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements()); if (passwordStrengthMessage) { return renderValidationError(passwordStrengthMessage); } } if (shouldUpdatePassword && password !== confirmPassword) { return renderValidationError('Passwords do not match.'); } if (!roleCheck.ok) { return renderValidationError(roleCheck.message); } if (await common.fetchDuplicateName(pool, 'a_users', name, userId)) { return renderValidationError('That name already exists.'); } const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]); if (existingRows.length) { return renderValidationError('That username already exists.'); } const changes = buildAuditChanges({ name: user.name, username: user.username, email: user.email || '', roleIds: user.roleIds, accountLocked: Boolean(user.account_locked), passwordReset: false }, { name: name, username: username, email: email, roleIds: roleCheck.roleIds, accountLocked: accountLocked, passwordReset: shouldUpdatePassword }); await connection.beginTransaction(); const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]); if (!result.affectedRows) { await connection.rollback(); return res.status(404).send('User not found.'); } await connection.query('UPDATE a_users SET email = ?, email_verified_at = CASE WHEN email = ? THEN email_verified_at ELSE NULL END, modified_by = ? WHERE id = ?', [email || null, email || null, getAuditUserId(req), userId]); if (emailVerified && email && (await fetchAppSettings(pool))['security.allow_admin_email_verification_bypass']) { await connection.query('UPDATE a_users SET email_verified_at = NOW() WHERE id = ? AND email = ?', [userId, email]); } await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds); if (accountLocked) { await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]); } if (shouldUpdatePassword) { const passwordRecord = hashPassword(password); const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset'); await connection.query( 'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?', [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId] ); await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]); } await connection.commit(); if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.updated', actorUserId: req.currentUser.id, targetType: 'user', targetId: userId, targetLabel: username, details: { changes: changes } }); if (Boolean(user.account_locked) !== accountLocked) { await recordRequestAuditEvent(pool, req, { category: 'security', eventType: accountLocked ? 'account.locked' : 'account.unlocked', actorUserId: req.currentUser.id, targetType: 'user', targetId: userId, targetLabel: username }); } if (shouldUpdatePassword) { await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.reset', actorUserId: req.currentUser.id, targetType: 'user', targetId: userId, targetLabel: username, details: { source: 'administrator' } }); } if (emailVerified && !user.email_verified_at && email) { await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'email.verification_bypassed', actorUserId: req.currentUser.id, targetType: 'user', targetId: userId, targetLabel: username, details: { source: 'administrator' } }); } } if (saveAction === 'new') { return res.redirect('/settings/users/new?message=' + encodeURIComponent('User updated.')); } res.redirect('/settings/users?message=' + encodeURIComponent('User updated.')); } catch (error) { try { await connection.rollback(); } catch (_rollbackError) { // Ignore rollback failures and surface the original error. } next(error); } finally { connection.release(); } }); app.post('/settings/users/:id/password', requirePermission('users.update'), async function (req, res, next) { try { const userId = Number(req.params.id); const password = String(req.body.password || ''); const confirmPassword = String(req.body.confirm_password || ''); const editUrl = '/settings/users/' + userId + '/edit'; if (!Number.isInteger(userId) || userId <= 0) { return res.status(400).send('Invalid user.'); } if (Number(req.currentUser.id) === userId) { return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.')); } const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements()); if (passwordStrengthMessage) { return res.redirect(editUrl + '?message=' + encodeURIComponent(passwordStrengthMessage)); } if (password !== confirmPassword) { return res.redirect(editUrl + '?message=' + encodeURIComponent('Passwords do not match.')); } const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]); if (!rows.length) { return res.status(404).send('User not found.'); } const passwordRecord = hashPassword(password); const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset'); await pool.query( 'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?', [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId] ); await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]); if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.reset', actorUserId: req.currentUser.id, targetType: 'user', targetId: userId, targetLabel: rows[0].username || String(userId), details: { source: 'administrator' } }); } res.redirect(editUrl + '?message=' + encodeURIComponent('Password updated.')); } catch (error) { next(error); } }); app.post('/settings/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) { try { const userId = Number(req.params.id); if (!Number.isInteger(userId) || userId <= 0) { return res.status(400).send('Invalid user.'); } if (Number(req.currentUser.id) === userId) { return res.redirect('/settings/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.')); } const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users'); if (!countRows.length || Number(countRows[0].user_count) <= 1) { return res.redirect('/settings/users?message=' + encodeURIComponent('At least one user must remain.')); } const [userRows] = await pool.query('SELECT username FROM a_users WHERE id = ? LIMIT 1', [userId]); const [result] = await pool.query('DELETE FROM a_users WHERE id = ?', [userId]); if (!result.affectedRows) { return res.status(404).send('User not found.'); } if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.deleted', actorUserId: req.currentUser.id, targetType: 'user', targetId: userId, targetLabel: userRows[0] && userRows[0].username ? userRows[0].username : String(userId) }); } res.redirect('/settings/users?message=' + encodeURIComponent('User deleted.')); } catch (error) { next(error); } }); };