Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea72747822 | ||
|
|
a5bf8e6f7f |
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
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.2 - 2026-08-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Standardized update audit events on from/to changes and added readable table diffs for nested JSON, arrays, null values, and empty strings.
|
||||||
|
- Standardized internal `src/data` imports on the `#src` alias.
|
||||||
|
|
||||||
|
## 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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.8.0",
|
"version": "2.8.2",
|
||||||
"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.2",
|
||||||
"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') {
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ function normalizeDetails(details) {
|
|||||||
return JSON.stringify(details);
|
return JSON.stringify(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAuditChanges(previousValues, nextValues) {
|
||||||
|
const previous = previousValues && typeof previousValues === 'object' ? previousValues : {};
|
||||||
|
const next = nextValues && typeof nextValues === 'object' ? nextValues : {};
|
||||||
|
const changes = {};
|
||||||
|
const keys = new Set(Object.keys(previous).concat(Object.keys(next)));
|
||||||
|
|
||||||
|
keys.forEach(function (key) {
|
||||||
|
if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) {
|
||||||
|
changes[key] = { from: previous[key], to: next[key] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
function getRequestMetadata(req) {
|
function getRequestMetadata(req) {
|
||||||
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||||
return {
|
return {
|
||||||
@@ -97,6 +112,7 @@ module.exports = {
|
|||||||
AUDIT_CATEGORY_KEYS,
|
AUDIT_CATEGORY_KEYS,
|
||||||
AUDIT_CATEGORY_LABELS,
|
AUDIT_CATEGORY_LABELS,
|
||||||
getRequestMetadata,
|
getRequestMetadata,
|
||||||
|
buildAuditChanges,
|
||||||
recordAuditEvent,
|
recordAuditEvent,
|
||||||
recordRequestAuditEvent
|
recordRequestAuditEvent
|
||||||
};
|
};
|
||||||
+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]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,39 @@
|
|||||||
.template-preview-card .btn-group {
|
.template-preview-card .btn-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
.audit-change-list {
|
||||||
|
display: grid;
|
||||||
|
padding: 0.08rem 0.3rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 18rem;
|
||||||
|
}
|
||||||
|
.audit-change-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
|
||||||
|
background: var(--bs-danger-bg-subtle);
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
background: var(--bs-success-bg-subtle);
|
||||||
|
.audit-change-to {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.audit-change-from {
|
||||||
|
color: var(--bs-danger-text-emphasis);
|
||||||
|
}
|
||||||
|
.audit-change-to {
|
||||||
|
color: var(--bs-success-text-emphasis);
|
||||||
|
}
|
||||||
|
.audit-change-from del,
|
||||||
|
.audit-change-to ins {
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
.audit-change-arrow {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Admin account route registration and profile helpers.
|
// Admin account route registration and profile helpers.
|
||||||
|
|
||||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
module.exports = function registerAccountRoutes(app, deps) {
|
module.exports = function registerAccountRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
||||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||||
|
|
||||||
module.exports = function registerContentRoutes(app, deps) {
|
module.exports = function registerContentRoutes(app, deps) {
|
||||||
@@ -39,6 +40,26 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
function normalizeTemplateRegionsForAudit(regions) {
|
||||||
|
return (Array.isArray(regions) ? regions : []).map(function (region) {
|
||||||
|
const animation = typeof region.animation_json === 'string'
|
||||||
|
? common.parseJsonSafe(region.animation_json) || {}
|
||||||
|
: region.animation_json || {};
|
||||||
|
return {
|
||||||
|
region_key: region.region_key,
|
||||||
|
region_type: region.region_type,
|
||||||
|
label: region.label,
|
||||||
|
lock_ratio: region.lock_ratio,
|
||||||
|
animation_json: animation,
|
||||||
|
x: Number(region.x),
|
||||||
|
y: Number(region.y),
|
||||||
|
width: Number(region.width),
|
||||||
|
height: Number(region.height),
|
||||||
|
z_index: Number(region.z_index)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSlideFormData() {
|
async function fetchSlideFormData() {
|
||||||
const data = await common.fetchTemplatesData(pool);
|
const data = await common.fetchTemplatesData(pool);
|
||||||
const rssData = await common.fetchRssFeedsData(pool);
|
const rssData = await common.fetchRssFeedsData(pool);
|
||||||
@@ -497,6 +518,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
title: slide.title,
|
||||||
|
templateId: Number(slide.template_id),
|
||||||
|
content: slide.content
|
||||||
|
}, {
|
||||||
|
title: payload.title,
|
||||||
|
templateId: Number(payload.templateId),
|
||||||
|
content: common.parseJsonSafe(payload.contentJson) || {}
|
||||||
|
});
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
||||||
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
||||||
@@ -512,7 +542,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
screenSlideCounts: screenSlideCounts
|
screenSlideCounts: screenSlideCounts
|
||||||
});
|
});
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title });
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
|
||||||
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
||||||
console.warn('Unable to queue slide thumbnail refresh:', error);
|
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||||
});
|
});
|
||||||
@@ -655,6 +685,19 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: template.name,
|
||||||
|
canvasSizeId: Number(template.canvas_size_id),
|
||||||
|
backgroundImagePath: template.background_image_path,
|
||||||
|
backgroundColor: template.background_color,
|
||||||
|
regions: normalizeTemplateRegionsForAudit(template.regions)
|
||||||
|
}, {
|
||||||
|
name: payload.name,
|
||||||
|
canvasSizeId: Number(payload.canvasSizeId),
|
||||||
|
backgroundImagePath: payload.backgroundImagePath,
|
||||||
|
backgroundColor: payload.backgroundColor,
|
||||||
|
regions: normalizeTemplateRegionsForAudit(payload.regions)
|
||||||
|
});
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
|
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
|
||||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
|
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
|
||||||
@@ -681,7 +724,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name });
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name, details: { changes: changes } });
|
||||||
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
|
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
|
||||||
closeUrl: '/templates',
|
closeUrl: '/templates',
|
||||||
newUrl: '/templates/new',
|
newUrl: '/templates/new',
|
||||||
@@ -813,8 +856,17 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||||
return res.status(400).send('That canvas size already exists.');
|
return res.status(400).send('That canvas size already exists.');
|
||||||
}
|
}
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: canvasSize.name,
|
||||||
|
width: Number(canvasSize.width),
|
||||||
|
height: Number(canvasSize.height)
|
||||||
|
}, {
|
||||||
|
name: payload.name,
|
||||||
|
width: payload.width,
|
||||||
|
height: payload.height
|
||||||
|
});
|
||||||
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
||||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { width: payload.width, height: payload.height } });
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { changes: changes } });
|
||||||
redirectAfterSave(req, res, '/canvas-sizes', {
|
redirectAfterSave(req, res, '/canvas-sizes', {
|
||||||
closeUrl: '/canvas-sizes',
|
closeUrl: '/canvas-sizes',
|
||||||
newUrl: '/canvas-sizes/new',
|
newUrl: '/canvas-sizes/new',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Admin manage routes for screens and commands.
|
// Admin manage routes for screens and commands.
|
||||||
|
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerManageRoutes(app, deps) {
|
module.exports = function registerManageRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
@@ -248,6 +250,15 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
const previousPlaylistId = screen.playlist_id;
|
const previousPlaylistId = screen.playlist_id;
|
||||||
const slug = String(screen.slug || '').trim();
|
const slug = String(screen.slug || '').trim();
|
||||||
const previousSlug = String(screen.slug || '').trim();
|
const previousSlug = String(screen.slug || '').trim();
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: screen.name,
|
||||||
|
slug: previousSlug,
|
||||||
|
playlistId: previousPlaylistId === null ? null : Number(previousPlaylistId)
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
slug: slug,
|
||||||
|
playlistId: playlistId
|
||||||
|
});
|
||||||
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||||
@@ -264,7 +275,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
await forwardPlayerCommand(previousSlug, redirectPayload);
|
await forwardPlayerCommand(previousSlug, redirectPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { slug: slug, playlistId: playlistId } });
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { changes: changes } });
|
||||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||||
closeUrl: '/screens',
|
closeUrl: '/screens',
|
||||||
newUrl: '/screens/new',
|
newUrl: '/screens/new',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Playlist admin routes and playlist-slide management.
|
// Playlist admin routes and playlist-slide management.
|
||||||
|
|
||||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerPlaylistRoutes(app, deps) {
|
module.exports = function registerPlaylistRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
@@ -356,7 +357,20 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
await connection.commit();
|
await connection.commit();
|
||||||
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name });
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: playlist.name,
|
||||||
|
fadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
||||||
|
skipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
|
||||||
|
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id)
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
fadeBetweenSlides: Boolean(fadeBetweenSlides),
|
||||||
|
skipUnavailableRtmp: Boolean(skipUnavailableRtmp),
|
||||||
|
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId)
|
||||||
|
});
|
||||||
|
await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name, details: { changes: changes } });
|
||||||
|
}
|
||||||
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
|
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
|
||||||
closeUrl: '/playlists',
|
closeUrl: '/playlists',
|
||||||
newUrl: '/playlists/new',
|
newUrl: '/playlists/new',
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Admin RBAC route registration and permission management.
|
// Admin RBAC route registration and permission management.
|
||||||
|
const { DEFAULT_ROLE } = require('#src/rbac');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerRbacRoutes(app, deps) {
|
module.exports = function registerRbacRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
@@ -359,7 +361,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) {
|
||||||
@@ -412,10 +414,14 @@
|
|||||||
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingRolePermissionKeys = shouldSyncPermissions
|
||||||
|
? await rbacData.fetchRolePermissionKeys(pool, roleId)
|
||||||
|
: [];
|
||||||
|
let existingRoleUserIds = [];
|
||||||
let availableUsers = [];
|
let availableUsers = [];
|
||||||
if (shouldSyncUsers) {
|
if (shouldSyncUsers) {
|
||||||
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||||
const existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||||
const visibleUserIdSet = new Set(visibleUserIds);
|
const visibleUserIdSet = new Set(visibleUserIds);
|
||||||
normalizedUserIds = existingRoleUserIds.filter(function (userId) {
|
normalizedUserIds = existingRoleUserIds.filter(function (userId) {
|
||||||
return !visibleUserIdSet.has(userId);
|
return !visibleUserIdSet.has(userId);
|
||||||
@@ -451,6 +457,17 @@
|
|||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
if (typeof recordRequestAuditEvent === 'function') {
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: role.name,
|
||||||
|
description: role.description,
|
||||||
|
permissionKeys: existingRolePermissionKeys,
|
||||||
|
userIds: existingRoleUserIds
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
description: description || null,
|
||||||
|
permissionKeys: normalizedPermissionKeys,
|
||||||
|
userIds: normalizedUserIds
|
||||||
|
});
|
||||||
await recordRequestAuditEvent(pool, req, {
|
await recordRequestAuditEvent(pool, req, {
|
||||||
category: 'roles',
|
category: 'roles',
|
||||||
eventType: 'role.updated',
|
eventType: 'role.updated',
|
||||||
@@ -458,7 +475,7 @@
|
|||||||
targetType: 'role',
|
targetType: 'role',
|
||||||
targetId: roleId,
|
targetId: roleId,
|
||||||
targetLabel: name,
|
targetLabel: name,
|
||||||
details: { permissionsChanged: shouldSyncPermissions, usersChanged: shouldSyncUsers }
|
details: { changes: changes }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
||||||
@@ -493,6 +510,7 @@
|
|||||||
})) {
|
})) {
|
||||||
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||||
}
|
}
|
||||||
|
const existingPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
@@ -513,7 +531,7 @@
|
|||||||
targetType: 'role',
|
targetType: 'role',
|
||||||
targetId: roleId,
|
targetId: roleId,
|
||||||
targetLabel: role.name,
|
targetLabel: role.name,
|
||||||
details: { permissionKeys: normalizedPermissionKeys }
|
details: { changes: buildAuditChanges({ permissionKeys: existingPermissionKeys }, { permissionKeys: normalizedPermissionKeys }) }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||||
@@ -540,6 +558,7 @@
|
|||||||
? [].concat(req.body.user_ids)
|
? [].concat(req.body.user_ids)
|
||||||
: [];
|
: [];
|
||||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||||
|
const existingUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||||
return Number(user.id);
|
return Number(user.id);
|
||||||
@@ -560,7 +579,7 @@
|
|||||||
targetType: 'role',
|
targetType: 'role',
|
||||||
targetId: roleId,
|
targetId: roleId,
|
||||||
targetLabel: role.name,
|
targetLabel: role.name,
|
||||||
details: { userIds: normalizedUserIds }
|
details: { changes: buildAuditChanges({ userIds: existingUserIds }, { userIds: normalizedUserIds }) }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
||||||
@@ -580,8 +599,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.'));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Admin user route registration and user-role management.
|
// Admin user route registration and user-role management.
|
||||||
|
|
||||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerUsersRoutes(app, deps) {
|
module.exports = function registerUsersRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
@@ -319,6 +320,8 @@
|
|||||||
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingUser = await rbacData.fetchUserWithRoles(pool, userId);
|
||||||
|
const changes = buildAuditChanges({ roleIds: existingUser ? existingUser.roleIds : [] }, { roleIds: roleCheck.roleIds });
|
||||||
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
||||||
if (typeof recordRequestAuditEvent === 'function') {
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
await recordRequestAuditEvent(pool, req, {
|
await recordRequestAuditEvent(pool, req, {
|
||||||
@@ -328,7 +331,7 @@
|
|||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: userId,
|
targetId: userId,
|
||||||
targetLabel: String(userId),
|
targetLabel: String(userId),
|
||||||
details: { roleIds: roleCheck.roleIds }
|
details: { changes: changes }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||||
@@ -404,6 +407,19 @@
|
|||||||
return renderValidationError('That username already exists.');
|
return renderValidationError('That username already exists.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: user.name,
|
||||||
|
username: user.username,
|
||||||
|
roleIds: user.roleIds,
|
||||||
|
accountLocked: Boolean(user.account_locked),
|
||||||
|
passwordReset: false
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
username: username,
|
||||||
|
roleIds: roleCheck.roleIds,
|
||||||
|
accountLocked: accountLocked,
|
||||||
|
passwordReset: shouldUpdatePassword
|
||||||
|
});
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]);
|
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]);
|
||||||
if (!result.affectedRows) {
|
if (!result.affectedRows) {
|
||||||
@@ -432,7 +448,7 @@
|
|||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: userId,
|
targetId: userId,
|
||||||
targetLabel: username,
|
targetLabel: username,
|
||||||
details: { roleIds: roleCheck.roleIds, passwordReset: shouldUpdatePassword, accountLocked: accountLocked }
|
details: { changes: changes }
|
||||||
});
|
});
|
||||||
if (Boolean(user.account_locked) !== accountLocked) {
|
if (Boolean(user.account_locked) !== accountLocked) {
|
||||||
await recordRequestAuditEvent(pool, req, {
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Authentication route registration for the web app.
|
// Authentication route registration for the web app.
|
||||||
|
|
||||||
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
|
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
|
||||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
module.exports = function registerAuthRoutes(app, deps) {
|
module.exports = function registerAuthRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const renderApiSourcesPage = require('./list');
|
|||||||
const renderApiSourceAddPage = require('./add');
|
const renderApiSourceAddPage = require('./add');
|
||||||
const renderApiSourceEditPage = require('./edit');
|
const renderApiSourceEditPage = require('./edit');
|
||||||
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
|
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
|
||||||
const { fetchAppSettings } = require('../../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
function toIsoTimestamp(value) {
|
function toIsoTimestamp(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const renderRssFeedsPage = require('./list');
|
|||||||
const renderRssFeedAddPage = require('./add');
|
const renderRssFeedAddPage = require('./add');
|
||||||
const renderRssFeedEditPage = require('./edit');
|
const renderRssFeedEditPage = require('./edit');
|
||||||
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
|
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
|
||||||
const { fetchAppSettings } = require('../../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
async function getDataSourceUsageMaps(pool, common) {
|
async function getDataSourceUsageMaps(pool, common) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Shared timetable group form view-model builder.
|
// Shared timetable group form view-model builder.
|
||||||
|
|
||||||
const { normalizeTimeZone } = require('../../../../data/timetables');
|
const { normalizeTimeZone } = require('#src/data/timetables');
|
||||||
|
|
||||||
const COMMON_TIME_ZONES = [
|
const COMMON_TIME_ZONES = [
|
||||||
'UTC',
|
'UTC',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Timetable group list page renderer.
|
// Timetable group list page renderer.
|
||||||
|
|
||||||
const { renderView } = require('../../../view');
|
const { renderView } = require('../../../view');
|
||||||
const { normalizeTimeZone } = require('../../../../data/timetables');
|
const { normalizeTimeZone } = require('#src/data/timetables');
|
||||||
|
|
||||||
function formatDateInTimeZone(value, timeZone) {
|
function formatDateInTimeZone(value, timeZone) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery
|
|||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
const SORTED_AUDIT_CATEGORY_KEYS = AUDIT_CATEGORY_KEYS.slice().sort(function (left, right) {
|
||||||
|
return String(left).localeCompare(String(right));
|
||||||
|
});
|
||||||
|
|
||||||
function requireAuditLogAccess(setAuthMessageCookie) {
|
function requireAuditLogAccess(setAuthMessageCookie) {
|
||||||
return function (req, res, next) {
|
return function (req, res, next) {
|
||||||
if (!req.currentUser) {
|
if (!req.currentUser) {
|
||||||
@@ -44,11 +48,109 @@ function csvCell(value) {
|
|||||||
return '"' + text.replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"';
|
return '"' + text.replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value) {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAuditValue(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value === undefined) {
|
||||||
|
return 'undefined';
|
||||||
|
}
|
||||||
|
if (value === null || typeof value !== 'object') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimitiveArray(value) {
|
||||||
|
return Array.isArray(value) && value.every(function (item) {
|
||||||
|
return item === null || typeof item !== 'object';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingAuditValue(value) {
|
||||||
|
return value === null || value === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAuditChangeRows(previousValue, nextValue, path, rows) {
|
||||||
|
if (isMissingAuditValue(previousValue) && isMissingAuditValue(nextValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMissingAuditValue(previousValue)) {
|
||||||
|
rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(nextValue) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMissingAuditValue(nextValue)) {
|
||||||
|
rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(previousValue), to: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrimitiveArray(previousValue) && isPrimitiveArray(nextValue)) {
|
||||||
|
const previousItems = new Set(previousValue.map(function (item) { return JSON.stringify(item); }));
|
||||||
|
const nextItems = new Set(nextValue.map(function (item) { return JSON.stringify(item); }));
|
||||||
|
const removedItems = previousValue.filter(function (item) { return !nextItems.has(JSON.stringify(item)); });
|
||||||
|
const addedItems = nextValue.filter(function (item) { return !previousItems.has(JSON.stringify(item)); });
|
||||||
|
if (removedItems.length) {
|
||||||
|
rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(removedItems), to: '' });
|
||||||
|
}
|
||||||
|
if (addedItems.length) {
|
||||||
|
rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(addedItems) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(previousValue) === JSON.stringify(nextValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(previousValue) && isRecord(nextValue)) {
|
||||||
|
const keys = new Set(Object.keys(previousValue).concat(Object.keys(nextValue)));
|
||||||
|
keys.forEach(function (key) {
|
||||||
|
collectAuditChangeRows(previousValue[key], nextValue[key], path ? path + '.' + key : key, rows);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(previousValue) && Array.isArray(nextValue) && previousValue.length === nextValue.length && previousValue.some(isRecord)) {
|
||||||
|
for (let index = 0; index < previousValue.length; index += 1) {
|
||||||
|
collectAuditChangeRows(previousValue[index], nextValue[index], path + '[' + index + ']', rows);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
path: path,
|
||||||
|
direction: 'changed',
|
||||||
|
from: formatAuditValue(previousValue),
|
||||||
|
to: formatAuditValue(nextValue)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuditDetailView(details) {
|
||||||
|
if (!isRecord(details) || !isRecord(details.changes)) {
|
||||||
|
return { hasChanges: false, changeRows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
Object.keys(details.changes).forEach(function (key) {
|
||||||
|
const change = details.changes[key];
|
||||||
|
if (isRecord(change) && Object.prototype.hasOwnProperty.call(change, 'from') && Object.prototype.hasOwnProperty.call(change, 'to')) {
|
||||||
|
collectAuditChangeRows(change.from, change.to, key, rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { hasChanges: rows.length > 0, changeRows: rows };
|
||||||
|
}
|
||||||
|
|
||||||
function mapAuditRow(row, formatDashboardDate) {
|
function mapAuditRow(row, formatDashboardDate) {
|
||||||
let details = '';
|
let details = '';
|
||||||
|
let parsedDetails = null;
|
||||||
if (row.details_json) {
|
if (row.details_json) {
|
||||||
try {
|
try {
|
||||||
details = JSON.stringify(typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json);
|
parsedDetails = typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json;
|
||||||
|
details = JSON.stringify(parsedDetails);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
details = String(row.details_json);
|
details = String(row.details_json);
|
||||||
}
|
}
|
||||||
@@ -60,7 +162,8 @@ function mapAuditRow(row, formatDashboardDate) {
|
|||||||
actorLabel: row.actor_name || row.actor_username || 'System',
|
actorLabel: row.actor_name || row.actor_username || 'System',
|
||||||
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
|
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
|
||||||
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
|
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
|
||||||
detailsDisplay: details
|
detailsDisplay: details,
|
||||||
|
auditDetailView: buildAuditDetailView(parsedDetails)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +239,7 @@ module.exports = function registerAuditLogRoutes(app, deps) {
|
|||||||
active: 'audit-log',
|
active: 'audit-log',
|
||||||
currentUser: req.currentUser,
|
currentUser: req.currentUser,
|
||||||
events: events,
|
events: events,
|
||||||
categories: AUDIT_CATEGORY_KEYS,
|
categories: SORTED_AUDIT_CATEGORY_KEYS,
|
||||||
eventTypes: eventTypes,
|
eventTypes: eventTypes,
|
||||||
selectedCategory: category,
|
selectedCategory: category,
|
||||||
selectedEventType: eventType,
|
selectedEventType: eventType,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
|||||||
const { buildPagination } = require('../../../lib/pagination');
|
const { buildPagination } = require('../../../lib/pagination');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
|
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
|
||||||
const { fetchAppSettings } = require('../../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
const LIST_PAGE_SIZE = 25;
|
const LIST_PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,22 @@
|
|||||||
<td>{{actorLabel}}</td>
|
<td>{{actorLabel}}</td>
|
||||||
<td>{{targetLabelDisplay}}</td>
|
<td>{{targetLabelDisplay}}</td>
|
||||||
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{user_agent}}</div></td>
|
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{user_agent}}</div></td>
|
||||||
<td class="text-break"><small>{{detailsDisplay}}</small></td>
|
<td class="text-break">
|
||||||
|
{{#if auditDetailView.hasChanges}}
|
||||||
|
<div class="audit-change-list">
|
||||||
|
{{#each auditDetailView.changeRows}}
|
||||||
|
<div class="audit-change-row">
|
||||||
|
<code class="audit-change-path">{{path}}</code>
|
||||||
|
<span class="audit-change-from"><span class="visually-hidden">From: </span>{{from}}</span>
|
||||||
|
{{#if (eq direction "changed")}}<span class="audit-change-arrow" aria-hidden="true">→</span>{{else}}<span></span>{{/if}}
|
||||||
|
<span class="audit-change-to"><span class="visually-hidden">To: </span>{{to}}</span>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<small>{{detailsDisplay}}</small>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerAccountRoutes = require('../src/web/routes/admin/account');
|
const registerAccountRoutes = require('../src/web/routes/admin/account');
|
||||||
const { validatePasswordStrength } = require('../src/auth');
|
const { validatePasswordStrength } = require('../src/auth');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||||
|
|
||||||
test('user create route allows users without roles', async () => {
|
test('user create route allows users without roles', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||||
|
|
||||||
test('user edit save and new goes to the blank create page', async () => {
|
test('user edit save and new goes to the blank create page', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||||
const { validatePasswordStrength } = require('../src/auth');
|
const { validatePasswordStrength } = require('../src/auth');
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,26 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
category: 'authentication',
|
category: 'authentication',
|
||||||
event_type: 'login.success',
|
event_type: 'login.success',
|
||||||
target_label: 'newer',
|
target_label: 'newer',
|
||||||
details_json: null
|
details_json: JSON.stringify({
|
||||||
|
changes: {
|
||||||
|
content: {
|
||||||
|
from: { title: 'Same', body: { color: 'red', keep: 'same' } },
|
||||||
|
to: { title: 'Same', body: { color: 'blue', keep: 'same' } }
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
from: ['dashboard.read', 'screens.read'],
|
||||||
|
to: ['screens.read', 'playlists.read']
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
from: null,
|
||||||
|
to: '#111111'
|
||||||
|
},
|
||||||
|
logoPath: {
|
||||||
|
from: '',
|
||||||
|
to: '/media/logo.png'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -60,10 +79,23 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
assert.match(response.body, /Audit log/);
|
assert.match(response.body, /Audit log/);
|
||||||
|
assert.ok(response.body.indexOf('>All categories</option>') < response.body.indexOf('>announcements</option>'));
|
||||||
|
assert.ok(response.body.indexOf('>announcements</option>') < response.body.indexOf('>api-sources</option>'));
|
||||||
|
assert.ok(response.body.indexOf('>api-sources</option>') < response.body.indexOf('>canvas-sizes</option>'));
|
||||||
assert.match(response.body, /table-pagination/);
|
assert.match(response.body, /table-pagination/);
|
||||||
assert.match(response.body, /data-local-datetime/);
|
assert.match(response.body, /data-local-datetime/);
|
||||||
assert.match(response.body, /audit-event-type-options/);
|
assert.match(response.body, /audit-event-type-options/);
|
||||||
assert.match(response.body, /login\.success/);
|
assert.match(response.body, /login\.success/);
|
||||||
|
assert.match(response.body, /content\.body\.color/);
|
||||||
|
assert.match(response.body, />red</);
|
||||||
|
assert.match(response.body, />blue</);
|
||||||
|
assert.doesNotMatch(response.body, /content\.body\.keep/);
|
||||||
|
assert.match(response.body, /permissions removed/);
|
||||||
|
assert.match(response.body, /permissions added/);
|
||||||
|
assert.match(response.body, /dashboard\.read/);
|
||||||
|
assert.match(response.body, /playlists\.read/);
|
||||||
|
assert.match(response.body, /backgroundColor added/);
|
||||||
|
assert.match(response.body, /logoPath added/);
|
||||||
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
|
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
|
||||||
assert.match(response.body, />Export</);
|
assert.match(response.body, />Export</);
|
||||||
assert.ok(handlers['/settings/audit-log/export']);
|
assert.ok(handlers['/settings/audit-log/export']);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
|||||||
|
|
||||||
require('../src/common');
|
require('../src/common');
|
||||||
|
|
||||||
const { recordRequestAuditEvent } = require('../src/data/audit-log');
|
const { buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
|
||||||
|
|
||||||
function createPool(settings) {
|
function createPool(settings) {
|
||||||
const inserts = [];
|
const inserts = [];
|
||||||
@@ -44,4 +44,19 @@ test('audit writer omits request metadata when disabled', async () => {
|
|||||||
assert.equal(pool.inserts.length, 1);
|
assert.equal(pool.inserts.length, 1);
|
||||||
assert.equal(pool.inserts[0][6], null);
|
assert.equal(pool.inserts[0][6], null);
|
||||||
assert.equal(pool.inserts[0][7], null);
|
assert.equal(pool.inserts[0][7], null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit changes include only fields with different from and to values', () => {
|
||||||
|
assert.deepEqual(buildAuditChanges({
|
||||||
|
name: 'Old name',
|
||||||
|
playlistId: 4,
|
||||||
|
roleIds: [1, 2]
|
||||||
|
}, {
|
||||||
|
name: 'New name',
|
||||||
|
playlistId: 4,
|
||||||
|
roleIds: [2, 3]
|
||||||
|
}), {
|
||||||
|
name: { from: 'Old name', to: 'New name' },
|
||||||
|
roleIds: { from: [1, 2], to: [2, 3] }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
|
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
|
||||||
|
|
||||||
function createAppAndHandlers() {
|
function createAppAndHandlers() {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
@@ -111,4 +111,20 @@ test('rbac duplicate route exists', () => {
|
|||||||
assert.ok(rbacRoutes.includes("requirePermission('rbac.create')"));
|
assert.ok(rbacRoutes.includes("requirePermission('rbac.create')"));
|
||||||
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.'));
|
||||||
});
|
});
|
||||||
@@ -76,4 +76,45 @@ test('pending migrations are reported when an older schema still needs scripts',
|
|||||||
assert.ok(pendingMigrations.length > 0);
|
assert.ok(pendingMigrations.length > 0);
|
||||||
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'; }));
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,8 @@ const assert = require('node:assert/strict');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
buildDuplicateTimetableGroupName,
|
buildDuplicateTimetableGroupName,
|
||||||
buildDuplicateTimetableGroup,
|
buildDuplicateTimetableGroup,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||||
|
|
||||||
test('user duplicate route pre-fills the add form', async () => {
|
test('user duplicate route pre-fills the add form', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
||||||
|
|
||||||
test('screen update keeps the existing slug on edit', async () => {
|
test('screen update keeps the existing slug on edit', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user