Release 2.8.1
This commit is contained in:
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
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.8.1 - 2026-08-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prevented startup and runtime duplicate-key writes from consuming auto-increment values in permission, player, onboarding, settings, and relationship tables.
|
||||||
|
- Prevented partial timetable schemas from being incorrectly treated as fully migrated during schema version detection.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed the built-in administrator role key to `super-admin`, while allowing its display name and description to be edited without being overwritten on restart.
|
||||||
|
|
||||||
## 2.8.0 - 2026-08-16
|
## 2.8.0 - 2026-08-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.8.0",
|
"version": "2.8.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.8.0",
|
"version": "2.8.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ PLAYER_INTERNAL_URL="http://player:8081"
|
|||||||
# Web app bootstrap settings
|
# Web app bootstrap settings
|
||||||
DEFAULT_ADMIN_USERNAME="admin"
|
DEFAULT_ADMIN_USERNAME="admin"
|
||||||
DEFAULT_ADMIN_NAME="Admin"
|
DEFAULT_ADMIN_NAME="Admin"
|
||||||
DEFAULT_ADMIN_PASSWORD="password123"
|
DEFAULT_ADMIN_PASSWORD="password123!"
|
||||||
|
|
||||||
# Bridge settings for the player-bridge service
|
# Bridge settings for the player-bridge service
|
||||||
WEB_INTERNAL_URL="http://web:8080"
|
WEB_INTERNAL_URL="http://web:8080"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.8.0",
|
"version": "2.8.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.8.0",
|
"version": "2.8.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^149.0.0",
|
"@sparticuz/chromium": "^149.0.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.8.0",
|
"version": "2.8.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -165,11 +165,19 @@ async function saveAppSettings(pool, settings, modifiedBy) {
|
|||||||
if (!Object.prototype.hasOwnProperty.call(inputSettings, definition.key)) {
|
if (!Object.prototype.hasOwnProperty.call(inputSettings, definition.key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE o_app_settings
|
||||||
|
SET setting_value = ?, modified_by = ?
|
||||||
|
WHERE setting_key = ?`,
|
||||||
|
[JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, definition.key]
|
||||||
|
);
|
||||||
await connection.query(
|
await connection.query(
|
||||||
`INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by)
|
`INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by)
|
||||||
VALUES (?, ?, ?, ?)
|
SELECT ?, ?, ?, ?
|
||||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), modified_by = VALUES(modified_by)`,
|
WHERE NOT EXISTS (
|
||||||
[definition.key, JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, modifiedBy || null]
|
SELECT 1 FROM o_app_settings WHERE setting_key = ?
|
||||||
|
)`,
|
||||||
|
[definition.key, JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, modifiedBy || null, definition.key]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (typeof connection.commit === 'function') {
|
if (typeof connection.commit === 'function') {
|
||||||
|
|||||||
+25
-14
@@ -92,15 +92,19 @@ async function upsertPlayerRegistration(pool, options) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE d_players
|
||||||
|
SET public_base_url = ?, internal_base_url = ?, last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE identifier = ?`,
|
||||||
|
[publicBaseUrl || null, internalBaseUrl || null, identifier]
|
||||||
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
|
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
|
||||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
SELECT ?, ?, ?, CURRENT_TIMESTAMP
|
||||||
ON DUPLICATE KEY UPDATE
|
WHERE NOT EXISTS (
|
||||||
public_base_url = VALUES(public_base_url),
|
SELECT 1 FROM d_players WHERE identifier = ?
|
||||||
internal_base_url = VALUES(internal_base_url),
|
)`,
|
||||||
last_seen_at = CURRENT_TIMESTAMP,
|
[identifier, publicBaseUrl || null, internalBaseUrl || null, identifier]
|
||||||
modified_at = CURRENT_TIMESTAMP`,
|
|
||||||
[identifier, publicBaseUrl || null, internalBaseUrl || null]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return resolvePlayerRegistration(pool, identifier);
|
return resolvePlayerRegistration(pool, identifier);
|
||||||
@@ -115,15 +119,22 @@ async function recordPlayerHeartbeat(pool, options) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE d_players
|
||||||
|
SET public_base_url = COALESCE(?, public_base_url),
|
||||||
|
internal_base_url = COALESCE(?, internal_base_url),
|
||||||
|
last_seen_at = CURRENT_TIMESTAMP,
|
||||||
|
modified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE identifier = ?`,
|
||||||
|
[publicBaseUrl || null, internalBaseUrl || null, identifier]
|
||||||
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
|
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
|
||||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
SELECT ?, ?, ?, CURRENT_TIMESTAMP
|
||||||
ON DUPLICATE KEY UPDATE
|
WHERE NOT EXISTS (
|
||||||
public_base_url = COALESCE(VALUES(public_base_url), public_base_url),
|
SELECT 1 FROM d_players WHERE identifier = ?
|
||||||
internal_base_url = COALESCE(VALUES(internal_base_url), internal_base_url),
|
)`,
|
||||||
last_seen_at = CURRENT_TIMESTAMP,
|
[identifier, publicBaseUrl || null, internalBaseUrl || null, identifier]
|
||||||
modified_at = CURRENT_TIMESTAMP`,
|
|
||||||
[identifier, publicBaseUrl || null, internalBaseUrl || null]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return resolvePlayerRegistration(pool, identifier);
|
return resolvePlayerRegistration(pool, identifier);
|
||||||
|
|||||||
Vendored
+34
-9
@@ -15,11 +15,19 @@ async function bootstrapDatabase(pool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const permission of PERMISSIONS) {
|
for (const permission of PERMISSIONS) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE a_permissions
|
||||||
|
SET name = ?, section_name = ?, description = ?, modified_by = ?
|
||||||
|
WHERE permission_key = ?`,
|
||||||
|
[permission.name, permission.sectionName, permission.description || null, null, permission.key]
|
||||||
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
|
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
SELECT ?, ?, ?, ?, ?, ?
|
||||||
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
|
WHERE NOT EXISTS (
|
||||||
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
|
SELECT 1 FROM a_permissions WHERE permission_key = ?
|
||||||
|
)`,
|
||||||
|
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null, permission.key]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,20 +43,37 @@ async function bootstrapDatabase(pool) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [legacyRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', ['administrators']);
|
||||||
|
const [currentRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||||
|
if (legacyRoleRows.length && !currentRoleRows.length) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE a_roles
|
||||||
|
SET role_key = ?, name = ?, description = ?, modified_by = ?
|
||||||
|
WHERE role_key = ?`,
|
||||||
|
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, 'administrators']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO a_roles (role_key, name, description, created_by, modified_by)
|
`INSERT INTO a_roles (role_key, name, description, created_by, modified_by)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
SELECT ?, ?, ?, ?, ?
|
||||||
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
|
WHERE NOT EXISTS (
|
||||||
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
|
SELECT 1 FROM a_roles WHERE role_key = ?
|
||||||
|
)`,
|
||||||
|
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null, DEFAULT_ROLE.key]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||||
const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null;
|
const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null;
|
||||||
if (defaultRoleId) {
|
if (defaultRoleId) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
|
`INSERT INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
|
||||||
SELECT ?, id, NULL, NULL FROM a_permissions`,
|
SELECT ?, permissions.id, NULL, NULL
|
||||||
[defaultRoleId]
|
FROM a_permissions permissions
|
||||||
|
LEFT JOIN a_role_permissions existing
|
||||||
|
ON existing.role_id = ? AND existing.permission_id = permissions.id
|
||||||
|
WHERE existing.id IS NULL`,
|
||||||
|
[defaultRoleId, defaultRoleId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ async function detectSchemaVersion(pool) {
|
|||||||
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
|
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
|
||||||
const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries');
|
const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries');
|
||||||
|
|
||||||
if ((timetableGroupsExists || timetableEntriesExists) && !scheduleGroupsExists && !scheduleEntriesExists) {
|
if (timetableGroupsExists && timetableEntriesExists && !scheduleGroupsExists && !scheduleEntriesExists) {
|
||||||
return '2.6.18';
|
return '2.6.18';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,9 +556,14 @@ async function recordSchemaVersion(pool, version) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const stateValue = String(version || appVersion || '0.0.0').trim();
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE state_value = VALUES(state_value)',
|
'UPDATE ' + APP_STATE_TABLE + ' SET state_value = ? WHERE state_key = ?',
|
||||||
[APP_STATE_SCHEMA_VERSION_KEY, String(version || appVersion || '0.0.0').trim()]
|
[stateValue, APP_STATE_SCHEMA_VERSION_KEY]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM ' + APP_STATE_TABLE + ' WHERE state_key = ?)',
|
||||||
|
[APP_STATE_SCHEMA_VERSION_KEY, stateValue, APP_STATE_SCHEMA_VERSION_KEY]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,8 +128,18 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
|
`UPDATE d_onboarding_devices
|
||||||
[normalizedDeviceId, normalizedClientName, screen.id]
|
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE device_id = ?`,
|
||||||
|
[normalizedClientName, screen.id, normalizedDeviceId]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
|
||||||
|
SELECT ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
|
||||||
|
)`,
|
||||||
|
[normalizedDeviceId, normalizedClientName, screen.id, normalizedDeviceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return getOnboardingStatus(pool, normalizedDeviceId);
|
return getOnboardingStatus(pool, normalizedDeviceId);
|
||||||
|
|||||||
+2
-2
@@ -236,8 +236,8 @@ const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section, sectionIndex)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_ROLE = {
|
const DEFAULT_ROLE = {
|
||||||
key: 'administrators',
|
key: 'super-admin',
|
||||||
name: 'Administrators',
|
name: 'Super Admin',
|
||||||
description: 'Full access to the admin interface.'
|
description: 'Full access to the admin interface.'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -236,7 +236,14 @@ async function syncUserRoles(pool, userId, roleIds) {
|
|||||||
|
|
||||||
await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]);
|
await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]);
|
||||||
for (const roleId of uniqueRoleIds) {
|
for (const roleId of uniqueRoleIds) {
|
||||||
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
await pool.query(
|
||||||
|
`INSERT INTO a_user_roles (user_id, role_id, created_by, modified_by)
|
||||||
|
SELECT ?, ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM a_user_roles WHERE user_id = ? AND role_id = ?
|
||||||
|
)`,
|
||||||
|
[userId, roleId, null, null, userId, roleId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +256,14 @@ async function syncRoleUsers(pool, roleId, userIds) {
|
|||||||
|
|
||||||
await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]);
|
await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]);
|
||||||
for (const userId of uniqueUserIds) {
|
for (const userId of uniqueUserIds) {
|
||||||
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
await pool.query(
|
||||||
|
`INSERT INTO a_user_roles (user_id, role_id, created_by, modified_by)
|
||||||
|
SELECT ?, ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM a_user_roles WHERE user_id = ? AND role_id = ?
|
||||||
|
)`,
|
||||||
|
[userId, roleId, null, null, userId, roleId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +282,15 @@ async function syncRolePermissions(pool, roleId, permissionKeys) {
|
|||||||
|
|
||||||
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
|
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
|
||||||
for (const permissionRow of permissionRows) {
|
for (const permissionRow of permissionRows) {
|
||||||
await pool.query('INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
|
const permissionId = Number(permissionRow.id);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
|
||||||
|
SELECT ?, ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM a_role_permissions WHERE role_id = ? AND permission_id = ?
|
||||||
|
)`,
|
||||||
|
[roleId, permissionId, null, null, roleId, permissionId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Admin RBAC route registration and permission management.
|
// Admin RBAC route registration and permission management.
|
||||||
|
const { DEFAULT_ROLE } = require('#src/rbac');
|
||||||
|
|
||||||
module.exports = function registerRbacRoutes(app, deps) {
|
module.exports = function registerRbacRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
@@ -359,7 +360,7 @@
|
|||||||
excludeUserId: currentUserId
|
excludeUserId: currentUserId
|
||||||
});
|
});
|
||||||
const users = mapUsersForView(data.users, viewModel.selectedUserIds);
|
const users = mapUsersForView(data.users, viewModel.selectedUserIds);
|
||||||
viewModel.role.inUse = String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0;
|
viewModel.role.inUse = String(viewModel.role.role_key || '') === DEFAULT_ROLE.key || String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0;
|
||||||
|
|
||||||
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')));
|
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -580,8 +581,8 @@
|
|||||||
if (!role) {
|
if (!role) {
|
||||||
return res.status(404).send('Role not found.');
|
return res.status(404).send('Role not found.');
|
||||||
}
|
}
|
||||||
if (String(role.role_key || '') === 'administrators') {
|
if (String(role.role_key || '') === DEFAULT_ROLE.key || String(role.role_key || '') === 'administrators') {
|
||||||
return res.redirect('/settings/roles?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
|
return res.redirect('/settings/roles?message=' + encodeURIComponent('The built-in Super Admin role cannot be deleted.'));
|
||||||
}
|
}
|
||||||
if (Number(role.user_count) > 0) {
|
if (Number(role.user_count) > 0) {
|
||||||
return res.redirect('/settings/roles?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
|
return res.redirect('/settings/roles?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ function buildRbacAddViewModel(message, currentUser, formValues, permissionGroup
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination) {
|
function buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination) {
|
||||||
|
const roleKey = String(role && role.role_key || '').trim();
|
||||||
|
const isProtectedRole = roleKey === 'super-admin' || roleKey === 'administrators';
|
||||||
|
const deleteDisabled = isProtectedRole || Boolean(role && role.inUse);
|
||||||
return {
|
return {
|
||||||
title: 'Edit role',
|
title: 'Edit role',
|
||||||
active: 'rbac',
|
active: 'rbac',
|
||||||
@@ -135,13 +138,16 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us
|
|||||||
footerCancelUrl: '/settings/roles',
|
footerCancelUrl: '/settings/roles',
|
||||||
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
|
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
|
||||||
footerDeleteUrl: '/settings/roles/' + Number(role && role.id) + '/delete',
|
footerDeleteUrl: '/settings/roles/' + Number(role && role.id) + '/delete',
|
||||||
footerDeleteDisabled: Boolean(role && role.inUse),
|
footerDeleteDisabled: deleteDisabled,
|
||||||
footerDeleteTitle: role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : '',
|
footerDeleteConfirmMessage: 'Delete ' + String(role && role.name || 'this role') + '?',
|
||||||
|
footerDeleteTitle: isProtectedRole
|
||||||
|
? 'The built-in Super Admin role cannot be deleted.'
|
||||||
|
: (role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : ''),
|
||||||
footerShowDelete: true,
|
footerShowDelete: true,
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
role: role,
|
role: Object.assign({}, role, { isProtected: isProtectedRole }),
|
||||||
inUse: Boolean(role && role.inUse),
|
inUse: deleteDisabled,
|
||||||
permissionGroups: permissionGroups || [],
|
permissionGroups: permissionGroups || [],
|
||||||
permissionSections: buildPermissionSections(permissionGroups),
|
permissionSections: buildPermissionSections(permissionGroups),
|
||||||
users: users || [],
|
users: users || [],
|
||||||
|
|||||||
@@ -159,12 +159,23 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedIds.length) {
|
if (selectedIds.length) {
|
||||||
await pool.query(
|
for (const screenId of selectedIds) {
|
||||||
'INSERT INTO d_announcement_screens (announcement_id, screen_id, created_by, modified_by) VALUES ? ON DUPLICATE KEY UPDATE modified_at = CURRENT_TIMESTAMP, modified_by = VALUES(modified_by)',
|
await pool.query(
|
||||||
[selectedIds.map(function (screenId) {
|
`UPDATE d_announcement_screens
|
||||||
return [announcementId, screenId, actorId || null, actorId || null];
|
SET modified_at = CURRENT_TIMESTAMP, modified_by = ?
|
||||||
})]
|
WHERE announcement_id = ? AND screen_id = ?`,
|
||||||
);
|
[actorId || null, announcementId, screenId]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO d_announcement_screens (announcement_id, screen_id, created_by, modified_by)
|
||||||
|
SELECT ?, ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM d_announcement_screens
|
||||||
|
WHERE announcement_id = ? AND screen_id = ?
|
||||||
|
)`,
|
||||||
|
[announcementId, screenId, actorId || null, actorId || null, announcementId, screenId]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-end flex-wrap">
|
<div class="card-footer d-flex justify-content-end flex-wrap">
|
||||||
<div class="btn-group" role="group" aria-label="Role actions">
|
<div class="btn-group" role="group" aria-label="Role actions">
|
||||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl cancelConfirmMessage=cancelConfirmMessage deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showDelete=footerShowDelete}}}
|
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl cancelConfirmMessage=cancelConfirmMessage deleteUrl=footerDeleteUrl deleteConfirmMessage=footerDeleteConfirmMessage deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showDelete=footerShowDelete}}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,5 +76,5 @@ test('save app settings writes only supplied keys in one transaction', async ()
|
|||||||
assert.equal(queries[0], 'begin');
|
assert.equal(queries[0], 'begin');
|
||||||
assert.equal(queries[queries.length - 2], 'commit');
|
assert.equal(queries[queries.length - 2], 'commit');
|
||||||
assert.equal(queries[queries.length - 1], 'release');
|
assert.equal(queries[queries.length - 1], 'release');
|
||||||
assert.equal(queries.filter(function (entry) { return entry && entry.sql; }).length, 1);
|
assert.equal(queries.filter(function (entry) { return entry && entry.sql; }).length, 2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ test('player registry upserts include the identifier field', async () => {
|
|||||||
if (String(sql || '').includes('information_schema.COLUMNS')) {
|
if (String(sql || '').includes('information_schema.COLUMNS')) {
|
||||||
return [[{ column_count: 1 }]];
|
return [[{ column_count: 1 }]];
|
||||||
}
|
}
|
||||||
if (String(sql || '').includes('INSERT INTO d_players')) {
|
if (String(sql || '').includes('UPDATE d_players')) {
|
||||||
return [{ affectedRows: 1 }];
|
return [{ affectedRows: 1 }];
|
||||||
}
|
}
|
||||||
if (String(sql || '').includes('SELECT id, identifier, public_base_url, internal_base_url, last_seen_at')) {
|
if (String(sql || '').includes('SELECT id, identifier, public_base_url, internal_base_url, last_seen_at')) {
|
||||||
@@ -63,5 +63,5 @@ test('player registry upserts include the identifier field', async () => {
|
|||||||
}));
|
}));
|
||||||
assert.deepEqual((calls.find(function (call) {
|
assert.deepEqual((calls.find(function (call) {
|
||||||
return /INSERT INTO d_players/.test(String(call.sql || ''));
|
return /INSERT INTO d_players/.test(String(call.sql || ''));
|
||||||
}) || {}).params, ['Shop2', 'http://player.local', 'http://player:8081']);
|
}) || {}).params, ['Shop2', 'http://player.local', 'http://player:8081', 'Shop2']);
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,7 @@ 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 rbacRoutesSource = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8');
|
const rbacRoutesSource = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8');
|
||||||
const { buildPermissionSections } = require('../src/web/routes/settings/rbac/form-view-model');
|
const { buildPermissionSections, buildRbacEditViewModel } = 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');
|
||||||
|
|
||||||
@@ -112,3 +112,19 @@ test('rbac duplicate route exists', () => {
|
|||||||
assert.ok(duplicateHelpers.includes('buildDuplicateRoleName'));
|
assert.ok(duplicateHelpers.includes('buildDuplicateRoleName'));
|
||||||
assert.ok(duplicateHelpers.includes('buildDuplicateRole'));
|
assert.ok(duplicateHelpers.includes('buildDuplicateRole'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('built-in Super Admin role cannot be deleted but can be renamed', () => {
|
||||||
|
const model = buildRbacEditViewModel({
|
||||||
|
id: 1,
|
||||||
|
role_key: 'super-admin',
|
||||||
|
name: 'Super Admin',
|
||||||
|
inUse: false
|
||||||
|
}, '', null, [], [], null);
|
||||||
|
|
||||||
|
assert.equal(model.footerDeleteDisabled, true);
|
||||||
|
assert.equal(model.role.isProtected, true);
|
||||||
|
assert.equal(model.footerDeleteTitle, 'The built-in Super Admin role cannot be deleted.');
|
||||||
|
assert.equal(model.footerDeleteConfirmMessage, 'Delete Super Admin?');
|
||||||
|
assert.ok(!rbacFormTemplate.includes('The built-in role name cannot be changed.'));
|
||||||
|
assert.ok(!rbacFormTemplate.includes('The built-in role description cannot be changed.'));
|
||||||
|
});
|
||||||
@@ -77,3 +77,44 @@ test('pending migrations are reported when an older schema still needs scripts',
|
|||||||
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 1);
|
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 1);
|
||||||
assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0');
|
assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a partial timetable schema does not fast-forward migration detection', async () => {
|
||||||
|
const pool = createPool([
|
||||||
|
{
|
||||||
|
match(sql) {
|
||||||
|
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
|
||||||
|
},
|
||||||
|
result: [[{ column_count: 0 }]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match(_sql, params) {
|
||||||
|
return Array.isArray(params) && params[0] === 'i_timetable_groups';
|
||||||
|
},
|
||||||
|
result: [[{ table_count: 1 }]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match(_sql, params) {
|
||||||
|
return Array.isArray(params) && params[0] === 'i_timetable_entries';
|
||||||
|
},
|
||||||
|
result: [[{ table_count: 0 }]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match(_sql, params) {
|
||||||
|
return Array.isArray(params) && params[0] === 'i_schedule_groups';
|
||||||
|
},
|
||||||
|
result: [[{ table_count: 0 }]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match(_sql, params) {
|
||||||
|
return Array.isArray(params) && params[0] === 'i_schedule_entries';
|
||||||
|
},
|
||||||
|
result: [[{ table_count: 0 }]]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '0.0.0' });
|
||||||
|
|
||||||
|
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.16'; }));
|
||||||
|
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.17'; }));
|
||||||
|
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.18'; }));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user