747 lines
33 KiB
JavaScript
747 lines
33 KiB
JavaScript
// 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);
|
|
}
|
|
});
|
|
};
|