Implement RBAC roles system
This commit is contained in:
+127
-23
@@ -4,36 +4,85 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.get('/admin/users', async function (req, res, next) {
|
||||
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.length) {
|
||||
return { ok: false, message: 'Select at least one role.' };
|
||||
}
|
||||
|
||||
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('/admin/users', requirePermission('users.read'), async function (req, res, next) {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users ORDER BY id ASC');
|
||||
const users = rows.map(function (user) {
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
const mappedUsers = 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)
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
});
|
||||
});
|
||||
res.send(pages.renderUsersPage({ users: users }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/users/new', function (req, res) {
|
||||
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
app.get('/admin/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('/admin/users/:id/edit', async function (req, res, next) {
|
||||
app.get('/admin/users/:id/edit', requirePermission('users.edit'), 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 [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
const user = rows[0] || null;
|
||||
const user = await rbacData.fetchUserWithRoles(pool, userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
@@ -41,54 +90,109 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const roles = await fetchRoleOptions();
|
||||
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||
isCurrentUser: false,
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at)
|
||||
}), req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users', async function (req, res, next) {
|
||||
app.post('/admin/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
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 res.redirect('/admin/users/new?message=' + encodeURIComponent('Name is required.'));
|
||||
return renderValidationError('Name is required.');
|
||||
}
|
||||
if (!username) {
|
||||
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Username is required.'));
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Password must be at least 8 characters.'));
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
return renderValidationError('Passwords do not match.');
|
||||
}
|
||||
if (!roleCheck.ok) {
|
||||
return renderValidationError(roleCheck.message);
|
||||
}
|
||||
|
||||
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||
if (existingRows.length) {
|
||||
return res.redirect('/admin/users/new?message=' + encodeURIComponent('That username already exists.'));
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query(
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO 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();
|
||||
res.redirect('/admin/users?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('/admin/users/:id/roles', requirePermission('users.edit'), 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('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM 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('/admin/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
||||
}
|
||||
|
||||
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
||||
res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/username', async function (req, res, next) {
|
||||
app.post('/admin/users/:id/username', requirePermission('users.edit'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const name = String(req.body.name || '').trim();
|
||||
@@ -122,7 +226,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/password', async function (req, res, next) {
|
||||
app.post('/admin/users/:id/password', requirePermission('users.edit'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const password = String(req.body.password || '');
|
||||
@@ -158,7 +262,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/delete', async function (req, res, next) {
|
||||
app.post('/admin/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
|
||||
Reference in New Issue
Block a user