Release v2.11.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m47s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
2026-09-04 15:43:55 +01:00
parent 2c150b5b2e
commit 98f969ca0f
104 changed files with 3559 additions and 447 deletions
+171 -3
View File
@@ -1,6 +1,7 @@
// Admin user route registration and user-role management.
const { fetchAppSettings } = require('#src/data/app-settings');
const { renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const { buildAuditChanges } = require('#src/data/audit-log');
module.exports = function registerUsersRoutes(app, deps) {
@@ -10,6 +11,10 @@
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;
@@ -20,6 +25,8 @@
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;
@@ -78,6 +85,23 @@
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));
@@ -102,6 +126,76 @@
}
});
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);
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, 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 24 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'));
@@ -110,6 +204,53 @@
});
});
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]);
await pool.query('INSERT INTO a_user_invitations (email, name, role_ids_json, token_hash, expires_at, created_by) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 24 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, 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);
@@ -222,6 +363,7 @@
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();
@@ -229,7 +371,8 @@
const roleCheck = await validateRoleIds(selectedRoleIds);
const formValues = {
username: username,
name: name
name: name,
email: email
};
async function renderValidationError(message) {
@@ -243,6 +386,9 @@
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);
@@ -267,8 +413,8 @@
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO a_users (name, username, password_hash, password_salt, password_iterations, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, actorId, actorId]
'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();
@@ -364,7 +510,9 @@
}
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;
@@ -386,6 +534,9 @@
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) {
@@ -410,12 +561,14 @@
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
@@ -426,6 +579,10 @@
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]);
@@ -471,6 +628,17 @@
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.'));