Implement RBAC roles system

This commit is contained in:
2026-07-21 21:05:02 +01:00
parent 7973ee0ea4
commit e051958bea
35 changed files with 2338 additions and 2257 deletions
+5
View File
@@ -13,9 +13,12 @@ It runs as two connected services:
- Create and organize playlists and slides
- Design reusable templates and canvas sizes
- Register screens and assign playlists to them
- Manage roles and permissions for the admin web UI
- Upload images and other media for use in slides and templates
- View live screen connections and send player commands
Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately.
## Documentation
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands.
@@ -34,6 +37,8 @@ When the app starts for the first time, it creates the database tables it needs
- Username: `admin`
- Password: `admin`
The first admin account is placed into the built-in `Administrators` role, which has full web-admin access through the CRUD permissions.
You can change the initial admin credentials with these optional environment variables:
- `DEFAULT_ADMIN_USERNAME`
-1927
View File
File diff suppressed because it is too large Load Diff
+312
View File
@@ -1,5 +1,6 @@
const mysql = require('mysql2/promise');
const { hashPassword } = require('./auth');
const { PERMISSIONS, DEFAULT_ROLE, normalizePermissionKeys } = require('./rbac');
function createPool() {
return mysql.createPool({
@@ -38,6 +39,229 @@ async function pruneStaleOnboardingDevices(pool) {
);
}
async function getTableColumnNames(pool, tableName) {
const [rows] = await pool.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?`,
[tableName]
);
return new Set((rows || []).map(function (row) {
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
}).filter(Boolean));
}
function getPermissionKey(row) {
return String((row && (row.permission_key || row.perm_key)) || '').trim().toLowerCase();
}
function getLegacyPermissionTargets(permissionKey) {
const normalizedKey = String(permissionKey || '').trim().toLowerCase();
const parts = normalizedKey.split('.');
if (parts.length !== 2) {
return [normalizedKey].filter(Boolean);
}
const sectionKey = parts[0];
const actionKey = parts[1];
if (actionKey === 'view') {
return [`${sectionKey}.read`];
}
if (actionKey === 'manage') {
return [`${sectionKey}.read`, `${sectionKey}.create`, `${sectionKey}.edit`, `${sectionKey}.delete`];
}
return [normalizedKey].filter(Boolean);
}
function buildPermissionSeedColumns(columnNames) {
const columns = [];
if (columnNames.has('permission_key')) {
columns.push('permission_key');
}
if (columnNames.has('perm_key')) {
columns.push('perm_key');
}
if (columnNames.has('name')) {
columns.push('name');
}
if (columnNames.has('section_name')) {
columns.push('section_name');
}
if (columnNames.has('description')) {
columns.push('description');
}
if (columnNames.has('created_by')) {
columns.push('created_by');
}
if (columnNames.has('modified_by')) {
columns.push('modified_by');
}
return columns;
}
async function backfillLegacyRbacSchema(pool) {
const permissionColumnNames = await getTableColumnNames(pool, 'permissions');
const hasPermissionKeyColumn = permissionColumnNames.has('permission_key');
const hasLegacyPermissionKeyColumn = permissionColumnNames.has('perm_key');
const [permissionRows] = await pool.query('SELECT id, permission_key, perm_key, name, section_name FROM permissions ORDER BY id ASC');
const [rolePermissionRows] = await pool.query(
`SELECT rp.role_id, p.permission_key, p.perm_key
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id`
);
const rolePermissionTargets = new Map();
const desiredPermissionKeys = new Set(PERMISSIONS.map(function (permission) {
return permission.key;
}));
const legacyPermissionRowIds = [];
function addRoleTarget(roleId, permissionKey) {
const normalizedPermissionKey = String(permissionKey || '').trim().toLowerCase();
if (!normalizedPermissionKey) {
return;
}
if (!rolePermissionTargets.has(roleId)) {
rolePermissionTargets.set(roleId, new Set());
}
rolePermissionTargets.get(roleId).add(normalizedPermissionKey);
}
for (const row of rolePermissionRows || []) {
const currentKey = getPermissionKey(row);
const targetKeys = getLegacyPermissionTargets(currentKey);
if (currentKey.endsWith('.view') || currentKey.endsWith('.manage')) {
for (const targetKey of targetKeys) {
addRoleTarget(Number(row.role_id), targetKey);
}
} else {
addRoleTarget(Number(row.role_id), currentKey);
}
}
for (const row of permissionRows || []) {
const currentKey = getPermissionKey(row);
if (currentKey.endsWith('.view') || currentKey.endsWith('.manage')) {
legacyPermissionRowIds.push(Number(row.id));
}
}
const roleRows = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC').then(function (result) {
return result[0] || [];
});
const defaultRoleRow = roleRows.find(function (row) {
return String(row.role_key || '').trim().toLowerCase() === DEFAULT_ROLE.key;
}) || null;
if (defaultRoleRow) {
if (!rolePermissionTargets.has(Number(defaultRoleRow.id))) {
rolePermissionTargets.set(Number(defaultRoleRow.id), new Set());
}
const defaultPermissions = rolePermissionTargets.get(Number(defaultRoleRow.id));
for (const permission of PERMISSIONS) {
defaultPermissions.add(permission.key);
}
}
const seedColumns = buildPermissionSeedColumns(permissionColumnNames);
if (!seedColumns.length) {
throw new Error('permissions table is missing required columns.');
}
for (const permission of PERMISSIONS) {
const seedValues = [];
if (permissionColumnNames.has('permission_key')) {
seedValues.push(permission.key);
}
if (permissionColumnNames.has('perm_key')) {
seedValues.push(permission.key);
}
if (permissionColumnNames.has('name')) {
seedValues.push(permission.name);
}
if (permissionColumnNames.has('section_name')) {
seedValues.push(permission.sectionName);
}
if (permissionColumnNames.has('description')) {
seedValues.push(permission.description || null);
}
if (permissionColumnNames.has('created_by')) {
seedValues.push(null);
}
if (permissionColumnNames.has('modified_by')) {
seedValues.push(null);
}
const updateAssignments = [];
if (permissionColumnNames.has('name')) {
updateAssignments.push('name = VALUES(name)');
}
if (permissionColumnNames.has('section_name')) {
updateAssignments.push('section_name = VALUES(section_name)');
}
if (permissionColumnNames.has('description')) {
updateAssignments.push('description = VALUES(description)');
}
if (permissionColumnNames.has('perm_key')) {
updateAssignments.push('perm_key = VALUES(perm_key)');
}
if (permissionColumnNames.has('permission_key')) {
updateAssignments.push('permission_key = VALUES(permission_key)');
}
await pool.query(
`INSERT INTO permissions (${seedColumns.join(', ')})
VALUES (${seedColumns.map(function () { return '?'; }).join(', ')})
ON DUPLICATE KEY UPDATE ${updateAssignments.join(', ')}`,
seedValues
);
}
if (legacyPermissionRowIds.length) {
await pool.query('DELETE FROM permissions WHERE id IN (?)', [legacyPermissionRowIds]);
}
const [currentPermissionRows] = await pool.query('SELECT id, permission_key, perm_key FROM permissions');
const permissionIdByKey = new Map();
for (const row of currentPermissionRows || []) {
const currentKey = getPermissionKey(row);
if (currentKey) {
permissionIdByKey.set(currentKey, Number(row.id));
}
}
await pool.query('DELETE FROM role_permissions');
for (const [roleId, permissionKeys] of rolePermissionTargets.entries()) {
const expandedPermissionKeys = normalizePermissionKeys(Array.from(permissionKeys.values()));
for (const permissionKey of expandedPermissionKeys) {
const permissionId = permissionIdByKey.get(permissionKey);
if (!permissionId) {
continue;
}
await pool.query(
'INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[Number(roleId), permissionId, null, null]
);
}
}
const [roleRowsAfter] = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC');
for (const row of roleRowsAfter || []) {
const currentKey = String(row.role_key || '').trim();
const isAdministratorsRole = String(row.name || '').trim().toLowerCase() === DEFAULT_ROLE.name.toLowerCase();
const expectedKey = isAdministratorsRole ? DEFAULT_ROLE.key : `role-${row.id}`;
if (!currentKey || currentKey !== expectedKey) {
await pool.query(
'UPDATE roles SET role_key = ?, name = ?, description = COALESCE(description, ?) WHERE id = ?',
[expectedKey, String(row.name || '').trim() || DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, row.id]
);
}
}
}
async function ensureSchema(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS canvas_sizes (
@@ -239,6 +463,73 @@ async function ensureSchema(pool) {
await addColumnIfMissing(pool, 'users', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'users', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'roles', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'roles', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
permission_key VARCHAR(100) NOT NULL UNIQUE,
perm_key VARCHAR(100) NULL,
name VARCHAR(255) NOT NULL,
section_name VARCHAR(255) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'permissions', 'perm_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'permissions', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'permissions', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'role_permissions', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'role_permissions', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'user_roles', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'user_roles', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
session_hash CHAR(64) PRIMARY KEY,
@@ -265,6 +556,27 @@ async function ensureSchema(pool) {
}
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO roles (role_key, name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
);
await backfillLegacyRbacSchema(pool);
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM users`,
[defaultRoleId]
);
}
}
}
module.exports = {
+224
View File
@@ -0,0 +1,224 @@
const PERMISSION_SECTIONS = [
{
key: 'dashboard',
order: 10,
name: 'Dashboard',
sectionName: 'Main navigation',
actions: [
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' },
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' }
]
},
{
key: 'clients',
order: 20,
name: 'Connected clients',
sectionName: 'Main navigation',
actions: [
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' },
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' }
]
},
{
key: 'screens',
order: 30,
name: 'Screens',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View the screen list and open screen details.' },
{ key: 'create', name: 'Create', description: 'Create new screens.' },
{ key: 'edit', name: 'Update', description: 'Edit screens and send screen commands.' },
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
]
},
{
key: 'playlists',
order: 40,
name: 'Playlists',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View playlists and playlist contents.' },
{ key: 'create', name: 'Create', description: 'Create new playlists.' },
{ key: 'edit', name: 'Update', description: 'Edit playlists and playlist slides.' },
{ key: 'delete', name: 'Delete', description: 'Delete playlists.' }
]
},
{
key: 'slides',
order: 50,
name: 'Slides',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View slides.' },
{ key: 'create', name: 'Create', description: 'Create new slides.' },
{ key: 'edit', name: 'Update', description: 'Edit slide content.' },
{ key: 'delete', name: 'Delete', description: 'Delete slides.' }
]
},
{
key: 'templates',
order: 60,
name: 'Slide templates',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View slide templates.' },
{ key: 'create', name: 'Create', description: 'Create new slide templates.' },
{ key: 'edit', name: 'Update', description: 'Edit slide templates.' },
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' }
]
},
{
key: 'canvas-sizes',
order: 70,
name: 'Canvas sizes',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View canvas sizes.' },
{ key: 'create', name: 'Create', description: 'Create new canvas sizes.' },
{ key: 'edit', name: 'Update', description: 'Edit canvas sizes.' },
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
]
},
{
key: 'users',
order: 80,
name: 'Users',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View users and role assignments.' },
{ key: 'create', name: 'Create', description: 'Create new users.' },
{ key: 'edit', name: 'Update', description: 'Edit users, passwords, and role assignments.' },
{ key: 'delete', name: 'Delete', description: 'Delete users.' }
]
},
{
key: 'rbac',
order: 90,
name: 'Roles and permissions',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View roles and permissions.' },
{ key: 'create', name: 'Create', description: 'Create new roles.' },
{ key: 'edit', name: 'Update', description: 'Edit role details and permissions.' },
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
]
}
];
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) {
return section.actions.map(function (action) {
return {
key: `${section.key}.${action.key}`,
name: section.name,
sectionOrder: section.order,
actionName: action.name,
sectionName: section.sectionName,
sectionKey: section.key,
actionKey: action.key,
description: action.description
};
});
});
const DEFAULT_ROLE = {
key: 'administrators',
name: 'Administrators',
description: 'Full access to the admin interface.'
};
function normalizePermissionKey(permissionKey) {
return String(permissionKey || '').trim();
}
function normalizePermissionKeys(permissionKeys) {
const normalized = [];
(Array.isArray(permissionKeys) ? permissionKeys : []).forEach(function (permissionKey) {
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
if (!normalizedPermissionKey) {
return;
}
normalized.push(normalizedPermissionKey);
const parts = normalizedPermissionKey.split('.');
if (parts.length !== 2) {
return;
}
const sectionKey = parts[0];
const actionKey = parts[1];
if (actionKey === 'create' || actionKey === 'edit' || actionKey === 'delete') {
normalized.push(`${sectionKey}.read`);
}
if (actionKey === 'manage') {
normalized.push(`${sectionKey}.read`);
normalized.push(`${sectionKey}.create`);
normalized.push(`${sectionKey}.edit`);
normalized.push(`${sectionKey}.delete`);
}
if (actionKey === 'view') {
normalized.push(`${sectionKey}.read`);
}
});
return Array.from(new Set(normalized));
}
function hasPermission(currentUser, permissionKey) {
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
if (!normalizedPermissionKey || !currentUser) {
return false;
}
const permissionKeys = Array.isArray(currentUser.permissionKeys)
? currentUser.permissionKeys
: Array.isArray(currentUser.permissions)
? currentUser.permissions
: [];
return permissionKeys.map(normalizePermissionKey).includes(normalizedPermissionKey);
}
function hasAnyPermission(currentUser, permissionKeys) {
const normalizedPermissionKeys = normalizePermissionKeys(permissionKeys);
if (!normalizedPermissionKeys.length || !currentUser) {
return false;
}
return normalizedPermissionKeys.some(function (permissionKey) {
return hasPermission(currentUser, permissionKey);
});
}
function requirePermission(permissionKey) {
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
if (!normalizedPermissionKey) {
throw new Error('requirePermission requires a permission key.');
}
return function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (hasPermission(req.currentUser, normalizedPermissionKey)) {
return next();
}
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
next(error);
};
}
module.exports = {
PERMISSIONS,
PERMISSION_SECTIONS,
DEFAULT_ROLE,
hasPermission,
hasAnyPermission,
requirePermission,
normalizePermissionKeys
};
+51 -6
View File
@@ -15,6 +15,8 @@ const registerAdminManageRoutes = require('./web/routes/admin-manage');
const registerAdminScreenCommandRoutes = require('./web/routes/admin-screen-commands');
const registerAdminContentRoutes = require('./web/routes/admin-content');
const { createWebBootstrap } = require('./web/bootstrap');
const { requirePermission } = require('./rbac');
const rbacData = require('./web/rbac-data');
const { createPlayerActionService } = require('./web/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('./client-name-check');
const { createSessionService } = require('./web/session');
@@ -130,6 +132,7 @@ async function start() {
pool: pool,
common: common,
pages: pages,
requirePermission: requirePermission,
buildDashboardState: webBootstrap.buildDashboardState
});
@@ -149,7 +152,10 @@ async function start() {
pages: pages,
formatDashboardDate: formatDashboardDate,
getAuditUserId: getAuditUserId,
hashPassword: hashPassword
hashPassword: hashPassword,
readArrayField: readArrayField,
rbacData: rbacData,
requirePermission: requirePermission
});
registerAdminManageRoutes(app, {
@@ -172,7 +178,8 @@ async function start() {
getScreenConnections: playerActionService.getScreenConnections,
getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage,
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
requirePermission: requirePermission
});
registerAdminScreenCommandRoutes(app, {
@@ -180,7 +187,19 @@ async function start() {
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
getScreenConnections: playerActionService.getScreenConnections,
isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation
withClientNameReservation: withClientNameReservation,
requirePermission: requirePermission
});
const registerAdminRbacRoutes = require('./web/routes/admin-rbac');
registerAdminRbacRoutes(app, {
pool: pool,
pages: pages,
getAuditUserId: getAuditUserId,
rbacData: rbacData,
permissions: require('./rbac').PERMISSIONS,
normalizePermissionKeys: require('./rbac').normalizePermissionKeys,
requirePermission: requirePermission
});
registerAdminContentRoutes(app, {
@@ -201,13 +220,39 @@ async function start() {
broadcastDashboardState: broadcastDashboardState,
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
requirePermission: requirePermission
});
app.use(function (error, _req, res, _next) {
app.use(function (error, req, res, _next) {
console.error(error);
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
const statusCode = Number(error && (error.statusCode || error.status)) || 500;
const wantsHtml = !String(req.originalUrl || '').startsWith('/api/') && (!req.accepts || req.accepts('html'));
if (wantsHtml && pages.renderErrorPage) {
const isPermissionError = statusCode === 403;
const message = isPermissionError
? String(error && error.message ? error.message : 'You do not have permission to access this area.')
: String(error && error.message ? error.message : 'An unexpected error occurred.');
const title = isPermissionError
? 'Access denied'
: statusCode === 404
? 'Not found'
: 'Something went wrong';
return res.status(statusCode).send(pages.renderErrorPage({
statusCode: statusCode,
title: title,
errorTitle: title,
message: message,
detail: statusCode >= 500 ? 'The server could not complete the request.' : '',
backUrl: req.currentUser ? '/admin' : '/login',
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
}, req.currentUser));
}
res.status(statusCode).send(statusCode >= 500 ? 'Internal server error' : String(error && error.message ? error.message : 'Error'));
});
// Ensure schema and mirror uploads before the web service starts handling traffic.
-1
View File
@@ -195,7 +195,6 @@
margin: 0.75rem 0 0;
font-size: clamp(1.4rem, 1.1rem + 1vw, 2.2rem);
line-height: 1.08;
max-width: 28rem;
}
.dashboard-hero-copy {
+11 -6
View File
@@ -206,7 +206,7 @@
}
}
function renderClientRow(client) {
function renderClientRow(client, hasActionsColumn) {
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
var clientIpValue = normalizeDisplayIp(client.clientIp);
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
@@ -215,6 +215,7 @@
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
return [
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
@@ -224,7 +225,7 @@
'<td data-label="IP">' + clientIp + '</td>',
'<td data-label="Viewport">' + viewport + '</td>',
'<td data-label="Connected/Updated">' + connectedAt + '</td>',
'<td data-label="Actions">' + renderClientActionCell(client) + '</td>',
actionCell,
'</tr>'
].join('');
}
@@ -266,8 +267,10 @@
if (!tbody || !Array.isArray(state.clients)) {
return;
}
var table = document.getElementById('dashboard-clients-table');
var hasActionsColumn = Boolean(table && String(table.getAttribute('data-has-actions-column') || '').toLowerCase() === 'true');
if (!state.clients.length) {
tbody.innerHTML = '<tr><td colspan="7" class="empty">No connected clients yet.</td></tr>';
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
return;
}
var existingRows = {};
@@ -286,7 +289,7 @@
var row = existingRows[rowKey];
if (!row) {
var tempBody = document.createElement('tbody');
tempBody.innerHTML = renderClientRow(client);
tempBody.innerHTML = renderClientRow(client, hasActionsColumn);
row = tempBody.firstElementChild;
}
@@ -298,7 +301,7 @@
row.setAttribute('data-client-id', client.clientId || '');
row.setAttribute('data-client-device-id', client.deviceId || '');
row.setAttribute('data-client-screen-slug', client.screen_slug || '');
if (row.cells && row.cells.length >= 7) {
if (row.cells && row.cells.length >= 6) {
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
var clientIpValue = normalizeDisplayIp(client.clientIp);
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
@@ -314,7 +317,9 @@
row.cells[3].innerHTML = clientIp;
row.cells[4].innerHTML = viewport;
row.cells[5].innerHTML = connectedAt;
updateClientActionCell(row.cells[6], client);
if (hasActionsColumn && row.cells.length >= 7) {
updateClientActionCell(row.cells[6], client);
}
}
var referenceNode = tbody.children[index] || null;
+111
View File
@@ -0,0 +1,111 @@
(function () {
function getGroupCheckboxes(group) {
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
}
function getPermissionKey(checkbox) {
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
}
function getActionKey(checkbox) {
var permissionKey = getPermissionKey(checkbox);
var parts = permissionKey.split('.');
if (parts.length !== 2) {
return '';
}
return String(parts[1] || '').trim().toLowerCase();
}
function syncPermissionGroup(group) {
var checkboxes = getGroupCheckboxes(group);
if (!checkboxes.length) {
return;
}
var readCheckbox = null;
var nonReadChecked = false;
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) === 'read') {
readCheckbox = checkbox;
return;
}
if (checkbox.checked) {
nonReadChecked = true;
}
});
if (!readCheckbox) {
return;
}
if (!readCheckbox.checked && nonReadChecked) {
readCheckbox.checked = true;
}
if (!readCheckbox.checked) {
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) !== 'read') {
checkbox.checked = false;
}
});
}
}
function handleGroupChange(event) {
var checkbox = event.target && event.target.matches ? event.target : null;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
var actionKey = getActionKey(checkbox);
var group = checkbox.closest('.accordion-item');
if (!group) {
return;
}
var checkboxes = getGroupCheckboxes(group);
var readCheckbox = checkboxes.find(function (candidate) {
return getActionKey(candidate) === 'read';
}) || null;
if (!readCheckbox) {
return;
}
if (actionKey === 'read' && !checkbox.checked) {
checkboxes.forEach(function (candidate) {
if (candidate !== checkbox) {
candidate.checked = false;
}
});
return;
}
if (actionKey !== 'read' && checkbox.checked) {
readCheckbox.checked = true;
}
}
function initPermissionGroups() {
document.querySelectorAll('.accordion-item').forEach(function (group) {
syncPermissionGroup(group);
});
document.addEventListener('change', function (event) {
var checkbox = event.target;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
return;
}
handleGroupChange(event);
syncPermissionGroup(checkbox.closest('.accordion-item'));
});
}
initPermissionGroups();
}());
+9 -4
View File
@@ -40,7 +40,7 @@
function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim();
if (/^(unable to delete|cannot delete|can't delete)|\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
return 'danger';
}
return String(fallbackVariant || 'success').trim().toLowerCase() || 'success';
@@ -63,7 +63,9 @@
if (existingBody) {
existingBody.textContent = text;
}
setToastVariant(existingToast, getMessageVariant(text, variant));
var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant);
setToastVariant(existingToast, nextVariant);
var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) {
existingInstance.show();
@@ -80,7 +82,9 @@
toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
setToastVariant(toast, getMessageVariant(text, variant));
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
setToastVariant(toast, toastVariant);
toast.querySelector('.toast-body').textContent = text;
toast.addEventListener('hidden.bs.toast', function () {
@@ -110,7 +114,8 @@
// ignore URL cleanup failures
}
setToastVariant(toast, getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success'));
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success');
setToastVariant(toast, existingVariant);
var instance = getBootstrapToast(toast);
if (instance) {
+196
View File
@@ -0,0 +1,196 @@
const { PERMISSIONS, normalizePermissionKeys } = require('../rbac');
function parseCsvIds(value) {
return String(value || '')
.split(',')
.map(function (item) {
return Number(item);
})
.filter(function (item) {
return Number.isInteger(item) && item > 0;
});
}
async function fetchPermissions(pool) {
const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM permissions ORDER BY section_name ASC, name ASC');
return rows || [];
}
async function fetchRoles(pool) {
const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM roles r
ORDER BY r.name ASC`
);
return rows || [];
}
async function fetchRoleById(pool, roleId) {
const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM roles r
WHERE r.id = ?
LIMIT 1`,
[roleId]
);
return rows[0] || null;
}
async function fetchRolePermissionKeys(pool, roleId) {
const [rows] = await pool.query(
`SELECT p.permission_key
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE rp.role_id = ?
ORDER BY p.section_name ASC, p.name ASC`,
[roleId]
);
return (rows || []).map(function (row) {
return String(row.permission_key || '').trim();
}).filter(Boolean);
}
async function fetchRoleUserIds(pool, roleId) {
const [rows] = await pool.query(
`SELECT ur.user_id
FROM user_roles ur
WHERE ur.role_id = ?
ORDER BY ur.user_id ASC`,
[roleId]
);
return (rows || []).map(function (row) {
return Number(row.user_id);
}).filter(function (userId) {
return Number.isInteger(userId) && userId > 0;
});
}
async function fetchRolesForUser(pool, userId) {
const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = ?
ORDER BY r.name ASC`,
[userId]
);
return rows || [];
}
async function fetchUsersWithRoles(pool) {
const [rows] = await pool.query(
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM users u
LEFT JOIN (
SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id
ORDER BY u.id ASC`
);
return (rows || []).map(function (row) {
return Object.assign({}, row, {
roleIds: parseCsvIds(row.role_ids_csv),
roleNames: String(row.role_names || '').trim()
});
});
}
async function fetchUserWithRoles(pool, userId) {
const [rows] = await pool.query(
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM users u
LEFT JOIN (
SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id
WHERE u.id = ?
LIMIT 1`,
[userId]
);
if (!rows.length) {
return null;
}
return Object.assign({}, rows[0], {
roleIds: parseCsvIds(rows[0].role_ids_csv),
roleNames: String(rows[0].role_names || '').trim()
});
}
async function syncUserRoles(pool, userId, roleIds) {
const uniqueRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
return Number(roleId);
}).filter(function (roleId) {
return Number.isInteger(roleId) && roleId > 0;
})));
await pool.query('DELETE FROM user_roles WHERE user_id = ?', [userId]);
for (const roleId of uniqueRoleIds) {
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
}
}
async function syncRoleUsers(pool, roleId, userIds) {
const uniqueUserIds = Array.from(new Set((Array.isArray(userIds) ? userIds : []).map(function (userId) {
return Number(userId);
}).filter(function (userId) {
return Number.isInteger(userId) && userId > 0;
})));
await pool.query('DELETE FROM user_roles WHERE role_id = ?', [roleId]);
for (const userId of uniqueUserIds) {
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
}
}
async function syncRolePermissions(pool, roleId, permissionKeys) {
const uniquePermissionKeys = normalizePermissionKeys(permissionKeys);
if (!uniquePermissionKeys.length) {
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
return;
}
const [permissionRows] = await pool.query('SELECT id, permission_key FROM permissions WHERE permission_key IN (?)', [uniquePermissionKeys]);
if (permissionRows.length !== uniquePermissionKeys.length) {
throw new Error('One or more selected permissions are invalid.');
}
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
for (const permissionRow of permissionRows) {
await pool.query('INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
}
}
module.exports = {
PERMISSIONS,
fetchPermissions,
fetchRoles,
fetchRoleById,
fetchRolePermissionKeys,
fetchRoleUserIds,
fetchRolesForUser,
fetchUsersWithRoles,
fetchUserWithRoles,
syncUserRoles,
syncRoleUsers,
syncRolePermissions
};
+19 -18
View File
@@ -16,6 +16,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const getSlideDeleteBlockMessage = deps.getSlideDeleteBlockMessage;
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
const requirePermission = deps.requirePermission;
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function') {
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
@@ -55,7 +56,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}, {});
}
app.get('/admin/slides', async function (req, res, next) {
app.get('/admin/slides', requirePermission('slides.read'), async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -64,7 +65,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/slides/new', async function (req, res, next) {
app.get('/admin/slides/new', requirePermission('slides.create'), async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -73,7 +74,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/slides/:id/edit', async function (req, res, next) {
app.get('/admin/slides/:id/edit', requirePermission('slides.edit'), async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
@@ -86,7 +87,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/slides', upload.any(), async function (req, res, next) {
app.post('/admin/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
try {
const payload = await common.buildSlidePayload(pool, req, null);
const actorId = getAuditUserId(req);
@@ -110,7 +111,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/slides/:id', upload.any(), async function (req, res, next) {
app.post('/admin/slides/:id', requirePermission('slides.edit'), upload.any(), async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
@@ -147,7 +148,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/slides/:id/delete', async function (req, res, next) {
app.post('/admin/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
@@ -177,7 +178,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/templates', async function (req, res, next) {
app.get('/admin/templates', requirePermission('templates.read'), async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -186,7 +187,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/templates/new', async function (req, res, next) {
app.get('/admin/templates/new', requirePermission('templates.create'), async function (req, res, next) {
try {
const data = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
@@ -195,7 +196,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/templates', upload.any(), async function (req, res, next) {
app.post('/admin/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
try {
const payload = await common.buildTemplatePayload(pool, req, null);
const actorId = getAuditUserId(req);
@@ -226,7 +227,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/templates/:id/edit', async function (req, res, next) {
app.get('/admin/templates/:id/edit', requirePermission('templates.edit'), async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
@@ -239,7 +240,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/templates/:id', upload.any(), async function (req, res, next) {
app.post('/admin/templates/:id', requirePermission('templates.edit'), upload.any(), async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
@@ -280,7 +281,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/templates/:id/delete', async function (req, res, next) {
app.post('/admin/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
@@ -308,7 +309,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/canvas-sizes', async function (req, res, next) {
app.get('/admin/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -317,11 +318,11 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/canvas-sizes/new', function (req, res) {
app.get('/admin/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/admin/canvas-sizes', async function (req, res, next) {
app.post('/admin/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
try {
const payload = common.buildCanvasSizePayload(req, null);
if (await canvasSizeExists(payload.width, payload.height)) {
@@ -339,7 +340,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/canvas-sizes/:id/edit', async function (req, res, next) {
app.get('/admin/canvas-sizes/:id/edit', requirePermission('canvas-sizes.edit'), async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
@@ -351,7 +352,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/canvas-sizes/:id', async function (req, res, next) {
app.post('/admin/canvas-sizes/:id', requirePermission('canvas-sizes.edit'), async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
@@ -372,7 +373,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/canvas-sizes/:id/delete', async function (req, res, next) {
app.post('/admin/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
+15 -14
View File
@@ -19,6 +19,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
const requirePermission = deps.requirePermission;
async function fetchAllScreenSlugs() {
const [rows] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
@@ -29,7 +30,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
.filter(Boolean);
}
app.post('/admin/commands', async function (req, res, next) {
app.post('/admin/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
try {
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
@@ -71,7 +72,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists', async function (req, res, next) {
app.post('/admin/playlists', requirePermission('playlists.create'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
@@ -90,7 +91,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists/:id', async function (req, res, next) {
app.post('/admin/playlists/:id', requirePermission('playlists.edit'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
@@ -255,7 +256,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists/:id/delete', async function (req, res, next) {
app.post('/admin/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -272,7 +273,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists/:id/slides', async function (req, res, next) {
app.post('/admin/playlists/:id/slides', requirePermission('playlists.edit'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -307,7 +308,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId', async function (req, res, next) {
app.post('/admin/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.edit'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -331,7 +332,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId/move', async function (req, res, next) {
app.post('/admin/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.edit'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
@@ -378,7 +379,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.get('/admin/playlists/:id/slides/:playlistSlideId/config', async function (req, res, next) {
app.get('/admin/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.edit'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -415,7 +416,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId/config', async function (req, res, next) {
app.post('/admin/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.edit'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -495,7 +496,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId/delete', async function (req, res, next) {
app.post('/admin/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.edit'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -510,7 +511,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.get('/admin/screens/new', async function (req, res, next) {
app.get('/admin/screens/new', requirePermission('screens.create'), async function (req, res, next) {
try {
const data = await common.fetchScreenEditData(pool);
res.send(pages.renderScreenFormPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -519,7 +520,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/screens', async function (req, res, next) {
app.post('/admin/screens', requirePermission('screens.create'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
if (!name) {
@@ -540,7 +541,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/screens/:id', async function (req, res, next) {
app.post('/admin/screens/:id', requirePermission('screens.edit'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
if (!name) {
@@ -575,7 +576,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/screens/:id/delete', async function (req, res, next) {
app.post('/admin/screens/:id/delete', requirePermission('screens.delete'), async function (req, res, next) {
try {
const screen = await common.fetchScreenById(pool, Number(req.params.id));
if (!screen) {
+15 -7
View File
@@ -3,8 +3,16 @@ module.exports = function registerAdminPagesRoutes(app, deps) {
const common = deps.common;
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const requirePermission = deps.requirePermission;
app.get('/admin', async function (req, res, next) {
function requireQueryPermission(readPermissionKey, editPermissionKey) {
return function (req, res, next) {
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
return requirePermission(permissionKey)(req, res, next);
};
}
app.get('/admin', requirePermission('dashboard.read'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
res.send(pages.renderDashboardPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -13,7 +21,7 @@ module.exports = function registerAdminPagesRoutes(app, deps) {
}
});
app.get('/admin/clients', async function (req, res, next) {
app.get('/admin/clients', requirePermission('clients.read'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -22,7 +30,7 @@ module.exports = function registerAdminPagesRoutes(app, deps) {
}
});
app.get('/admin/screens', async function (req, res, next) {
app.get('/admin/screens', requireQueryPermission('screens.read', 'screens.edit'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
if (req.query.edit) {
@@ -39,7 +47,7 @@ module.exports = function registerAdminPagesRoutes(app, deps) {
}
});
app.get('/admin/screens/:id/edit', async function (req, res, next) {
app.get('/admin/screens/:id/edit', requirePermission('screens.edit'), async function (req, res, next) {
try {
const screen = await common.fetchScreenById(pool, Number(req.params.id));
if (!screen) {
@@ -52,7 +60,7 @@ module.exports = function registerAdminPagesRoutes(app, deps) {
}
});
app.get('/admin/playlists', async function (req, res, next) {
app.get('/admin/playlists', requireQueryPermission('playlists.read', 'playlists.edit'), async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
if (req.query.edit) {
@@ -68,11 +76,11 @@ module.exports = function registerAdminPagesRoutes(app, deps) {
}
});
app.get('/admin/playlists/new', function (req, res) {
app.get('/admin/playlists/new', requirePermission('playlists.create'), function (req, res) {
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/admin/playlists/:id/edit', async function (req, res, next) {
app.get('/admin/playlists/:id/edit', requirePermission('playlists.edit'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
+365
View File
@@ -0,0 +1,365 @@
module.exports = function registerAdminRbacRoutes(app, deps) {
const pool = deps.pool;
const pages = deps.pages;
const getAuditUserId = deps.getAuditUserId;
const rbacData = deps.rbacData;
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
const normalizePermissionKeys = deps.normalizePermissionKeys;
const requirePermission = deps.requirePermission;
function slugifyRoleKey(name) {
const value = String(name || '').trim().toLowerCase();
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return slug || 'role';
}
function normalizeSelectedIds(values) {
return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) {
return Number(value);
}).filter(function (value) {
return Number.isInteger(value) && value > 0;
})));
}
function getActionLabel(actionKey) {
const normalizedActionKey = String(actionKey || '').trim().toLowerCase();
if (normalizedActionKey === 'edit') {
return 'Update';
}
if (normalizedActionKey === 'allow') {
return 'Allow';
}
if (!normalizedActionKey) {
return 'Read';
}
return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1);
}
async function createUniqueRoleKey(baseName) {
const baseKey = slugifyRoleKey(baseName);
let candidate = baseKey;
let suffix = 2;
while (true) {
const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]);
if (!rows.length) {
return candidate;
}
candidate = `${baseKey}-${suffix}`;
suffix += 1;
}
}
function mapPermissionsForView(permissionRows, selectedPermissionKeys) {
const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys));
const permissionDefinitions = new Map(permissions.map(function (permission) {
return [String(permission.key || '').trim(), permission];
}));
return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) {
const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null;
return Object.assign({}, permission, {
resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
resourceName: definition ? definition.name : permission.section_name,
categoryName: definition ? definition.sectionName : permission.section_name,
sectionOrder: definition ? definition.sectionOrder : 999,
actionKey: definition ? definition.actionKey : 'read',
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
isSelected: selectedKeys.has(String(permission.permission_key || '').trim())
});
});
}
function mapUsersForView(userRows, selectedUserIds) {
const selectedIds = new Set(normalizeSelectedIds(selectedUserIds));
return (Array.isArray(userRows) ? userRows : []).map(function (user) {
return Object.assign({}, user, {
isSelected: selectedIds.has(Number(user.id))
});
});
}
function buildPermissionGroups(permissionRows) {
const groups = [];
const groupIndex = new Map();
(Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) {
const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
if (!groupIndex.has(sectionKey)) {
const group = {
id: sectionKey || 'permissions',
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
categoryName: String(permission.categoryName || '').trim(),
sectionOrder: Number(permission.sectionOrder) || 999,
permissions: []
};
groupIndex.set(sectionKey, group);
groups.push(group);
}
groupIndex.get(sectionKey).permissions.push(permission);
});
groups.forEach(function (group) {
group.permissions.sort(function (left, right) {
const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 };
const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99;
const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return String(left.name || '').localeCompare(String(right.name || ''));
});
});
groups.sort(function (left, right) {
const leftOrder = Number(left.sectionOrder) || 999;
const rightOrder = Number(right.sectionOrder) || 999;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return String(left.title || '').localeCompare(String(right.title || ''));
});
return groups;
}
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
const permissionRows = await rbacData.fetchPermissions(pool);
return {
formValues: {
name: String(formValues && formValues.name || '').trim(),
description: String(formValues && formValues.description || '').trim()
},
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
};
}
async function loadRoleViewModel(roleId) {
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return null;
}
const permissionRows = await rbacData.fetchPermissions(pool);
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
const users = await rbacData.fetchUsersWithRoles(pool);
return {
role: Object.assign({}, role, {
permissionKeys: selectedPermissionKeys,
permissionCount: Number(role.permission_count) || 0,
userCount: Number(role.user_count) || 0
}),
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
users: mapUsersForView(users, selectedUserIds)
};
}
app.get('/admin/rbac', requirePermission('rbac.read'), async function (req, res, next) {
try {
const roles = await rbacData.fetchRoles(pool);
res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
buildRoleCreateViewModel({
name: String(req.query.name || '').trim(),
description: String(req.query.description || '').trim()
}, []).then(function (viewModel) {
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
}).catch(function (error) {
next(error);
});
});
app.post('/admin/rbac', requirePermission('rbac.create'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const description = String(req.body.description || '').trim();
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
? req.body['permission_keys[]']
: req.body.permission_keys
? [].concat(req.body.permission_keys)
: []);
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
if (!name) {
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
}
if (selectedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
}
const roleKey = await createUniqueRoleKey(name);
const actorId = getAuditUserId(req);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[roleKey, name, description || null, actorId, actorId]
);
if (selectedPermissionKeys.length) {
await rbacData.syncRolePermissions(connection, Number(result.insertId), selectedPermissionKeys);
}
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/admin/rbac?message=' + encodeURIComponent('Role created.'));
} catch (error) {
next(error);
}
});
app.get('/admin/rbac/:id/edit', requirePermission('rbac.edit'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const viewModel = await loadRoleViewModel(roleId);
if (!viewModel) {
return res.status(404).send('Role not found.');
}
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, viewModel.users));
} catch (error) {
next(error);
}
});
app.post('/admin/rbac/:id', requirePermission('rbac.edit'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
const name = String(req.body.name || '').trim();
const description = String(req.body.description || '').trim();
if (!name) {
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
}
await pool.query(
'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
[name, description || null, getAuditUserId(req), roleId]
);
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/rbac/:id/permissions', requirePermission('rbac.edit'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]'])
? req.body['permission_keys[]']
: req.body.permission_keys
? [].concat(req.body.permission_keys)
: [];
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys);
if (normalizedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/rbac/:id/users', requirePermission('rbac.edit'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
const selectedUserIds = Array.isArray(req.body['user_ids[]'])
? req.body['user_ids[]']
: req.body.user_ids
? [].concat(req.body.user_ids)
: [];
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
const validUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id);
}));
if (normalizedUserIds.some(function (userId) {
return !validUserIds.has(userId);
})) {
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
}
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
if (String(role.role_key || '') === 'administrators') {
return res.redirect('/admin/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
}
if (Number(role.user_count) > 0) {
return res.redirect('/admin/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
}
await pool.query('DELETE FROM roles WHERE id = ?', [roleId]);
res.redirect('/admin/rbac?message=' + encodeURIComponent('Role deleted.'));
} catch (error) {
next(error);
}
});
};
+2 -1
View File
@@ -4,8 +4,9 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable;
const withClientNameReservation = deps.withClientNameReservation;
const requirePermission = deps.requirePermission;
app.post('/admin/screens/:slug/commands', async function (req, res, next) {
app.post('/admin/screens/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
try {
const slug = String(req.params.slug || '').trim();
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
+127 -23
View File
@@ -4,36 +4,85 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
const formatDashboardDate = deps.formatDashboardDate;
const getAuditUserId = deps.getAuditUserId;
const hashPassword = deps.hashPassword;
const readArrayField = deps.readArrayField;
const rbacData = deps.rbacData;
const requirePermission = deps.requirePermission;
app.get('/admin/users', async function (req, res, next) {
async function fetchRoleOptions() {
return rbacData.fetchRoles(pool);
}
function mapRolesForForm(roles, selectedRoleIds) {
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
return Number(roleId);
}).filter(function (roleId) {
return Number.isInteger(roleId) && roleId > 0;
}));
return (Array.isArray(roles) ? roles : []).map(function (role) {
return Object.assign({}, role, {
isSelected: selectedIds.has(Number(role.id))
});
});
}
async function validateRoleIds(roleIds) {
const availableRoles = await fetchRoleOptions();
const validRoleIds = new Set(availableRoles.map(function (role) {
return Number(role.id);
}));
const normalizedRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
return Number(roleId);
}).filter(function (roleId) {
return Number.isInteger(roleId) && roleId > 0;
})));
if (!normalizedRoleIds.length) {
return { ok: false, message: 'Select at least one role.' };
}
if (normalizedRoleIds.some(function (roleId) {
return !validRoleIds.has(roleId);
})) {
return { ok: false, message: 'One or more selected roles are invalid.' };
}
return { ok: true, roleIds: normalizedRoleIds };
}
app.get('/admin/users', requirePermission('users.read'), async function (req, res, next) {
try {
const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users ORDER BY id ASC');
const users = rows.map(function (user) {
const users = await rbacData.fetchUsersWithRoles(pool);
const mappedUsers = users.map(function (user) {
return Object.assign({}, user, {
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
createdAtLabel: formatDashboardDate(user.created_at),
modifiedAtLabel: formatDashboardDate(user.modified_at)
modifiedAtLabel: formatDashboardDate(user.modified_at),
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
});
});
res.send(pages.renderUsersPage({ users: users }, req.query.message ? String(req.query.message) : '', req.currentUser));
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/users/new', function (req, res) {
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser));
app.get('/admin/users/new', requirePermission('users.create'), function (req, res) {
fetchRoleOptions().then(function (roles) {
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
}).catch(function (error) {
res.status(500).send(error.message || 'Unable to load roles.');
});
});
app.get('/admin/users/:id/edit', async function (req, res, next) {
app.get('/admin/users/:id/edit', requirePermission('users.edit'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users WHERE id = ? LIMIT 1', [userId]);
const user = rows[0] || null;
const user = await rbacData.fetchUserWithRoles(pool, userId);
if (!user) {
return res.status(404).send('User not found.');
}
@@ -41,54 +90,109 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const roles = await fetchRoleOptions();
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
isCurrentUser: false,
createdAtLabel: formatDashboardDate(user.created_at),
modifiedAtLabel: formatDashboardDate(user.modified_at)
}), req.query.message ? String(req.query.message) : '', req.currentUser));
modifiedAtLabel: formatDashboardDate(user.modified_at),
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
} catch (error) {
next(error);
}
});
app.post('/admin/users', async function (req, res, next) {
app.post('/admin/users', requirePermission('users.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const roleCheck = await validateRoleIds(selectedRoleIds);
const formValues = {
username: username,
name: name
};
async function renderValidationError(message) {
const roles = await fetchRoleOptions();
return res.status(400).send(pages.renderUsersAddPage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
}
if (!name) {
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Name is required.'));
return renderValidationError('Name is required.');
}
if (!username) {
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Username is required.'));
return renderValidationError('Username is required.');
}
if (!password || password.length < 8) {
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Password must be at least 8 characters.'));
return renderValidationError('Password must be at least 8 characters.');
}
if (password !== confirmPassword) {
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Passwords do not match.'));
return renderValidationError('Passwords do not match.');
}
if (!roleCheck.ok) {
return renderValidationError(roleCheck.message);
}
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
if (existingRows.length) {
return res.redirect('/admin/users/new?message=' + encodeURIComponent('That username already exists.'));
return renderValidationError('That username already exists.');
}
const passwordRecord = hashPassword(password);
const actorId = getAuditUserId(req);
await pool.query(
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
await connection.commit();
res.redirect('/admin/users?message=' + encodeURIComponent('User created.'));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/admin/users/:id/roles', requirePermission('users.edit'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
if (!rows.length) {
return res.status(404).send('User not found.');
}
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const roleCheck = await validateRoleIds(selectedRoleIds);
if (!roleCheck.ok) {
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
}
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/users/:id/username', async function (req, res, next) {
app.post('/admin/users/:id/username', requirePermission('users.edit'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
const name = String(req.body.name || '').trim();
@@ -122,7 +226,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
}
});
app.post('/admin/users/:id/password', async function (req, res, next) {
app.post('/admin/users/:id/password', requirePermission('users.edit'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
const password = String(req.body.password || '');
@@ -158,7 +262,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
}
});
app.post('/admin/users/:id/delete', async function (req, res, next) {
app.post('/admin/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
+62
View File
@@ -0,0 +1,62 @@
const { renderView } = require('../view');
function getErrorCopy(statusCode, message) {
const normalizedStatusCode = Number(statusCode) || 500;
if (normalizedStatusCode === 403) {
return {
title: 'Access denied',
errorTitle: 'Access denied',
errorMessage: message || 'You do not have permission to access this area.',
backLabel: 'Back to dashboard'
};
}
if (normalizedStatusCode === 404) {
return {
title: 'Not found',
errorTitle: 'Oops! Page not found.',
errorMessage: message || 'We could not find the page you were looking for. Meanwhile, you may return to the dashboard or try searching for what you need.',
backLabel: 'Back to dashboard'
};
}
if (normalizedStatusCode >= 500) {
return {
title: 'Something went wrong',
errorTitle: 'Something went wrong.',
errorMessage: message || 'An unexpected error occurred. Please try again in a moment.',
backLabel: 'Back to dashboard'
};
}
return {
title: 'Error',
errorTitle: 'Error',
errorMessage: message || 'An unexpected error occurred.',
backLabel: 'Back to dashboard'
};
}
module.exports = function renderErrorPage(options, currentUser) {
const errorOptions = options || {};
const statusCode = Number(errorOptions.statusCode || 500);
const copy = getErrorCopy(statusCode, String(errorOptions.message || '').trim());
return renderView('error', {
title: String(errorOptions.title || copy.title || 'Error').trim(),
active: '',
messageVariant: 'primary',
currentUser: currentUser || null,
statusCode: statusCode,
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
errorMessage: String(errorOptions.message || copy.errorMessage || 'An unexpected error occurred.').trim(),
detail: String(errorOptions.detail || '').trim(),
backUrl: String(errorOptions.backUrl || '/admin').trim() || '/admin',
backLabel: String(errorOptions.backLabel || copy.backLabel || 'Back to dashboard').trim() || 'Back to dashboard',
searchUrl: String(errorOptions.searchUrl || '').trim(),
bodyClass: 'error-page bg-dark text-white',
errorShell: true,
stylesheets: [],
scripts: []
});
};
+5 -1
View File
@@ -20,5 +20,9 @@ module.exports = {
renderTemplateEditPage: require('./templates/edit'),
renderCanvasSizesPage: require('./canvas-sizes/list'),
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
renderCanvasSizeEditPage: require('./canvas-sizes/edit')
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
renderErrorPage: require('./error'),
renderRbacPage: require('./rbac/list'),
renderRbacAddPage: require('./rbac/add'),
renderRbacEditPage: require('./rbac/edit')
};
+14
View File
@@ -0,0 +1,14 @@
const { renderView } = require('../../view');
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, messageVariant) {
return renderView('rbac/add', {
title: 'Create role',
active: 'rbac',
message: message,
messageVariant: messageVariant || 'primary',
currentUser: currentUser || null,
formValues: formValues || {},
permissionGroups: permissionGroups || [],
scripts: ['js/rbac-permissions.js']
});
};
+14
View File
@@ -0,0 +1,14 @@
const { renderView } = require('../../view');
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users) {
return renderView('rbac/edit', {
title: 'Edit role',
active: 'rbac',
message: message,
currentUser: currentUser || null,
role: role,
permissionGroups: permissionGroups || [],
users: users || [],
scripts: ['js/rbac-permissions.js']
});
};
+11
View File
@@ -0,0 +1,11 @@
const { renderView } = require('../../view');
module.exports = function renderRbacPage(data, message, currentUser) {
return renderView('rbac/list', {
title: 'Roles and permissions',
active: 'rbac',
message: message,
currentUser: currentUser || null,
roles: data.roles || []
});
};
+5 -2
View File
@@ -1,10 +1,13 @@
const { renderView } = require('../../view');
module.exports = function renderUsersAddPage(message, currentUser) {
module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) {
return renderView('users/add', {
title: 'Add user',
active: 'users',
message: message,
currentUser: currentUser || null
messageVariant: messageVariant || 'primary',
currentUser: currentUser || null,
roles: roles || [],
formValues: formValues || {}
});
};
+3 -2
View File
@@ -1,11 +1,12 @@
const { renderView } = require('../../view');
module.exports = function renderUsersEditPage(user, message, currentUser) {
module.exports = function renderUsersEditPage(user, message, currentUser, roles) {
return renderView('users/edit', {
title: 'Edit user',
active: 'users',
message: message,
currentUser: currentUser || null,
user: user
user: user,
roles: roles || []
});
};
+29 -1
View File
@@ -1,3 +1,5 @@
const { normalizePermissionKeys } = require('../rbac');
function createSessionService(options) {
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
@@ -66,8 +68,34 @@ function createSessionService(options) {
return null;
}
const userId = Number(rows[0].id);
const [roleRows] = await pool.query(
`SELECT r.role_key
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = ?
ORDER BY r.name ASC`,
[userId]
);
const [permissionRows] = await pool.query(
`SELECT p.permission_key
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = ?
ORDER BY p.section_name ASC, p.name ASC`,
[userId]
);
await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
return rows[0];
return Object.assign({}, rows[0], {
roleKeys: roleRows.map(function (row) {
return String(row.role_key || '').trim();
}).filter(Boolean),
permissionKeys: normalizePermissionKeys(permissionRows.map(function (row) {
return String(row.permission_key || '').trim();
}))
});
}
async function createUserSession(pool, userId) {
+42 -7
View File
@@ -2,10 +2,20 @@ const fs = require('fs');
const path = require('path');
const Handlebars = require('handlebars');
const { version: appVersion } = require('../../package.json');
const { hasPermission, hasAnyPermission } = require('../rbac');
const VIEWS_ROOT = path.join(__dirname, 'views');
const cache = new Map();
function inferMessageVariant(message, fallbackVariant) {
const text = String(message || '').trim();
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
return 'danger';
}
return String(fallbackVariant || '').trim().toLowerCase() || 'primary';
}
Handlebars.registerHelper('eq', function (left, right) {
return left === right;
});
@@ -23,6 +33,16 @@ Handlebars.registerHelper('json', function (value) {
return new Handlebars.SafeString(JSON.stringify(value).replace(/</g, '\\u003c'));
});
Handlebars.registerHelper('hasPermission', function (currentUser, permissionKey) {
return hasPermission(currentUser, permissionKey);
});
Handlebars.registerHelper('anyPermission', function (currentUser) {
const args = Array.prototype.slice.call(arguments, 1);
args.pop();
return hasAnyPermission(currentUser, args);
});
Handlebars.registerHelper('usernameInitial', function (username) {
const value = String(username || '').trim();
if (!value) {
@@ -39,6 +59,16 @@ Handlebars.registerHelper('userInitial', function (name, username) {
return value.charAt(0).toUpperCase();
});
Handlebars.registerHelper('truncateText', function (value, maxLength) {
const text = String(value || '').trim();
const limit = Number(maxLength);
if (!text || !Number.isFinite(limit) || limit <= 0 || text.length <= limit) {
return text;
}
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}`;
});
Handlebars.registerHelper('saveActionButtons', function (options) {
const hash = options && options.hash ? options.hash : {};
const formId = String(hash.formId || '').trim();
@@ -53,6 +83,7 @@ Handlebars.registerHelper('saveActionButtons', function (options) {
const showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false';
const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).toLowerCase() !== 'false';
const ariaLabel = String(hash.ariaLabel || 'Save actions');
const hasSecondaryActions = showSaveAndClose || showSaveAndNew;
if (!formId) {
return '';
@@ -64,13 +95,11 @@ Handlebars.registerHelper('saveActionButtons', function (options) {
return new Handlebars.SafeString([
'<div class="btn-group save-action-group" role="group">',
`<button type="submit" class="${escape(saveButtonClass)}"${formAttr} name="${escape(saveActionName)}" value="${escape(saveActionValue)}">${escape(saveLabel)}</button>`,
`<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">`,
'<span class="visually-hidden">Toggle save options</span>',
'</button>',
'<div class="dropdown-menu dropdown-menu-end">',
showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(closeValue)}">${escape(closeLabel)}</button>` : '',
showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(newValue)}">${escape(newLabel)}</button>` : '',
'</div>',
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></button>` : '',
hasSecondaryActions ? '<div class="dropdown-menu dropdown-menu-end">' : '',
hasSecondaryActions && showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(closeValue)}">${escape(closeLabel)}</button>` : '',
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(newValue)}">${escape(newLabel)}</button>` : '',
hasSecondaryActions ? '</div>' : '',
'</div>',
].join(''));
});
@@ -89,6 +118,9 @@ function loadTemplate(relativePath) {
function renderView(viewName, context) {
const viewContext = Object.assign({ stylesheets: [], scripts: [], appVersion: appVersion }, context || {});
if (!viewContext.messageVariant) {
viewContext.messageVariant = inferMessageVariant(viewContext.message, 'primary');
}
const page = loadTemplate(`${viewName}.hbs`);
const layout = loadTemplate(path.join('layout.hbs'));
const body = page(viewContext);
@@ -97,6 +129,9 @@ function renderView(viewName, context) {
function renderFragment(viewName, context) {
const viewContext = Object.assign({ stylesheets: [], scripts: [], appVersion: appVersion }, context || {});
if (!viewContext.messageVariant) {
viewContext.messageVariant = inferMessageVariant(viewContext.message, 'primary');
}
const page = loadTemplate(`${viewName}.hbs`);
const layout = loadTemplate(path.join('frame-layout.hbs'));
const body = page(viewContext);
+87 -75
View File
@@ -10,82 +10,94 @@
<h3 class="card-title">Active connections</h3>
</div>
<div class="card-body table-responsive p-0">
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0">
<thead><tr><th>Client</th><th>Current Screen</th><th>Current Slide</th><th>IP</th><th>Viewport</th><th>Connected/Updated</th><th>Actions</th></tr></thead>
<tbody id="dashboard-clients-table-body">
{{#if clients.length}}
{{#each clients}}
<tr data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}">
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
</td>
<td data-label="Current Screen">
<div>{{#if screen_name}}{{screen_name}}{{else}}Unknown{{/if}}</div>
</td>
<td data-label="Current Slide">
{{#if currentSlideTitle}}
<div>{{currentSlideTitle}}</div>
{{else}}
<span class="empty">No slide currently showing</span>
{{/if}}
</td>
<td data-label="IP">{{#if clientIp}}{{clientIp}}{{else}}<span class="empty">Unknown</span>{{/if}}</td>
<td data-label="Viewport">
{{#if viewport}}
{{viewport.width}}x{{viewport.height}}
{{else}}
<span class="empty">Unknown</span>
{{/if}}
</td>
<td data-label="Connected/Updated">
{{#if connectedAt}}
<div>{{connectedAtLabel}}</div>
{{#if lastSeenAt}}
<div class="subtle"><i>{{lastSeenAtLabel}}</i></div>
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}">
<thead>
<tr>
<th>Client</th>
<th>Current Screen</th>
<th>Current Slide</th>
<th>IP</th>
<th>Viewport</th>
<th>Connected/Updated</th>
{{#if (hasPermission currentUser 'clients.allow')}}<th>Actions</th>{{/if}}
</tr>
</thead>
<tbody id="dashboard-clients-table-body">
{{#if clients.length}}
{{#each clients}}
<tr data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}">
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
</td>
<td data-label="Current Screen">
<div>{{#if screen_name}}{{screen_name}}{{else}}Unknown{{/if}}</div>
</td>
<td data-label="Current Slide">
{{#if currentSlideTitle}}
<div>{{currentSlideTitle}}</div>
{{else}}
<span class="empty">No slide currently showing</span>
{{/if}}
{{else}}
<span class="empty">Unknown</span>
</td>
<td data-label="IP">{{#if clientIp}}{{clientIp}}{{else}}<span class="empty">Unknown</span>{{/if}}</td>
<td data-label="Viewport">
{{#if viewport}}
{{viewport.width}}x{{viewport.height}}
{{else}}
<span class="empty">Unknown</span>
{{/if}}
</td>
<td data-label="Connected/Updated">
{{#if connectedAt}}
<div>{{connectedAtLabel}}</div>
{{#if lastSeenAt}}
<div class="subtle"><i>{{lastSeenAtLabel}}</i></div>
{{/if}}
{{else}}
<span class="empty">Unknown</span>
{{/if}}
</td>
{{#if (hasPermission currentUser 'clients.allow')}}
<td data-label="Actions" class="text-end">
<div class="actions justify-content-end">
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-confirm-message="Reloading will restart the player page. Continue?" data-async-command>
<input type="hidden" name="command" value="reload" />
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm btn-danger">Reload</button>
</form>
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="previous" />
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm btn-warning" aria-label="Previous slide">◀</button>
</form>
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="next" />
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm btn-warning" aria-label="Next slide">▶</button>
</form>
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="pause" />
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm btn-info">
<i class="bi bi-pause-fill me-1" aria-hidden="false"></i>Pause
</button>
</form>
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="blackout" />
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm {{#if blackout}}btn-success{{else}}btn-secondary{{/if}}">
<i class="bi bi-eye-slash me-1" aria-hidden="false"></i>Blackout
</button>
</form>
</div>
</td>
{{/if}}
</td>
<td data-label="Actions" class="text-end">
<div class="actions justify-content-end">
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-confirm-message="Reloading will restart the player page. Continue?" data-async-command>
<input type="hidden" name="command" value="reload" />
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm btn-danger">Reload</button>
</form>
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="previous" />
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm btn-warning" aria-label="Previous slide">◀</button>
</form>
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="next" />
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm btn-warning" aria-label="Next slide">▶</button>
</form>
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="pause" />
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm btn-info">
<i class="bi bi-pause-fill me-1" aria-hidden="false"></i>Pause
</button>
</form>
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="blackout" />
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm {{#if blackout}}btn-success{{else}}btn-secondary{{/if}}">
<i class="bi bi-eye-slash me-1" aria-hidden="false"></i>Blackout
</button>
</form>
</div>
</td>
</tr>
{{/each}}
{{else}}
<tr><td colspan="7" class="empty">No connected clients yet.</td></tr>
{{/if}}
</tbody>
</table>
</tr>
{{/each}}
{{else}}
<tr><td colspan="{{#if (hasPermission currentUser 'clients.allow')}}7{{else}}6{{/if}}" class="empty">No connected clients yet.</td></tr>
{{/if}}
</tbody>
</table>
</div>
</div>
+101 -79
View File
@@ -13,104 +13,126 @@
<p class="dashboard-hero-copy">Use the cards below for the current totals, then jump to the screens table when you need a deeper look at playback and connection state.</p>
</div>
<div class="dashboard-hero-stats">
{{#if (hasPermission currentUser "playlists.read")}}
<div class="dashboard-hero-stat">
<span class="dashboard-hero-stat-value">{{playlists.length}}</span>
<span class="dashboard-hero-stat-label">playlists</span>
</div>
<div class="dashboard-hero-stat">
<span class="dashboard-hero-stat-value">{{screens.length}}</span>
<span class="dashboard-hero-stat-label">screens</span>
</div>
{{/if}}
{{#if (hasPermission currentUser "screens.read")}}
<div class="dashboard-hero-stat">
<span class="dashboard-hero-stat-value">{{screens.length}}</span>
<span class="dashboard-hero-stat-label">screens</span>
</div>
{{/if}}
{{#if (hasPermission currentUser "clients.read")}}
<div class="dashboard-hero-stat">
<span class="dashboard-hero-stat-value">{{connectedClientsCount}}</span>
<span class="dashboard-hero-stat-label">clients</span>
</div>
{{/if}}
</div>
</div>
</section>
<div class="row">
<div class="col-12">
<div class="card mb-4 card-outline card-primary dashboard-actions-card">
<div class="card-header">
<div class="dashboard-card-heading">
<h3 class="card-title">Quick actions</h3>
<p class="dashboard-card-subtitle">Send global commands without leaving the overview.</p>
{{#if (hasPermission currentUser "dashboard.allow")}}
<div class="row">
<div class="col-12">
<div class="card mb-4 card-outline card-primary dashboard-actions-card">
<div class="card-header">
<div class="dashboard-card-heading">
<h3 class="card-title">Quick actions</h3>
<p class="dashboard-card-subtitle">Send global commands without
leaving the overview.</p>
</div>
</div>
</div>
<div class="card-body">
<div class="dashboard-action-grid">
<form
method="post"
action="/admin/commands"
class="dashboard-action-form"
data-confirm-message="Reload all connected clients?"
data-async-command
>
<input type="hidden" name="command" value="reload" />
<button
type="submit"
class="btn btn-danger dashboard-action-button"
id="dashboard-reload-all-button"
><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload all clients</button>
<span class="dashboard-action-help">Restart every connected player page.</span>
</form>
<form
method="post"
action="/admin/commands"
class="dashboard-action-form"
data-confirm-message="Blackout all connected clients?"
data-async-command
>
<input type="hidden" name="command" value="blackout" />
<input type="hidden" name="blackout" value="true" />
<button
type="submit"
class="btn btn-secondary dashboard-action-button"
id="dashboard-blackout-all-button"
><i class="bi bi-eye-slash me-1" aria-hidden="false"></i>Blackout all clients</button>
<span class="dashboard-action-help">Blank active players immediately.</span>
</form>
<div class="card-body">
<div class="dashboard-action-grid">
<form
method="post"
action="/admin/commands"
class="dashboard-action-form"
data-confirm-message="Reload all connected clients?"
data-async-command
>
<input type="hidden" name="command" value="reload" />
<button
type="submit"
class="btn btn-danger dashboard-action-button"
id="dashboard-reload-all-button"
><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload all clients</button>
<span class="dashboard-action-help">Restart every connected player page.</span>
</form>
<form
method="post"
action="/admin/commands"
class="dashboard-action-form"
data-confirm-message="Blackout all connected clients?"
data-async-command
>
<input type="hidden" name="command" value="blackout" />
<input type="hidden" name="blackout" value="true" />
<button
type="submit"
class="btn btn-secondary dashboard-action-button"
id="dashboard-blackout-all-button"
><i class="bi bi-eye-slash me-1" aria-hidden="false"></i>Blackout
all clients</button>
<span class="dashboard-action-help">Blank active players
immediately.</span>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{{/if}}
<div class="row">
<div class="col-12">
<div class="card card-outline card-secondary dashboard-table-card">
<div class="card-header">
<div class="dashboard-card-heading">
<h3 class="card-title">Current screens</h3>
<p class="dashboard-card-subtitle">Player URL, playlist assignment, and live connection count.</p>
{{#if (hasPermission currentUser "screens.read")}}
<div class="row">
<div class="col-12">
<div class="card card-outline card-secondary dashboard-table-card">
<div class="card-header">
<div class="dashboard-card-heading">
<h3 class="card-title">Current screens</h3>
<p class="dashboard-card-subtitle">Player URL, playlist assignment,
and live connection count.</p>
</div>
</div>
<div class="card-body table-responsive p-0">
<table
id="dashboard-screens-table"
class="table table-striped w-100 mb-0"
>
<thead><tr><th>Name</th><th>Player URL</th><th>Playlist</th><th
>Connected clients</th></tr></thead>
<tbody>
{{#if screens.length}}
{{#each screens}}
<tr data-client-key="{{id}}">
<td data-label="Name">{{name}}</td>
<td data-label="Player URL"><a
href="{{playerUrl slug}}"
target="_blank"
>{{playerUrl slug}}</a></td>
<td data-label="Playlist">{{playlist_name}}</td>
<td data-label="Connected clients">
{{#if player_connection_count}}
<div class="connection-count">{{player_connection_count}}
connected</div>
{{else}}
<span class="empty">No clients connected.</span>
{{/if}}
</td>
</tr>
{{/each}}
{{else}}
<tr><td colspan="4" class="empty">No screens yet.</td></tr>
{{/if}}
</tbody>
</table>
</div>
</div>
<div class="card-body table-responsive p-0">
<table id="dashboard-screens-table" class="table table-striped w-100 mb-0">
<thead><tr><th>Name</th><th>Player URL</th><th>Playlist</th><th>Connected clients</th></tr></thead>
<tbody>
{{#if screens.length}}
{{#each screens}}
<tr data-client-key="{{id}}">
<td data-label="Name">{{name}}</td>
<td data-label="Player URL"><a href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a></td>
<td data-label="Playlist">{{playlist_name}}</td>
<td data-label="Connected clients">
{{#if player_connection_count}}
<div class="connection-count">{{player_connection_count}} connected</div>
{{else}}
<span class="empty">No clients connected.</span>
{{/if}}
</td>
</tr>
{{/each}}
{{else}}
<tr><td colspan="4" class="empty">No screens yet.</td></tr>
{{/if}}
</tbody>
</table>
</div>
</div>
</div>
</div>
{{/if}}
+15
View File
@@ -0,0 +1,15 @@
<div class="container">
<div class="row justify-content-center">
<div class="text-center">
<div class="display-1 fw-bold text-primary lh-1 mb-3">{{statusCode}}</div>
<h1 class="h3 mb-3">{{errorTitle}}</h1>
<p class="text-secondary mb-4">
{{errorMessage}}
</p>
<a href="{{backUrl}}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1" aria-hidden="true"></i>
{{backLabel}}
</a>
</div>
</div>
</div>
+83 -51
View File
@@ -18,7 +18,13 @@
{{/if}}
</head>
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}">
{{#if authShell}}
{{#if errorShell}}
<main class="d-flex align-items-center justify-content-center min-vh-100 p-3 {{bodyClass}}">
{{{body}}}
</main>
<script src="/assets/js/admin-page.js"></script>
<script src="/assets/js/system-status.js"></script>
{{else if authShell}}
<main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
<div class="login-box">
{{{body}}}
@@ -29,7 +35,7 @@
{{else}}
<div id="app-toast-container" class="toast-container position-fixed top-0 end-0 p-3">
{{#if message}}
<div class="toast align-items-center text-bg-primary border-0" id="app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000">
<div class="toast align-items-center text-bg-{{#if messageVariant}}{{messageVariant}}{{else}}primary{{/if}} border-0" id="app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000" data-toast-variant="{{#if messageVariant}}{{messageVariant}}{{else}}primary{{/if}}">
<div class="d-flex">
<div class="toast-body">{{message}}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>
@@ -138,55 +144,81 @@
<nav class="mt-2 flex-grow-1" aria-label="Main navigation">
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false" role="menu" id="navigation">
<li class="nav-header">MAIN NAVIGATION</li>
<li class="nav-item">
<a class="nav-link {{#if (eq active 'dashboard')}}active{{/if}}" href="/admin">
<i class="nav-icon bi bi-speedometer2"></i>
<p>Dashboard</p>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{#if (eq active 'clients')}}active{{/if}}" href="/admin/clients">
<i class="nav-icon bi bi-broadcast"></i>
<p>Connected clients</p>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{#if (eq active 'screens')}}active{{/if}}" href="/admin/screens">
<i class="nav-icon bi bi-display"></i>
<p>Screens</p>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{#if (eq active 'playlists')}}active{{/if}}" href="/admin/playlists">
<i class="nav-icon bi bi-collection-play"></i>
<p>Playlists</p>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{#if (eq active 'slides')}}active{{/if}}" href="/admin/slides">
<i class="nav-icon bi bi-file-earmark-richtext"></i>
<p>Slides</p>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{#if (eq active 'templates')}}active{{/if}}" href="/admin/templates">
<i class="nav-icon bi bi-layout-text-window-reverse"></i>
<p>Slide templates</p>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{#if (eq active 'canvas-sizes')}}active{{/if}}" href="/admin/canvas-sizes">
<i class="nav-icon bi bi-aspect-ratio"></i>
<p>Canvas sizes</p>
</a>
</li>
<li class="nav-header">SETTINGS</li>
<li class="nav-item">
<a class="nav-link {{#if (eq active 'users')}}active{{/if}}" href="/admin/users">
<i class="nav-icon bi bi-people"></i>
<p>Users</p>
</a>
</li>
{{#if (hasPermission currentUser 'dashboard.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'dashboard')}}active{{/if}}" href="/admin">
<i class="nav-icon bi bi-speedometer2"></i>
<p>Dashboard</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'clients.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'clients')}}active{{/if}}" href="/admin/clients">
<i class="nav-icon bi bi-broadcast"></i>
<p>Connected clients</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'screens.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'screens')}}active{{/if}}" href="/admin/screens">
<i class="nav-icon bi bi-display"></i>
<p>Screens</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'playlists.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'playlists')}}active{{/if}}" href="/admin/playlists">
<i class="nav-icon bi bi-collection-play"></i>
<p>Playlists</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'slides.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'slides')}}active{{/if}}" href="/admin/slides">
<i class="nav-icon bi bi-file-earmark-richtext"></i>
<p>Slides</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'templates.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'templates')}}active{{/if}}" href="/admin/templates">
<i class="nav-icon bi bi-layout-text-window-reverse"></i>
<p>Slide templates</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'canvas-sizes.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'canvas-sizes')}}active{{/if}}" href="/admin/canvas-sizes">
<i class="nav-icon bi bi-aspect-ratio"></i>
<p>Canvas sizes</p>
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'users.read' 'rbac.read')}}
<li class="nav-header">SETTINGS</li>
{{#if (hasPermission currentUser 'users.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'users')}}active{{/if}}" href="/admin/users">
<i class="nav-icon bi bi-people"></i>
<p>Users</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'rbac.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'rbac')}}active{{/if}}" href="/admin/rbac">
<i class="nav-icon bi bi-shield-lock"></i>
<p>Roles and permissions</p>
</a>
</li>
{{/if}}
{{/if}}
</ul>
</nav>
<div class="sidebar-version mt-auto pt-2 text-secondary small">
+81
View File
@@ -0,0 +1,81 @@
<div class="page-header">
<div>
<h2>Create role</h2>
<p>Define a new role and choose its permissions before saving.</p>
</div>
</div>
<form method="post" action="/admin/rbac" id="role-create-form" data-async-save data-async-save-close-url="/admin/rbac">
<div class="row g-4">
<div class="col-12 col-xl-4 d-flex flex-column gap-4">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Role details</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="role-name" class="form-label">Role name</label>
<input id="role-name" name="name" class="form-control" value="{{formValues.name}}" required />
</div>
<div class="mb-0">
<label for="role-description" class="form-label">Description</label>
<textarea id="role-description" name="description" class="form-control" rows="5">{{formValues.description}}</textarea>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-8 d-flex flex-column gap-4">
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header">
<h3 class="card-title">Permissions</h3>
</div>
<div class="card-body">
<p class="text-body-secondary mb-3">Pick the permissions this role should have now.</p>
{{#if permissionGroups.length}}
<div class="accordion" id="role-create-permissions-accordion">
{{#each permissionGroups}}
<div class="accordion-item">
<h2 class="accordion-header" id="create-heading-{{id}}">
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#create-collapse-{{id}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="create-collapse-{{id}}">
<span>{{title}}</span>
{{#if categoryName}}
<span class="badge text-bg-secondary ms-2">{{categoryName}}</span>
{{/if}}
</button>
</h2>
<div id="create-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="create-heading-{{id}}" data-bs-parent="#role-create-permissions-accordion">
<div class="accordion-body">
<div class="row g-3">
{{#each permissions}}
<div class="col-12 col-md-6">
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" {{#if isSelected}}checked{{/if}} />
<span class="form-check-label pe-4">
<strong>{{actionLabel}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
{{/each}}
</div>
</div>
</div>
</div>
{{/each}}
</div>
{{else}}
<div class="alert alert-warning mb-0">No permissions are available.</div>
{{/if}}
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end mt-4">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId="role-create-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
<a class="btn btn-warning" href="/admin/rbac" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
</div>
</div>
</form>
+134
View File
@@ -0,0 +1,134 @@
<div class="page-header">
<div>
<h2>Edit role - {{role.name}}</h2>
<p>Choose which permissions and users this role receives.</p>
</div>
</div>
<div class="row g-4">
<div class="col-12 col-xl-4 d-flex flex-column gap-4">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Role details</h3>
</div>
<form method="post" action="/admin/rbac/{{role.id}}" id="role-details-form" data-async-save data-async-save-close-url="/admin/rbac">
<div class="card-body">
<div class="mb-3">
<label for="role-name-edit" class="form-label">Role Name</label>
<input id="role-name-edit" name="name" class="form-control" value="{{role.name}}" required />
</div>
<div class="mb-0">
<label for="role-description-edit" class="form-label">Description</label>
<textarea id="role-description-edit" name="description" class="form-control" rows="5">{{role.description}}</textarea>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId="role-details-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
</div>
{{#unless role.user_count}}
<form method="post" action="/admin/rbac/{{role.id}}/delete" class="inline-form" data-confirm-message="Delete {{role.name}}?">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
{{/unless}}
</div>
</form>
</div>
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header">
<h3 class="card-title">Users</h3>
</div>
<form method="post" action="/admin/rbac/{{role.id}}/users" id="role-users-form" data-async-save data-async-save-close-url="/admin/rbac/{{role.id}}/edit">
<div class="card-body p-0">
<div class="px-3 pt-3">
<p class="text-body-secondary mb-3">Choose the users who should belong to this role.</p>
</div>
{{#if users.length}}
<div class="table-responsive">
<table class="table table-sm align-middle mb-0 w-100">
<thead>
<tr>
<th style="width: 1%" scope="col">Select</th>
<th scope="col">Name</th>
<th scope="col">Username</th>
<th scope="col">Roles</th>
</tr>
</thead>
<tbody>
{{#each users}}
<tr>
<td>
<input class="form-check-input" type="checkbox" name="user_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
</td>
<td><strong>{{name}}</strong></td>
<td class="text-body-secondary">{{username}}</td>
<td class="text-body-secondary">{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{else}}
<div class="px-3 pb-3">
<div class="alert alert-warning mb-0">Create at least one user before assigning this role.</div>
</div>
{{/if}}
</div>
</form>
</div>
</div>
<div class="col-12 col-xl-8 d-flex flex-column gap-4">
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header">
<h3 class="card-title">Permissions</h3>
</div>
<form method="post" action="/admin/rbac/{{role.id}}/permissions" id="role-permissions-form" data-async-save data-async-save-close-url="/admin/rbac/{{role.id}}/edit">
<div class="card-body">
<p class="text-body-secondary mb-3">Each section below is a resource. Pick the actions this role should have for that resource.</p>
{{#if permissionGroups.length}}
<div class="accordion" id="role-permissions-accordion">
{{#each permissionGroups}}
<div class="accordion-item">
<h2 class="accordion-header" id="heading-{{id}}">
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{id}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="collapse-{{id}}">
<span>{{title}}</span>
{{#if categoryName}}
<span class="badge text-bg-secondary ms-2">{{categoryName}}</span>
{{/if}}
</button>
</h2>
<div id="collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="heading-{{id}}" data-bs-parent="#role-permissions-accordion">
<div class="accordion-body">
<div class="row g-3">
{{#each permissions}}
<div class="col-12 col-md-6">
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" {{#if isSelected}}checked{{/if}} />
<span class="form-check-label pe-4">
<strong>{{actionLabel}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
{{/each}}
</div>
</div>
</div>
</div>
{{/each}}
</div>
{{else}}
<div class="alert alert-warning mb-0">No permissions are available.</div>
{{/if}}
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Permission actions">
{{{saveActionButtons formId="role-permissions-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
</div>
</div>
</form>
</div>
</div>
</div>
+70
View File
@@ -0,0 +1,70 @@
<div class="page-header">
<div>
<h2>Roles and permissions</h2>
<p>Define roles here, then assign those roles to users from either the role or users screen.</p>
</div>
</div>
<div class="row g-4">
<div class="col-12">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Existing roles</h3>
<div class="card-tools">
<a class="btn btn-primary btn-sm" href="/admin/rbac/new">Add role</a>
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0 users-table">
<thead>
<tr>
<th>Role</th>
<th>Description</th>
<th>Users</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{#if roles.length}}
{{#each roles}}
<tr>
<td data-label="Role">
<div class="user-cell">
<div>
<strong>{{name}}</strong>
</div>
</div>
</td>
<td data-label="Description" title="{{description}}">
{{#if description}}
<span class="subtle">{{truncateText description 80}}</span>
{{else}}
<span class="empty">No description provided.</span>
{{/if}}
</td>
<td data-label="Users">{{user_count}}</td>
<td data-label="Permissions">{{permission_count}}</td>
<td data-label="Actions">
<div class="actions users-row-actions">
<a class="btn btn-sm btn-primary" href="/admin/rbac/{{id}}/edit">Edit</a>
{{#unless user_count}}
<form method="post" action="/admin/rbac/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{name}}?">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
{{else}}
<span class="empty">In use</span>
{{/unless}}
</div>
</td>
</tr>
{{/each}}
{{else}}
<tr><td colspan="5" class="empty">No roles found.</td></tr>
{{/if}}
</tbody>
</table>
</div>
</div>
</div>
</div>
+62 -28
View File
@@ -5,36 +5,70 @@
</div>
</div>
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">User details</h3>
<div class="row g-4">
<div class="col-12 col-xl-4">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">User details</h3>
</div>
<form id="user-form" method="post" action="/admin/users">
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="user-username" class="form-label">Username</label>
<input id="user-username" type="text" name="username" class="form-control" autocomplete="username" value="{{formValues.username}}" required />
</div>
<div class="col-12 col-md-6">
<label for="user-name" class="form-label">Name</label>
<input id="user-name" type="text" name="name" class="form-control" autocomplete="name" value="{{formValues.name}}" required />
</div>
<div class="col-12 col-md-6">
<label for="user-password" class="form-label">Password</label>
<input id="user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
<div class="col-12 col-md-6">
<label for="user-confirm-password" class="form-label">Confirm password</label>
<input id="user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="User actions">
{{{saveActionButtons formId="user-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/admin/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
</div>
</div>
</form>
</div>
</div>
<form id="user-form" method="post" action="/admin/users">
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="user-username" class="form-label">Username</label>
<input id="user-username" type="text" name="username" class="form-control" autocomplete="username" required />
</div>
<div class="col-12 col-md-6">
<label for="user-name" class="form-label">Name</label>
<input id="user-name" type="text" name="name" class="form-control" autocomplete="name" required />
</div>
<div class="col-12 col-md-6">
<label for="user-password" class="form-label">Password</label>
<input id="user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
<div class="col-12 col-md-6">
<label for="user-confirm-password" class="form-label">Confirm password</label>
<input id="user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
<div class="col-12 col-xl-8">
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header">
<h3 class="card-title">Roles</h3>
</div>
<div class="card-body">
<div class="row g-3">
{{#if roles.length}}
{{#each roles}}
<div class="col-4">
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
<span class="form-check-label pe-4">
<strong>{{name}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">Create at least one role before adding users.</div>
</div>
{{/if}}
</div>
<div class="form-text mt-2">Select at least one role so the new user can sign in with access.</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="User actions">
{{{saveActionButtons formId="user-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/admin/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
</div>
</div>
</form>
</div>
</div>
+36
View File
@@ -60,6 +60,42 @@
</form>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card card-outline card-secondary admin-form-card h-100 mb-0">
<div class="card-header">
<h3 class="card-title">Roles</h3>
</div>
<form id="user-roles-form" method="post" action="/admin/users/{{user.id}}/roles" data-async-save data-async-save-close-url="/admin/users">
<div class="card-body">
<div class="row g-3">
{{#if roles.length}}
{{#each roles}}
<div class="col-12 col-md-6">
<label class="form-check card card-outline card-secondary p-3 h-100 mb-0">
<input class="form-check-input" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
<span class="form-check-label ms-2">
<strong>{{name}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">Create at least one role before assigning users.</div>
</div>
{{/if}}
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId="user-roles-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
+22 -4
View File
@@ -18,6 +18,7 @@
<tr>
<th>User</th>
<th>Name</th>
<th>Roles</th>
<th>Actions</th>
</tr>
</thead>
@@ -28,9 +29,10 @@
<td data-label="User">
<div class="user-cell">
<div>
<strong>{{username}}</strong>
{{#if isCurrentUser}}
<div class="subtle">Current signed-in user</div>
<i>{{username}}</i>
{{else}}
{{username}}
{{/if}}
</div>
</div>
@@ -38,7 +40,23 @@
<td data-label="Name">
<div class="user-cell">
<div>
<strong>{{#if name}}{{name}}{{else}}-{{/if}}</strong>
{{#if isCurrentUser}}
<i>{{#if name}}{{name}}{{else}}-{{/if}}</i>
{{else}}
{{#if name}}{{name}}{{else}}-{{/if}}
{{/if}}
</div>
</div>
</td>
<td data-label="Roles">
<div class="user-cell">
<div>
{{#if isCurrentUser}}
<i>{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}</i>
{{else}}
{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}
{{/if}}
</div>
</div>
</td>
@@ -57,7 +75,7 @@
</tr>
{{/each}}
{{else}}
<tr><td colspan="3" class="empty">No users found.</td></tr>
<tr><td colspan="4" class="empty">No users found.</td></tr>
{{/if}}
</tbody>
</table>