module.exports = function registerAdminRbacRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const pages = deps.pages; const getAuditUserId = deps.getAuditUserId; const rbacData = deps.rbacData; const permissions = Array.isArray(deps.permissions) ? deps.permissions : []; const readArrayField = deps.readArrayField; const normalizePermissionKeys = deps.normalizePermissionKeys; const { buildPagination } = require('../../lib/pagination'); const requirePermission = deps.requirePermission; const LIST_PAGE_SIZE = 10; function slugifyRoleKey(name) { const value = String(name || '').trim().toLowerCase(); const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); return slug || 'role'; } function normalizeSelectedIds(values) { return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) { return Number(value); }).filter(function (value) { return Number.isInteger(value) && value > 0; }))); } function getActionLabel(actionKey) { const normalizedActionKey = String(actionKey || '').trim().toLowerCase(); if (normalizedActionKey === 'edit') { return 'Update'; } if (normalizedActionKey === 'allow') { return 'Allow'; } if (!normalizedActionKey) { return 'Read'; } return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1); } async function createUniqueRoleKey(baseName) { const baseKey = slugifyRoleKey(baseName); let candidate = baseKey; let suffix = 2; while (true) { const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]); if (!rows.length) { return candidate; } candidate = `${baseKey}-${suffix}`; suffix += 1; } } function mapPermissionsForView(permissionRows, selectedPermissionKeys) { const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys)); const permissionDefinitions = new Map(permissions.map(function (permission) { return [String(permission.key || '').trim(), permission]; })); return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) { const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null; return Object.assign({}, permission, { resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'), resourceName: definition ? definition.name : permission.section_name, categoryName: definition ? definition.sectionName : permission.section_name, sectionOrder: definition ? definition.sectionOrder : 999, actionKey: definition ? definition.actionKey : 'read', actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey), isSelected: selectedKeys.has(String(permission.permission_key || '').trim()) }); }); } function mapUsersForView(userRows, selectedUserIds) { const selectedIds = new Set(normalizeSelectedIds(selectedUserIds)); return (Array.isArray(userRows) ? userRows : []).map(function (user) { return Object.assign({}, user, { isSelected: selectedIds.has(Number(user.id)) }); }); } function buildPermissionGroups(permissionRows) { const groups = []; const groupIndex = new Map(); (Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) { const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'); if (!groupIndex.has(sectionKey)) { const group = { id: sectionKey || 'permissions', title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(), categoryName: String(permission.categoryName || '').trim(), sectionOrder: Number(permission.sectionOrder) || 999, permissions: [] }; groupIndex.set(sectionKey, group); groups.push(group); } groupIndex.get(sectionKey).permissions.push(permission); }); groups.forEach(function (group) { group.permissions.sort(function (left, right) { const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 }; const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99; const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99; if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } return String(left.name || '').localeCompare(String(right.name || '')); }); }); groups.sort(function (left, right) { const leftOrder = Number(left.sectionOrder) || 999; const rightOrder = Number(right.sectionOrder) || 999; if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } return String(left.title || '').localeCompare(String(right.title || '')); }); return groups; } async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) { const permissionRows = await rbacData.fetchPermissions(pool); return { formValues: { name: String(formValues && formValues.name || '').trim(), description: String(formValues && formValues.description || '').trim() }, permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)) }; } async function loadRoleViewModel(roleId) { const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return null; } const permissionRows = await rbacData.fetchPermissions(pool); const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id); const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id); const users = await rbacData.fetchUsersWithRoles(pool); return { role: Object.assign({}, role, { permissionKeys: selectedPermissionKeys, permissionCount: Number(role.permission_count) || 0, userCount: Number(role.user_count) || 0 }), permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)), users: mapUsersForView(users, selectedUserIds) }; } app.get('/rbac', requirePermission('rbac.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.fetchRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderRbacPage({ roles: data.roles || [], pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'roles', 'Role pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } }); app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) { buildRoleCreateViewModel({ name: String(req.query.name || '').trim(), description: String(req.query.description || '').trim() }, []).then(function (viewModel) { res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary')); }).catch(function (error) { next(error); }); }); app.post('/rbac', requirePermission('rbac.create'), async function (req, res, next) { try { const name = String(req.body.name || '').trim(); const description = String(req.body.description || '').trim(); const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]']) ? req.body['permission_keys[]'] : req.body.permission_keys ? [].concat(req.body.permission_keys) : []); const validPermissionKeys = new Set(permissions.map(function (permission) { return String(permission.key || '').trim(); })); const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys); if (!name) { return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning')); } if (await common.fetchDuplicateName(pool, 'roles', name)) { return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning')); } if (selectedPermissionKeys.some(function (permissionKey) { return !validPermissionKeys.has(permissionKey); })) { return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning')); } const roleKey = await createUniqueRoleKey(name); const actorId = getAuditUserId(req); const connection = await pool.getConnection(); let insertedRoleId = null; try { await connection.beginTransaction(); const [result] = await connection.query( 'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [roleKey, name, description || null, actorId, actorId] ); insertedRoleId = Number(result.insertId); if (selectedPermissionKeys.length) { await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys); } await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } res.redirect('/rbac/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.')); } catch (error) { next(error); } }); app.get('/rbac/:id/edit', requirePermission('rbac.update'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const viewModel = await loadRoleViewModel(roleId); if (!viewModel) { return res.status(404).send('Role not found.'); } const currentUserId = req.currentUser ? Number(req.currentUser.id) : null; const users = Array.isArray(viewModel.users) ? viewModel.users.filter(function (user) { return Number(user && user.id) !== currentUserId; }) : []; res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users)); } catch (error) { next(error); } }); app.post('/rbac/:id', requirePermission('rbac.update'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return res.status(404).send('Role not found.'); } const name = String(req.body.name || '').trim(); const description = String(req.body.description || '').trim(); const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present'); const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present'); const selectedPermissionKeys = shouldSyncPermissions ? readArrayField(req.body, ['permission_keys[]', 'permission_keys']) : []; const selectedUserIds = shouldSyncUsers ? readArrayField(req.body, ['user_ids[]', 'user_ids']) : []; const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : []; const normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : []; if (!name) { return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.')); } if (await common.fetchDuplicateName(pool, 'roles', name, roleId)) { return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.')); } const validPermissionKeys = new Set(permissions.map(function (permission) { return String(permission.key || '').trim(); })); if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) { return !validPermissionKeys.has(permissionKey); })) { return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.')); } let availableUsers = []; if (shouldSyncUsers) { availableUsers = await rbacData.fetchUsersWithRoles(pool); } const validUserIds = new Set(availableUsers.map(function (user) { return Number(user.id); })); if (shouldSyncUsers && normalizedUserIds.some(function (userId) { return !validUserIds.has(userId); })) { return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.')); } const connection = await pool.getConnection(); try { await connection.beginTransaction(); await connection.query( 'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?', [name, description || null, getAuditUserId(req), roleId] ); if (shouldSyncPermissions) { await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys); } if (shouldSyncUsers) { await rbacData.syncRoleUsers(connection, roleId, normalizedUserIds); } await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.')); } catch (error) { next(error); } }); app.post('/rbac/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return res.status(404).send('Role not found.'); } const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]']) ? req.body['permission_keys[]'] : req.body.permission_keys ? [].concat(req.body.permission_keys) : []; const validPermissionKeys = new Set(permissions.map(function (permission) { return String(permission.key || '').trim(); })); const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys); if (normalizedPermissionKeys.some(function (permissionKey) { return !validPermissionKeys.has(permissionKey); })) { return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.')); } const connection = await pool.getConnection(); try { await connection.beginTransaction(); await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.')); } catch (error) { next(error); } }); app.post('/rbac/:id/users', requirePermission('rbac.update'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return res.status(404).send('Role not found.'); } const selectedUserIds = Array.isArray(req.body['user_ids[]']) ? req.body['user_ids[]'] : req.body.user_ids ? [].concat(req.body.user_ids) : []; const normalizedUserIds = normalizeSelectedIds(selectedUserIds); const availableUsers = await rbacData.fetchUsersWithRoles(pool); const validUserIds = new Set(availableUsers.map(function (user) { return Number(user.id); })); if (normalizedUserIds.some(function (userId) { return !validUserIds.has(userId); })) { return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.')); } await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds); res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.')); } catch (error) { next(error); } }); app.post('/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) { try { const roleId = Number(req.params.id); if (!Number.isInteger(roleId) || roleId <= 0) { return res.status(400).send('Invalid role.'); } const role = await rbacData.fetchRoleById(pool, roleId); if (!role) { return res.status(404).send('Role not found.'); } if (String(role.role_key || '') === 'administrators') { return res.redirect('/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.')); } if (Number(role.user_count) > 0) { return res.redirect('/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.')); } await pool.query('DELETE FROM roles WHERE id = ?', [roleId]); res.redirect('/rbac?message=' + encodeURIComponent('Role deleted.')); } catch (error) { next(error); } }); };