Files
pulse-signage/src/web/routes/admin/users.js
T
2026-08-05 19:38:16 +01:00

414 lines
16 KiB
JavaScript

// Admin user route registration and user-role management.
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 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 LIST_PAGE_SIZE = 25;
async function fetchRoleOptions() {
return rbacData.fetchRoles(pool);
}
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 };
}
app.get('/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('/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('/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('/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('/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();
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)));
} catch (error) {
next(error);
}
});
app.post('/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 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
};
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.');
}
const passwordStrengthMessage = validatePasswordStrength(password);
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 actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
await connection.commit();
if (saveAction === 'new') {
return res.redirect('/users/new?message=' + encodeURIComponent('User created.'));
}
res.redirect('/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('/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('/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
}
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
} catch (error) {
next(error);
}
});
app.post('/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 [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 (shouldUpdatePassword) {
const passwordStrengthMessage = validatePasswordStrength(password);
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.');
}
await connection.beginTransaction();
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
if (!result.affectedRows) {
await connection.rollback();
return res.status(404).send('User not found.');
}
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
if (shouldUpdatePassword) {
const passwordRecord = hashPassword(password);
await connection.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
);
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
}
await connection.commit();
if (saveAction === 'new') {
return res.redirect('/users/new?message=' + encodeURIComponent('User updated.'));
}
res.redirect('/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('/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 = '/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);
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);
await pool.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
);
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
res.redirect(editUrl + '?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
}
});
app.post('/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('/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('/users?message=' + encodeURIComponent('At least one user must remain.'));
}
const [result] = await pool.query('DELETE FROM a_users WHERE id = ?', [userId]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
res.redirect('/users?message=' + encodeURIComponent('User deleted.'));
} catch (error) {
next(error);
}
});
};