Release v2.6.11

This commit is contained in:
2026-08-08 15:11:11 +01:00
parent 2c97fe81d1
commit 5a08ccddbb
7 changed files with 108 additions and 16 deletions
+7
View File
@@ -2,6 +2,13 @@
All notable changes to this project will be documented in this file. 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 ## 2.6.10 - 2026-08-08
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.6.10", "version": "2.6.11",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
+7 -4
View File
@@ -194,18 +194,21 @@ const PERMISSION_SECTIONS = [
} }
]; ];
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) { const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section, sectionIndex) {
return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource) { return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource, resourceIndex) {
return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, index) { return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, actionIndex) {
return { return {
key: `${resource.key}.${action.key}`, key: `${resource.key}.${action.key}`,
name: resource.name, name: resource.name,
sectionOrder: section.order, sectionOrder: section.order,
sectionIndex: sectionIndex,
actionName: action.name, actionName: action.name,
permissionOrder: index + 1, permissionOrder: actionIndex + 1,
permissionIndex: actionIndex,
sectionName: section.sectionName, sectionName: section.sectionName,
resourceKey: resource.key, resourceKey: resource.key,
resourceOrder: resource.order, resourceOrder: resource.order,
resourceIndex: resourceIndex,
resourceName: resource.name, resourceName: resource.name,
actionKey: action.key, actionKey: action.key,
description: action.description description: action.description
+24 -4
View File
@@ -19,6 +19,11 @@
const LIST_PAGE_SIZE = 25; const LIST_PAGE_SIZE = 25;
function toSortIndex(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 999;
}
function slugifyRoleKey(name) { function slugifyRoleKey(name) {
const value = String(name || '').trim().toLowerCase(); const value = String(name || '').trim().toLowerCase();
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH); 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, '-'), resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
resourceName: definition ? definition.resourceName : permission.section_name, resourceName: definition ? definition.resourceName : permission.section_name,
categoryName: definition ? definition.sectionName : permission.section_name, categoryName: definition ? definition.sectionName : permission.section_name,
sectionIndex: definition ? toSortIndex(definition.sectionIndex) : 999,
sectionOrder: definition ? definition.sectionOrder : 999, sectionOrder: definition ? definition.sectionOrder : 999,
resourceIndex: definition ? toSortIndex(definition.resourceIndex) : 999,
resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999, resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999,
permissionIndex: definition ? toSortIndex(definition.permissionIndex) : 999,
permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999, permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999,
actionKey: definition ? definition.actionKey : 'read', actionKey: definition ? definition.actionKey : 'read',
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey), actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
@@ -103,7 +111,9 @@
id: sectionKey || 'permissions', id: sectionKey || 'permissions',
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(), title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
categoryName: String(permission.categoryName || '').trim(), categoryName: String(permission.categoryName || '').trim(),
sectionIndex: toSortIndex(permission.sectionIndex),
sectionOrder: Number(permission.sectionOrder) || 999, sectionOrder: Number(permission.sectionOrder) || 999,
resourceIndex: toSortIndex(permission.resourceIndex),
permissions: [] permissions: []
}; };
groupIndex.set(sectionKey, group); groupIndex.set(sectionKey, group);
@@ -114,8 +124,13 @@
groups.forEach(function (group) { groups.forEach(function (group) {
group.permissions.sort(function (left, right) { group.permissions.sort(function (left, right) {
const leftOrder = Number(left.permissionOrder) || 999; const leftIndex = toSortIndex(left.permissionIndex);
const rightOrder = Number(right.permissionOrder) || 999; const rightIndex = toSortIndex(right.permissionIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.permissionOrder);
const rightOrder = toSortIndex(right.permissionOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -124,8 +139,13 @@
}); });
groups.sort(function (left, right) { groups.sort(function (left, right) {
const leftOrder = Number(left.sectionOrder) || 999; const leftIndex = toSortIndex(left.sectionIndex);
const rightOrder = Number(right.sectionOrder) || 999; const rightIndex = toSortIndex(right.sectionIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.sectionOrder);
const rightOrder = toSortIndex(right.sectionOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -4,6 +4,11 @@ function normalizeSectionId(value) {
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'); 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) { function buildPermissionSections(permissionGroups) {
const sections = []; const sections = [];
const sectionIndex = new Map(); const sectionIndex = new Map();
@@ -23,7 +28,8 @@ function buildPermissionSections(permissionGroups) {
sectionIndex.set(sectionKey, { sectionIndex.set(sectionKey, {
id: sectionKey, id: sectionKey,
title: sectionTitle, title: sectionTitle,
sectionOrder: Number(group && group.sectionOrder) || 999, sourceIndex: toSortIndex(group && group.sectionIndex),
sectionOrder: toSortIndex(group && group.sectionOrder),
groups: [] groups: []
}); });
sections.push(sectionIndex.get(sectionKey)); sections.push(sectionIndex.get(sectionKey));
@@ -47,8 +53,14 @@ function buildPermissionSections(permissionGroups) {
}); });
section.groups.sort(function (left, right) { section.groups.sort(function (left, right) {
const leftOrder = Number(left.resourceOrder) || 999; const leftIndex = toSortIndex(left.resourceIndex);
const rightOrder = Number(right.resourceOrder) || 999; const rightIndex = toSortIndex(right.resourceIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.resourceOrder);
const rightOrder = toSortIndex(right.resourceOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -65,8 +77,14 @@ function buildPermissionSections(permissionGroups) {
}); });
sections.sort(function (left, right) { sections.sort(function (left, right) {
const leftOrder = Number(left.sectionOrder) || 999; const leftIndex = toSortIndex(left.sourceIndex);
const rightOrder = Number(right.sectionOrder) || 999; const rightIndex = toSortIndex(right.sourceIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.sectionOrder);
const rightOrder = toSortIndex(right.sectionOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -134,5 +152,6 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us
module.exports = { module.exports = {
buildRbacAddViewModel: buildRbacAddViewModel, buildRbacAddViewModel: buildRbacAddViewModel,
buildRbacEditViewModel: buildRbacEditViewModel buildRbacEditViewModel: buildRbacEditViewModel,
buildPermissionSections: buildPermissionSections
}; };
+2 -1
View File
@@ -6,6 +6,7 @@
</div> </div>
<form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}> <form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}>
<input type="hidden" name="permissions_present" value="1" />
<input type="hidden" name="users_present" value="1" /> <input type="hidden" name="users_present" value="1" />
<div class="row g-3"> <div class="row g-3">
@@ -94,7 +95,7 @@
<span class="fw-semibold">{{title}}</span> <span class="fw-semibold">{{title}}</span>
</button> </button>
</h2> </h2>
<div id="permission-section-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="permission-section-heading-{{id}}" data-bs-parent="#role-permissions-accordion"> <div id="permission-section-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="permission-section-heading-{{id}}">
<div class="accordion-body p-0"> <div class="accordion-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped align-middle mb-0 rbac-permissions-table" style="table-layout: fixed; width: 100%;"> <table class="table table-striped align-middle mb-0 rbac-permissions-table" style="table-layout: fixed; width: 100%;">
+42
View File
@@ -4,14 +4,17 @@ const fs = require('node:fs');
const rbacPermissionsScript = fs.readFileSync(require.resolve('../src/web/public/js/rbac-permissions.js'), 'utf8'); 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 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 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'); const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8');
test('rbac form exposes bulk permission controls', () => { test('rbac form exposes bulk permission controls', () => {
assert.match(rbacFormTemplate, /name="permissions_present" value="1"/);
assert.match(rbacFormTemplate, /accordion accordion-flush/); assert.match(rbacFormTemplate, /accordion accordion-flush/);
assert.match(rbacFormTemplate, /card card-outline card-secondary overflow-hidden/); assert.match(rbacFormTemplate, /card card-outline card-secondary overflow-hidden/);
assert.match(rbacFormTemplate, /accordion-item overflow-hidden" data-permission-section/); assert.match(rbacFormTemplate, /accordion-item overflow-hidden" data-permission-section/);
assert.match(rbacFormTemplate, /accordion-body p-0/); 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, /table-layout: fixed; width: 100%;/);
assert.match(rbacFormTemplate, /<col style="width: 5\.5rem;" \/>/); assert.match(rbacFormTemplate, /<col style="width: 5\.5rem;" \/>/);
assert.match(rbacFormTemplate, /class="text-center"/); 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')); 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', () => { test('rbac duplicate route exists', () => {
const rbacRoutes = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8'); 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'); const duplicateHelpers = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/duplicate.js'), 'utf8');