diff --git a/CHANGELOG.md b/CHANGELOG.md index ecae403..4f9a2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## 2.6.11 - 2026-08-08 + +### Fixed + +- Role edits now save selected permissions from the shared RBAC form, so changes on the role page persist when you submit the form. +- RBAC permission sections now stay open independently in the accordion, so opening one section no longer closes the others. + ## 2.6.10 - 2026-08-08 ### Fixed diff --git a/package.json b/package.json index 6e91498..450a4d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.10", + "version": "2.6.11", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/rbac.js b/src/rbac.js index 99d5918..65df0a2 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -194,18 +194,21 @@ const PERMISSION_SECTIONS = [ } ]; -const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) { - return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource) { - return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, index) { +const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section, sectionIndex) { + return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource, resourceIndex) { + return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, actionIndex) { return { key: `${resource.key}.${action.key}`, name: resource.name, sectionOrder: section.order, + sectionIndex: sectionIndex, actionName: action.name, - permissionOrder: index + 1, + permissionOrder: actionIndex + 1, + permissionIndex: actionIndex, sectionName: section.sectionName, resourceKey: resource.key, resourceOrder: resource.order, + resourceIndex: resourceIndex, resourceName: resource.name, actionKey: action.key, description: action.description diff --git a/src/web/routes/admin/rbac.js b/src/web/routes/admin/rbac.js index 5f14e2e..2f17bac 100644 --- a/src/web/routes/admin/rbac.js +++ b/src/web/routes/admin/rbac.js @@ -19,6 +19,11 @@ const LIST_PAGE_SIZE = 25; + function toSortIndex(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 999; + } + function slugifyRoleKey(name) { const value = String(name || '').trim().toLowerCase(); const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH); @@ -73,8 +78,11 @@ resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'), resourceName: definition ? definition.resourceName : permission.section_name, categoryName: definition ? definition.sectionName : permission.section_name, + sectionIndex: definition ? toSortIndex(definition.sectionIndex) : 999, sectionOrder: definition ? definition.sectionOrder : 999, + resourceIndex: definition ? toSortIndex(definition.resourceIndex) : 999, resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999, + permissionIndex: definition ? toSortIndex(definition.permissionIndex) : 999, permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999, actionKey: definition ? definition.actionKey : 'read', actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey), @@ -103,7 +111,9 @@ id: sectionKey || 'permissions', title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(), categoryName: String(permission.categoryName || '').trim(), + sectionIndex: toSortIndex(permission.sectionIndex), sectionOrder: Number(permission.sectionOrder) || 999, + resourceIndex: toSortIndex(permission.resourceIndex), permissions: [] }; groupIndex.set(sectionKey, group); @@ -114,8 +124,13 @@ groups.forEach(function (group) { group.permissions.sort(function (left, right) { - const leftOrder = Number(left.permissionOrder) || 999; - const rightOrder = Number(right.permissionOrder) || 999; + const leftIndex = toSortIndex(left.permissionIndex); + const rightIndex = toSortIndex(right.permissionIndex); + if (leftIndex !== rightIndex) { + return leftIndex - rightIndex; + } + const leftOrder = toSortIndex(left.permissionOrder); + const rightOrder = toSortIndex(right.permissionOrder); if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } @@ -124,8 +139,13 @@ }); groups.sort(function (left, right) { - const leftOrder = Number(left.sectionOrder) || 999; - const rightOrder = Number(right.sectionOrder) || 999; + const leftIndex = toSortIndex(left.sectionIndex); + const rightIndex = toSortIndex(right.sectionIndex); + if (leftIndex !== rightIndex) { + return leftIndex - rightIndex; + } + const leftOrder = toSortIndex(left.sectionOrder); + const rightOrder = toSortIndex(right.sectionOrder); if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } diff --git a/src/web/routes/settings/rbac/form-view-model.js b/src/web/routes/settings/rbac/form-view-model.js index d65c713..f28e317 100644 --- a/src/web/routes/settings/rbac/form-view-model.js +++ b/src/web/routes/settings/rbac/form-view-model.js @@ -4,6 +4,11 @@ function normalizeSectionId(value) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'); } +function toSortIndex(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 999; +} + function buildPermissionSections(permissionGroups) { const sections = []; const sectionIndex = new Map(); @@ -23,7 +28,8 @@ function buildPermissionSections(permissionGroups) { sectionIndex.set(sectionKey, { id: sectionKey, title: sectionTitle, - sectionOrder: Number(group && group.sectionOrder) || 999, + sourceIndex: toSortIndex(group && group.sectionIndex), + sectionOrder: toSortIndex(group && group.sectionOrder), groups: [] }); sections.push(sectionIndex.get(sectionKey)); @@ -47,8 +53,14 @@ function buildPermissionSections(permissionGroups) { }); section.groups.sort(function (left, right) { - const leftOrder = Number(left.resourceOrder) || 999; - const rightOrder = Number(right.resourceOrder) || 999; + const leftIndex = toSortIndex(left.resourceIndex); + const rightIndex = toSortIndex(right.resourceIndex); + if (leftIndex !== rightIndex) { + return leftIndex - rightIndex; + } + + const leftOrder = toSortIndex(left.resourceOrder); + const rightOrder = toSortIndex(right.resourceOrder); if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } @@ -65,8 +77,14 @@ function buildPermissionSections(permissionGroups) { }); sections.sort(function (left, right) { - const leftOrder = Number(left.sectionOrder) || 999; - const rightOrder = Number(right.sectionOrder) || 999; + const leftIndex = toSortIndex(left.sourceIndex); + const rightIndex = toSortIndex(right.sourceIndex); + if (leftIndex !== rightIndex) { + return leftIndex - rightIndex; + } + + const leftOrder = toSortIndex(left.sectionOrder); + const rightOrder = toSortIndex(right.sectionOrder); if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } @@ -134,5 +152,6 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us module.exports = { buildRbacAddViewModel: buildRbacAddViewModel, - buildRbacEditViewModel: buildRbacEditViewModel + buildRbacEditViewModel: buildRbacEditViewModel, + buildPermissionSections: buildPermissionSections }; \ No newline at end of file diff --git a/src/web/views/settings/rbac/form.hbs b/src/web/views/settings/rbac/form.hbs index 3d9cf59..612afdf 100644 --- a/src/web/views/settings/rbac/form.hbs +++ b/src/web/views/settings/rbac/form.hbs @@ -6,6 +6,7 @@
+
@@ -94,7 +95,7 @@ {{title}} -
+
diff --git a/test/rbac-permissions.test.js b/test/rbac-permissions.test.js index 459fc18..942373c 100644 --- a/test/rbac-permissions.test.js +++ b/test/rbac-permissions.test.js @@ -4,14 +4,17 @@ const fs = require('node:fs'); const rbacPermissionsScript = fs.readFileSync(require.resolve('../src/web/public/js/rbac-permissions.js'), 'utf8'); const rbacFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/rbac/form.hbs'), 'utf8'); +const { buildPermissionSections } = require('../src/web/routes/settings/rbac/form-view-model'); const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8'); const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8'); test('rbac form exposes bulk permission controls', () => { + assert.match(rbacFormTemplate, /name="permissions_present" value="1"/); assert.match(rbacFormTemplate, /accordion accordion-flush/); assert.match(rbacFormTemplate, /card card-outline card-secondary overflow-hidden/); assert.match(rbacFormTemplate, /accordion-item overflow-hidden" data-permission-section/); assert.match(rbacFormTemplate, /accordion-body p-0/); + assert.ok(!rbacFormTemplate.includes('data-bs-parent="#role-permissions-accordion"')); assert.match(rbacFormTemplate, /table-layout: fixed; width: 100%;/); assert.match(rbacFormTemplate, //); assert.match(rbacFormTemplate, /class="text-center"/); @@ -51,6 +54,45 @@ test('rbac permissions script supports bulk permission selection', () => { assert.ok(!rbacPermissionsScript.includes('data-permission-section-select-none')); }); +test('rbac accordion sections follow source order', () => { + const sections = buildPermissionSections([ + { + categoryName: 'Settings', + sectionIndex: 3, + sectionOrder: 40, + resourceIndex: 1, + resourceOrder: 20, + title: 'Later resource', + permissions: [] + }, + { + categoryName: 'Main navigation', + sectionIndex: 0, + sectionOrder: 10, + resourceIndex: 1, + resourceOrder: 20, + title: 'Clients', + permissions: [] + }, + { + categoryName: 'Main navigation', + sectionIndex: 0, + sectionOrder: 10, + resourceIndex: 0, + resourceOrder: 10, + title: 'Dashboard', + permissions: [] + } + ]); + + assert.deepEqual(sections.map(function (section) { + return section.title; + }), ['Main navigation', 'Settings']); + assert.deepEqual(sections[0].groups.map(function (group) { + return group.title; + }), ['Dashboard', 'Clients']); +}); + test('rbac duplicate route exists', () => { const rbacRoutes = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8'); const duplicateHelpers = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/duplicate.js'), 'utf8');