Release 2.8.0
This commit is contained in:
+180
-33
@@ -1,11 +1,14 @@
|
||||
// Admin user route registration and user-role management.
|
||||
|
||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
||||
|
||||
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 hashPassword = deps.hashPassword;
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const readArrayField = deps.readArrayField;
|
||||
@@ -23,6 +26,23 @@
|
||||
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);
|
||||
@@ -57,7 +77,7 @@
|
||||
return { ok: true, roleIds: normalizedRoleIds };
|
||||
}
|
||||
|
||||
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
|
||||
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);
|
||||
@@ -81,7 +101,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/users/new', requirePermission('users.create'), function (req, res) {
|
||||
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) {
|
||||
@@ -89,7 +109,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
|
||||
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) {
|
||||
@@ -100,8 +120,9 @@
|
||||
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.'));
|
||||
return res.redirect('/settings/users?message=' + encodeURIComponent('Use Add user to create another login for yourself.'));
|
||||
}
|
||||
|
||||
const roles = await fetchRoleOptions();
|
||||
@@ -121,7 +142,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
|
||||
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) {
|
||||
@@ -140,19 +161,62 @@
|
||||
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: String(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)));
|
||||
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds), sessions));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
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');
|
||||
@@ -178,7 +242,7 @@
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
||||
const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements());
|
||||
if (passwordStrengthMessage) {
|
||||
return renderValidationError(passwordStrengthMessage);
|
||||
}
|
||||
@@ -198,18 +262,30 @@
|
||||
}
|
||||
|
||||
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, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
|
||||
'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]
|
||||
);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
if (saveAction === 'new') {
|
||||
return res.redirect('/users/new?message=' + encodeURIComponent('User created.'));
|
||||
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 }
|
||||
});
|
||||
}
|
||||
res.redirect('/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
|
||||
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();
|
||||
@@ -222,7 +298,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
|
||||
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) {
|
||||
@@ -240,17 +316,28 @@
|
||||
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));
|
||||
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
||||
}
|
||||
|
||||
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
||||
res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||
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: { roleIds: roleCheck.roleIds }
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
|
||||
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);
|
||||
@@ -274,6 +361,7 @@
|
||||
}
|
||||
|
||||
const username = common.validateMaxLength(req.body.username || user.username || '', USER_USERNAME_MAX_LENGTH, 'Username');
|
||||
const accountLocked = req.body.account_locked === '1' || (Array.isArray(req.body.account_locked) && req.body.account_locked.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;
|
||||
@@ -296,7 +384,7 @@
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (shouldUpdatePassword) {
|
||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
||||
const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements());
|
||||
if (passwordStrengthMessage) {
|
||||
return renderValidationError(passwordStrengthMessage);
|
||||
}
|
||||
@@ -317,25 +405,61 @@
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
||||
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 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 = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
'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 (saveAction === 'new') {
|
||||
return res.redirect('/users/new?message=' + encodeURIComponent('User updated.'));
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'users',
|
||||
eventType: 'user.updated',
|
||||
actorUserId: req.currentUser.id,
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
targetLabel: username,
|
||||
details: { roleIds: roleCheck.roleIds, passwordReset: shouldUpdatePassword, accountLocked: accountLocked }
|
||||
});
|
||||
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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
|
||||
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();
|
||||
@@ -348,12 +472,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
|
||||
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 = '/users/' + userId + '/edit';
|
||||
const editUrl = '/settings/users/' + userId + '/edit';
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
@@ -361,7 +485,7 @@
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
|
||||
}
|
||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
||||
const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements());
|
||||
if (passwordStrengthMessage) {
|
||||
return res.redirect(editUrl + '?message=' + encodeURIComponent(passwordStrengthMessage));
|
||||
}
|
||||
@@ -375,37 +499,60 @@
|
||||
}
|
||||
|
||||
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 = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
'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('/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
|
||||
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('/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
|
||||
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('/users?message=' + encodeURIComponent('At least one user must remain.'));
|
||||
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.');
|
||||
}
|
||||
res.redirect('/users?message=' + encodeURIComponent('User deleted.'));
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user