Release v2.17.0: add read-only user previews and access fallbacks
This commit is contained in:
1 parent
11dd801f6d
commit
9c28eca3ef
52 files changed
+1623
-139
No files matched your search
@@ -7,4 +7,5 @@
|
||||
!src/**
|
||||
!scripts/
|
||||
!scripts/**
|
||||
!CHANGELOG.md
|
||||
/local-examples/
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.17.0 - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added read-only user previews from the Users page, controlled by Impersonate > Allow, with an expiring session, a persistent exit banner, and audit logging.
|
||||
- Added a responsive changelog page linked from About, with collapsible release history and the latest release expanded by default.
|
||||
- Added a password-only screen for required password changes, keeping profile and session controls unavailable until completion.
|
||||
- Dashboard-wide reload, pause, and blackout actions use Connected clients > Allow, the same permission as per-client commands.
|
||||
- Signed-in users without Dashboard access now land on their first permitted section; users without assigned sections get a no-access page with Account and Sign out options.
|
||||
- Role permission checkboxes show each permission's description on hover.
|
||||
|
||||
## 2.16.8 - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added a responsive changelog page linked from About, with collapsible release history and the latest release expanded by default.
|
||||
|
||||
## 2.16.7 - 2026-10-22
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -10,6 +10,7 @@ COPY package-lock.json ./package-lock.json
|
||||
|
||||
COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
COPY CHANGELOG.md ./CHANGELOG.md
|
||||
|
||||
RUN mkdir -p /app/media
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.16.7",
|
||||
"version": "2.17.0",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.16.7",
|
||||
"version": "2.17.0",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"engines": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.16.7",
|
||||
"version": "2.17.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.16.7",
|
||||
"version": "2.17.0",
|
||||
"dependencies": {
|
||||
"animate.css": "^4.1.1",
|
||||
"bootstrap-icons": "1.13.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.16.7",
|
||||
"version": "2.17.0",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"engines": {
|
||||
|
||||
@@ -511,6 +511,8 @@ async function ensureSchema(pool, options) {
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
session_hash CHAR(64) NOT NULL UNIQUE,
|
||||
user_id INT NOT NULL,
|
||||
view_as_user_id INT NULL,
|
||||
view_as_expires_at DATETIME NULL,
|
||||
ip_address VARCHAR(255) NULL,
|
||||
user_agent VARCHAR(512) NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
|
||||
@@ -724,6 +724,14 @@ const VERSIONED_MIGRATIONS = [
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'i_api_source_groups', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'name');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.17.0',
|
||||
label: 'v2.17.0 read-only user preview sessions',
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'a_sessions', 'view_as_user_id', 'INT NULL', 'user_id');
|
||||
await ensureColumn(pool, 'a_sessions', 'view_as_expires_at', 'DATETIME NULL', 'view_as_user_id');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+25
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
const PERMISSION_SECTIONS = [
|
||||
{
|
||||
sectionName: 'Main navigation',
|
||||
sectionName: 'Main Navigation',
|
||||
order: 10,
|
||||
permissions: [
|
||||
{
|
||||
@@ -10,8 +10,7 @@ const PERMISSION_SECTIONS = [
|
||||
order: 10,
|
||||
name: 'Dashboard',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' }
|
||||
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -213,6 +212,14 @@ const PERMISSION_SECTIONS = [
|
||||
{ key: 'allow', name: 'Allow', description: 'Resend pending user invitations.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'impersonate',
|
||||
order: 25,
|
||||
name: 'Impersonate',
|
||||
permissions: [
|
||||
{ key: 'allow', name: 'Allow', description: 'Preview a user account in read-only mode.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rbac',
|
||||
order: 20,
|
||||
@@ -237,7 +244,7 @@ const PERMISSION_SECTIONS = [
|
||||
{
|
||||
key: 'background-tasks',
|
||||
order: 40,
|
||||
name: 'Task Queue',
|
||||
name: 'Task queue',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||
@@ -343,6 +350,9 @@ function hasPermission(currentUser, permissionKey) {
|
||||
if (!normalizedPermissionKey || !currentUser) {
|
||||
return false;
|
||||
}
|
||||
if (currentUser.isViewingAs && normalizedPermissionKey.endsWith('.allow')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const permissionKeys = Array.isArray(currentUser.permissionKeys)
|
||||
? currentUser.permissionKeys
|
||||
@@ -353,6 +363,16 @@ function hasPermission(currentUser, permissionKey) {
|
||||
return normalizePermissionKeys(permissionKeys).includes(normalizedPermissionKey);
|
||||
}
|
||||
|
||||
function hasAssignedPermission(currentUser, permissionKey) {
|
||||
if (!currentUser) {
|
||||
return false;
|
||||
}
|
||||
if (!currentUser.isViewingAs) {
|
||||
return hasPermission(currentUser, permissionKey);
|
||||
}
|
||||
return hasPermission(Object.assign({}, currentUser, { isViewingAs: false }), permissionKey);
|
||||
}
|
||||
|
||||
function hasAnyPermission(currentUser, permissionKeys) {
|
||||
const normalizedPermissionKeys = normalizePermissionKeys(permissionKeys);
|
||||
if (!normalizedPermissionKeys.length || !currentUser) {
|
||||
@@ -398,6 +418,7 @@ module.exports = {
|
||||
PERMISSION_SECTIONS,
|
||||
DEFAULT_ROLE,
|
||||
hasPermission,
|
||||
hasAssignedPermission,
|
||||
hasAnyPermission,
|
||||
requirePermission,
|
||||
normalizePermissionKeys,
|
||||
|
||||
@@ -121,6 +121,8 @@ async function start() {
|
||||
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie;
|
||||
const loadCurrentUser = sessionService.loadCurrentUser;
|
||||
const requireAuth = sessionService.requireAuth;
|
||||
const startViewAsUser = sessionService.startViewAsUser;
|
||||
const stopViewAsUser = sessionService.stopViewAsUser;
|
||||
// Middleware and route registration.
|
||||
registerMiddleware(app, {
|
||||
pool: pool,
|
||||
@@ -141,6 +143,8 @@ async function start() {
|
||||
mediaDir: webConfig.mediaDir,
|
||||
uploadDir: webConfig.uploadsDir,
|
||||
createUserSession: createUserSession,
|
||||
startViewAsUser: startViewAsUser,
|
||||
stopViewAsUser: stopViewAsUser,
|
||||
setSessionCookie: setSessionCookie,
|
||||
getSessionMaxAgeMs: getSessionMaxAgeMs,
|
||||
clearSessionCookie: clearSessionCookie,
|
||||
|
||||
+11
-1
@@ -71,6 +71,7 @@ function registerUrlHelpers(Handlebars) {
|
||||
function registerPermissionHelpers(Handlebars, deps) {
|
||||
const hasPermission = deps && deps.hasPermission;
|
||||
const hasAnyPermission = deps && deps.hasAnyPermission;
|
||||
const hasAssignedPermission = deps && deps.hasAssignedPermission;
|
||||
|
||||
Handlebars.registerHelper('hasPermission', function (currentUser, permissionKey) {
|
||||
return hasPermission(currentUser, permissionKey);
|
||||
@@ -81,6 +82,10 @@ function registerPermissionHelpers(Handlebars, deps) {
|
||||
args.pop();
|
||||
return hasAnyPermission(currentUser, args);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('hasAssignedPermission', function (currentUser, permissionKey) {
|
||||
return hasAssignedPermission(currentUser, permissionKey);
|
||||
});
|
||||
}
|
||||
|
||||
function registerUserHelpers(Handlebars) {
|
||||
@@ -171,6 +176,7 @@ function registerActionHelpers(Handlebars) {
|
||||
|
||||
// Shared partials are registered once at startup.
|
||||
function registerPartials(Handlebars, viewsRoot) {
|
||||
Handlebars.registerPartial('view-as-banner', fs.readFileSync(path.join(viewsRoot, 'shared', 'view-as-banner.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('media-picker-modal', fs.readFileSync(path.join(viewsRoot, 'shared', 'media-picker-modal.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
|
||||
@@ -192,6 +198,10 @@ function registerHandlebars(Handlebars, deps) {
|
||||
const viewsRoot = String(deps && deps.viewsRoot || '').trim();
|
||||
const hasPermission = deps && deps.hasPermission;
|
||||
const hasAnyPermission = deps && deps.hasAnyPermission;
|
||||
const hasAssignedPermission = deps && deps.hasAssignedPermission || function (currentUser, permissionKey) {
|
||||
if (!currentUser) return false;
|
||||
return hasPermission(Object.assign({}, currentUser, { isViewingAs: false }), permissionKey);
|
||||
};
|
||||
|
||||
if (!Handlebars || !viewsRoot || typeof hasPermission !== 'function' || typeof hasAnyPermission !== 'function') {
|
||||
throw new Error('registerHandlebars requires Handlebars, viewsRoot, hasPermission, and hasAnyPermission.');
|
||||
@@ -199,7 +209,7 @@ function registerHandlebars(Handlebars, deps) {
|
||||
|
||||
registerGeneralHelpers(Handlebars);
|
||||
registerUrlHelpers(Handlebars);
|
||||
registerPermissionHelpers(Handlebars, { hasPermission: hasPermission, hasAnyPermission: hasAnyPermission });
|
||||
registerPermissionHelpers(Handlebars, { hasPermission: hasPermission, hasAnyPermission: hasAnyPermission, hasAssignedPermission: hasAssignedPermission });
|
||||
registerUserHelpers(Handlebars);
|
||||
registerActionHelpers(Handlebars);
|
||||
registerPartials(Handlebars, viewsRoot);
|
||||
|
||||
+108
-28
@@ -1,6 +1,6 @@
|
||||
// Session cookie and current-user helpers for the web auth flow.
|
||||
|
||||
const { normalizePermissionKeys } = require('#src/rbac');
|
||||
const { hasPermission, normalizePermissionKeys } = require('#src/rbac');
|
||||
|
||||
function getRequestOrigin(req) {
|
||||
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||
@@ -128,9 +128,14 @@ function createSessionService(options) {
|
||||
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.user_id, u.id, u.name, u.username, u.email, u.email_verified_at, u.pending_email, u.must_change_password
|
||||
`SELECT s.user_id, s.view_as_user_id, s.view_as_expires_at,
|
||||
u.id, u.name, u.username, u.email, u.email_verified_at, u.pending_email, u.must_change_password,
|
||||
preview.id AS preview_id, preview.name AS preview_name, preview.username AS preview_username,
|
||||
preview.email AS preview_email, preview.email_verified_at AS preview_email_verified_at,
|
||||
preview.pending_email AS preview_pending_email, preview.must_change_password AS preview_must_change_password
|
||||
FROM a_sessions s
|
||||
JOIN a_users u ON u.id = s.user_id
|
||||
LEFT JOIN a_users preview ON preview.id = s.view_as_user_id AND s.view_as_expires_at > NOW()
|
||||
WHERE s.session_hash = ?
|
||||
AND s.expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
@@ -141,34 +146,107 @@ function createSessionService(options) {
|
||||
}
|
||||
|
||||
const userId = Number(rows[0].id);
|
||||
const [roleRows] = await pool.query(
|
||||
`SELECT r.role_key
|
||||
FROM a_user_roles ur
|
||||
JOIN a_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 a_user_roles ur
|
||||
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN a_permissions p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY p.section_name ASC, p.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
async function loadAccess(userId) {
|
||||
const [roleRows] = await pool.query(
|
||||
`SELECT r.role_key
|
||||
FROM a_user_roles ur
|
||||
JOIN a_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 a_user_roles ur
|
||||
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN a_permissions p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY p.section_name ASC, p.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
return {
|
||||
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();
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
const actorAccess = await loadAccess(userId);
|
||||
const authenticatedUser = Object.assign({}, rows[0], {
|
||||
mustChangePassword: Boolean(rows[0].must_change_password),
|
||||
roleKeys: actorAccess.roleKeys,
|
||||
permissionKeys: actorAccess.permissionKeys
|
||||
});
|
||||
const previewUserId = Number(rows[0].preview_id);
|
||||
const canContinuePreview = previewUserId > 0
|
||||
&& previewUserId !== userId
|
||||
&& hasPermission(authenticatedUser, 'impersonate.allow');
|
||||
|
||||
if (Number(rows[0].view_as_user_id) > 0 && !canContinuePreview) {
|
||||
await pool.query(
|
||||
'UPDATE a_sessions SET view_as_user_id = NULL, view_as_expires_at = NULL WHERE session_hash = ? AND user_id = ?',
|
||||
[tokenHash, userId]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
||||
return Object.assign({}, rows[0], {
|
||||
mustChangePassword: Boolean(rows[0].must_change_password),
|
||||
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();
|
||||
}))
|
||||
});
|
||||
|
||||
if (!canContinuePreview) {
|
||||
return authenticatedUser;
|
||||
}
|
||||
|
||||
const previewAccess = await loadAccess(previewUserId);
|
||||
return {
|
||||
id: previewUserId,
|
||||
name: rows[0].preview_name,
|
||||
username: rows[0].preview_username,
|
||||
email: rows[0].preview_email,
|
||||
email_verified_at: rows[0].preview_email_verified_at,
|
||||
pending_email: rows[0].preview_pending_email,
|
||||
mustChangePassword: Boolean(rows[0].preview_must_change_password),
|
||||
roleKeys: previewAccess.roleKeys,
|
||||
permissionKeys: previewAccess.permissionKeys,
|
||||
authenticatedUser: authenticatedUser,
|
||||
isViewingAs: true,
|
||||
viewAsExpiresAt: rows[0].view_as_expires_at
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionHash(req) {
|
||||
const cookies = parseCookies(req && req.headers && req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
return token ? hashSessionToken(token) : '';
|
||||
}
|
||||
|
||||
async function startViewAsUser(pool, req, actorUserId, targetUserId) {
|
||||
const sessionHash = getSessionHash(req);
|
||||
if (!sessionHash) {
|
||||
return false;
|
||||
}
|
||||
const [result] = await pool.query(
|
||||
`UPDATE a_sessions
|
||||
SET view_as_user_id = ?, view_as_expires_at = DATE_ADD(NOW(), INTERVAL 15 MINUTE), modified_by = ?
|
||||
WHERE session_hash = ? AND user_id = ? AND expires_at > NOW() AND view_as_user_id IS NULL`,
|
||||
[targetUserId, actorUserId, sessionHash, actorUserId]
|
||||
);
|
||||
return Number(result && result.affectedRows) === 1;
|
||||
}
|
||||
|
||||
async function stopViewAsUser(pool, req, actorUserId) {
|
||||
const sessionHash = getSessionHash(req);
|
||||
if (!sessionHash) {
|
||||
return false;
|
||||
}
|
||||
const [result] = await pool.query(
|
||||
`UPDATE a_sessions
|
||||
SET view_as_user_id = NULL, view_as_expires_at = NULL, modified_by = ?
|
||||
WHERE session_hash = ? AND user_id = ? AND view_as_user_id IS NOT NULL`,
|
||||
[actorUserId, sessionHash, actorUserId]
|
||||
);
|
||||
return Number(result && result.affectedRows) === 1;
|
||||
}
|
||||
|
||||
async function createUserSession(pool, userId, configuredMaxAgeMs, metadata) {
|
||||
@@ -219,6 +297,8 @@ function createSessionService(options) {
|
||||
setAuthMessageCookie: setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
startViewAsUser: startViewAsUser,
|
||||
stopViewAsUser: stopViewAsUser,
|
||||
createUserSession: createUserSession,
|
||||
getSessionMaxAgeMs: getSessionMaxAgeMs,
|
||||
requireAuth: requireAuth,
|
||||
|
||||
+17
-2
@@ -23,6 +23,9 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
app.use(async function (req, _res, next) {
|
||||
try {
|
||||
req.currentUser = await loadCurrentUser(pool, req);
|
||||
req.authenticatedUser = req.currentUser && req.currentUser.authenticatedUser
|
||||
? req.currentUser.authenticatedUser
|
||||
: req.currentUser;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -32,14 +35,26 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
registerErrorPageMiddleware(app, deps.pages);
|
||||
|
||||
app.use(function (req, res, next) {
|
||||
const isViewAsStop = req.method === 'POST' && req.path === '/settings/view-as/stop';
|
||||
if (req.currentUser && req.currentUser.isViewingAs && !['GET', 'HEAD', 'OPTIONS'].includes(String(req.method || '').toUpperCase()) && !isViewAsStop) {
|
||||
const error = new Error('This preview is read-only. Exit View as to make changes.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
return next(error);
|
||||
}
|
||||
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/verify-email' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') {
|
||||
return next();
|
||||
}
|
||||
|
||||
return requireAuth(req, res, function () {
|
||||
if (req.currentUser && req.currentUser.mustChangePassword && req.path !== '/account' && req.path !== '/account/password' && req.path !== '/account/sessions/revoke' && req.path !== '/logout') {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Please change your password before continuing.'));
|
||||
const mustChangePassword = req.currentUser && req.currentUser.isViewingAs
|
||||
? Boolean(req.authenticatedUser && req.authenticatedUser.mustChangePassword)
|
||||
: Boolean(req.authenticatedUser && req.authenticatedUser.mustChangePassword || req.currentUser && req.currentUser.mustChangePassword);
|
||||
if (mustChangePassword && req.path !== '/account/password' && req.path !== '/settings/view-as/stop' && req.path !== '/logout') {
|
||||
return res.redirect('/account/password');
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,14 @@ module.exports = {
|
||||
renderForgotPasswordPage: require(routePath('auth', 'forgot-password')),
|
||||
renderResetPasswordPage: require(routePath('auth', 'reset-password')),
|
||||
renderAcceptInvitePage: require(routePath('auth', 'accept-invite')),
|
||||
renderNoAccessPage: require(routePath('auth', 'no-access')),
|
||||
renderEmailVerifiedPage: require(routePath('auth', 'email-verified')),
|
||||
renderEmailVerificationErrorPage: require(routePath('auth', 'email-verification-error')),
|
||||
renderAccountPage: require(routePath('account', 'password')),
|
||||
renderRequiredPasswordPage: require(routePath('account', 'password-required')),
|
||||
renderSettingsPage: require(routePath('settings', 'index')),
|
||||
renderAboutPage: require(routePath('settings', 'about', 'index')),
|
||||
renderChangelogPage: require(routePath('settings', 'changelog', 'index')),
|
||||
renderUsersPage: require(routePath('settings', 'users', 'list')),
|
||||
renderInvitationsPage: require(routePath('settings', 'invitations', 'list')),
|
||||
renderUsersAddPage: require(routePath('settings', 'users', 'add')),
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/* Error pages */
|
||||
|
||||
.error-page-content {
|
||||
width: 100%;
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
/* Media picker and gallery */
|
||||
|
||||
.media-picker-item {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: .375rem;
|
||||
@@ -59,10 +63,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-gallery-bulk-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.media-gallery-controls {
|
||||
flex: 1 1 auto;
|
||||
@@ -236,17 +236,6 @@
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.media-gallery-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.media-gallery-footer .media-gallery-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-gallery-empty {
|
||||
padding: 3rem 1rem;
|
||||
border: 1px dashed var(--bs-border-color);
|
||||
@@ -259,10 +248,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-gallery-bulk-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-gallery-search {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -287,6 +272,8 @@
|
||||
max-height: min(72vh, 48rem);
|
||||
object-fit: contain;
|
||||
}
|
||||
/* Connection status indicators */
|
||||
|
||||
.status-dot {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -349,6 +336,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Account menu */
|
||||
|
||||
.user-menu-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -399,6 +388,8 @@
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
/* Application shell and sidebar */
|
||||
|
||||
.app-header .navbar-nav {
|
||||
--bs-navbar-nav-link-padding-x: 10px;
|
||||
}
|
||||
@@ -443,6 +434,8 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Application settings */
|
||||
|
||||
.settings-nav-card {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
@@ -474,6 +467,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Cards, authentication and email previews */
|
||||
|
||||
.card {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -579,6 +574,8 @@
|
||||
padding: 0.65rem 1.1rem;
|
||||
}
|
||||
|
||||
/* Shared card headers and search controls */
|
||||
|
||||
.card-header {
|
||||
position: relative;
|
||||
}
|
||||
@@ -604,6 +601,8 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Template animation preview controls */
|
||||
|
||||
.template-preview-card .card-body {
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
@@ -637,6 +636,8 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Audit log change comparisons */
|
||||
|
||||
.audit-change-list {
|
||||
display: grid;
|
||||
padding: 0.08rem 0.3rem;
|
||||
@@ -663,6 +664,8 @@
|
||||
.audit-change-to ins {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
/* Template preview states and permission tables */
|
||||
|
||||
.template-preview-card .btn-group .btn {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -712,6 +715,8 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Announcement icon, type and screen pickers */
|
||||
|
||||
.announcement-icon-picker {
|
||||
position: relative;
|
||||
}
|
||||
@@ -1088,6 +1093,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Background task controls */
|
||||
|
||||
.background-tasks-task-tools {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
@@ -1102,6 +1109,8 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Paginated page layout */
|
||||
|
||||
.table-pagination-page .app-main {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
@@ -1122,6 +1131,8 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Background task page layout */
|
||||
|
||||
.background-tasks-page .app-content .container-fluid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1141,6 +1152,8 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Media library page layout */
|
||||
|
||||
.media-library-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1179,6 +1192,8 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Shared table pagination and responsive search */
|
||||
|
||||
[data-table-pagination-card] {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
@@ -1291,6 +1306,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Device onboarding, pairing PIN and QR scanner */
|
||||
|
||||
.onboarding-pairing-card > .card-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
@@ -1377,6 +1394,8 @@
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Timetable entry tables */
|
||||
|
||||
.timetable-entries-table-shell {
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: calc(var(--bs-border-radius) - 1px);
|
||||
@@ -1409,19 +1428,7 @@
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.api-source-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
padding-bottom: 0.35rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Dashboard and connected screen controls */
|
||||
|
||||
.dashboard-hero {
|
||||
overflow: hidden;
|
||||
@@ -2018,6 +2025,8 @@
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Shared page headers, tables and text utilities */
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -2041,6 +2050,248 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.no-access-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: max(28rem, calc(100dvh - 8rem));
|
||||
}
|
||||
|
||||
.no-access-panel {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
place-items: center;
|
||||
padding: 1.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.no-access-card {
|
||||
width: min(100%, 36rem);
|
||||
}
|
||||
|
||||
.view-as-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1020;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 1.25rem;
|
||||
border-bottom: 1px solid var(--bs-warning-border-subtle);
|
||||
background: var(--bs-warning-bg-subtle);
|
||||
color: var(--bs-warning-text-emphasis);
|
||||
}
|
||||
|
||||
.view-as-banner-copy {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.view-as-banner-copy > i {
|
||||
margin-top: 0.15rem;
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
.view-as-banner-copy > div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.view-as-banner-copy span {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.view-as-banner {
|
||||
align-items: flex-start;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.view-as-banner .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.changelog-eyebrow {
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.changelog-feed {
|
||||
max-width: 76rem;
|
||||
margin: 1rem auto 0 0.35rem;
|
||||
border-left: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.changelog-release {
|
||||
position: relative;
|
||||
margin-left: 1rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.changelog-release-summary {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.85rem 1rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.changelog-release-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog-release-summary:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.changelog-release-summary:focus-visible {
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.changelog-release-indicator {
|
||||
position: absolute;
|
||||
top: 1.55rem;
|
||||
left: -1.36rem;
|
||||
width: 0.7rem;
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid var(--bs-secondary-color);
|
||||
border-radius: 50%;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.changelog-release[open] .changelog-release-indicator {
|
||||
border-color: var(--bs-primary);
|
||||
background: var(--bs-primary);
|
||||
box-shadow: 0 0 0 3px var(--bs-body-bg), 0 0 0 4px var(--bs-primary);
|
||||
}
|
||||
|
||||
.changelog-release-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.changelog-version {
|
||||
color: var(--bs-emphasis-color);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.changelog-latest {
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.changelog-release-date {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.changelog-release-chevron {
|
||||
color: var(--bs-secondary-color);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.changelog-release[open] .changelog-release-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.changelog-release-content {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 1rem 1.35rem;
|
||||
}
|
||||
|
||||
.changelog-category {
|
||||
display: grid;
|
||||
grid-template-columns: 7rem minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.changelog-category-title {
|
||||
margin: 0.1rem 0 0;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.changelog-items {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.changelog-items li {
|
||||
position: relative;
|
||||
padding-left: 1rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.changelog-items li::before {
|
||||
position: absolute;
|
||||
top: 0.62em;
|
||||
left: 0;
|
||||
width: 0.35rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.changelog-feed {
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
|
||||
.changelog-release-summary {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
column-gap: 0.65rem;
|
||||
row-gap: 0.15rem;
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.changelog-release-date {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.changelog-release-chevron {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.changelog-release-content {
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.changelog-category {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card.card-outline > .card-header {
|
||||
align-items: center;
|
||||
min-height: 3.25rem;
|
||||
@@ -2088,11 +2339,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip[data-time-date-placeholder-chip] {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
@@ -2136,6 +2382,8 @@ td[data-label="Slides"] {
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide editor layout */
|
||||
|
||||
.slide-editor-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
|
||||
@@ -2177,6 +2425,8 @@ td[data-label="Slides"] {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* TinyMCE integration, including library-generated markup */
|
||||
|
||||
.slide-form-stack .editor-holder,
|
||||
.template-field-card .editor-holder {
|
||||
position: relative;
|
||||
@@ -2351,6 +2601,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
}
|
||||
|
||||
|
||||
/* Slide preview and layout / animation dialogs */
|
||||
|
||||
.slide-preview-dimensions-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2510,6 +2762,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
}
|
||||
}
|
||||
|
||||
/* Template snapping and shared alignment guides */
|
||||
|
||||
.template-designer-main-header::after {
|
||||
display: none;
|
||||
}
|
||||
@@ -2593,6 +2847,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide preview canvas and popup */
|
||||
|
||||
.slide-preview-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -2707,6 +2963,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Image cropping, uploads and media picker dialogs */
|
||||
|
||||
.slide-image-cropper-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -2995,6 +3253,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Template designer canvas, selection and resize handles */
|
||||
|
||||
.template-designer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 25rem;
|
||||
@@ -3129,6 +3389,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
background: rgba(13, 110, 253, 0.08);
|
||||
}
|
||||
|
||||
/* Template designer sidebar and region fields */
|
||||
|
||||
.template-designer-sidebar {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
@@ -3371,6 +3633,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Announcement color picker and list icons */
|
||||
|
||||
.announcement-color-picker {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -3448,6 +3712,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* API region placeholders and sample responses */
|
||||
|
||||
.api-region-placeholder-section {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
@@ -3518,13 +3784,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.template-field-card.is-expanded .tox-edit-area__iframe {
|
||||
min-height: 32rem;
|
||||
}
|
||||
|
||||
.template-field-card.is-expanded .card-body {
|
||||
min-height: 36rem;
|
||||
}
|
||||
/* Region field controls and responsive editor layout */
|
||||
|
||||
.template-field-card .slide-image-file,
|
||||
.region-item .slide-image-file {
|
||||
@@ -3590,6 +3850,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
|
||||
}
|
||||
|
||||
/* Popup frames, toasts and visibility utilities */
|
||||
|
||||
.frame-popup-card-body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -3612,6 +3874,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Playlist slide picker */
|
||||
|
||||
.playlist-slide-picker-modal-body {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -3793,6 +4057,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Playlist scheduling controls and dialog */
|
||||
|
||||
.schedule-day-grid .schedule-day-button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
@@ -3918,6 +4184,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Shared table actions */
|
||||
|
||||
.actions,
|
||||
.playlist-item-actions,
|
||||
.users-row-actions {
|
||||
@@ -3950,6 +4218,8 @@ td[data-label="Actions"] > div {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Playlist duration and playback configuration */
|
||||
|
||||
.playlist-duration-input,
|
||||
.playlist-group-block-quota {
|
||||
width: 4rem !important;
|
||||
@@ -4025,6 +4295,8 @@ table.table > :not(caption) > * > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Slide table content and thumbnails */
|
||||
|
||||
.playlist-slide-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4057,7 +4329,9 @@ table.table > :not(caption) > * > * {
|
||||
}
|
||||
|
||||
.card .table td[data-label="Thumbnail"] {
|
||||
padding-inline: 1.5rem;
|
||||
width: 1%;
|
||||
padding-inline: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td[data-label="Thumbnail"] > .playlist-slide-thumb {
|
||||
@@ -4087,6 +4361,8 @@ td[data-label="Thumbnail"] > .playlist-slide-thumb {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Playlist ordering and groups */
|
||||
|
||||
.playlist-order-cell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
@@ -4129,13 +4405,6 @@ td[data-label="Thumbnail"] > .playlist-slide-thumb {
|
||||
padding-left: 1.5rem !important;
|
||||
}
|
||||
|
||||
.playlist-group-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.playlist-group-block-slide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4192,6 +4461,8 @@ td[data-label="Thumbnail"] > .playlist-slide-thumb {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Playlist drag-and-drop states and previews */
|
||||
|
||||
.playlist-group-block-dragging > td {
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -4335,6 +4606,8 @@ td[data-label="Thumbnail"] > .playlist-slide-thumb {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Table sorting */
|
||||
|
||||
table.table thead th.sortable {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@@ -4350,6 +4623,8 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Account menu and responsive dashboard overrides */
|
||||
|
||||
.navbar-nav>.user-menu>.dropdown-menu>li.user-header {
|
||||
min-height: 100px;
|
||||
}
|
||||
@@ -4379,6 +4654,8 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
}
|
||||
}
|
||||
|
||||
/* Weather previews */
|
||||
|
||||
.weather-preview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4430,6 +4707,8 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
width: 7.5rem;
|
||||
}
|
||||
|
||||
/* Gradient stop editor */
|
||||
|
||||
.gradient-stop-bar {
|
||||
position: relative;
|
||||
height: 2.25rem;
|
||||
@@ -4469,6 +4748,8 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Media selection states and large dialogs */
|
||||
|
||||
.media-picker-item-selected-card .media-picker-item {
|
||||
border-color: var(--bs-success);
|
||||
box-shadow: 0 0 0 .15rem rgba(var(--bs-success-rgb), .2);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Password-only page shown before a user may access the rest of the app.
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { buildPasswordRequirementsText } = require('#src/web/routes/account/password');
|
||||
|
||||
module.exports = function renderRequiredPasswordPage(currentUser, requirements, inlineMessage) {
|
||||
const passwordRequirements = requirements || {
|
||||
minimumLength: 10,
|
||||
minimumCategories: 3,
|
||||
requireLowercase: false,
|
||||
requireUppercase: false,
|
||||
requireNumber: false,
|
||||
requireSymbol: false
|
||||
};
|
||||
|
||||
return renderView('account/password-required', {
|
||||
title: 'Change your password',
|
||||
currentUser: currentUser || null,
|
||||
passwordMinimumLength: passwordRequirements.minimumLength,
|
||||
passwordRequirementsText: buildPasswordRequirementsText(passwordRequirements),
|
||||
inlineMessage: String(inlineMessage || ''),
|
||||
message: '',
|
||||
messageVariant: 'warning',
|
||||
scripts: []
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
function buildPasswordRequirementsText(requirements) {
|
||||
const requiredCategories = [];
|
||||
if (requirements.requireLowercase) requiredCategories.push('lowercase');
|
||||
if (requirements.requireUppercase) requiredCategories.push('uppercase');
|
||||
if (requirements.requireNumber) requiredCategories.push('number');
|
||||
if (requirements.requireSymbol) requiredCategories.push('symbol');
|
||||
return requiredCategories.length
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include ' + requiredCategories.join(', ') + '.'
|
||||
: requirements.minimumCategories === 4
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
|
||||
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
|
||||
}
|
||||
|
||||
function normalizeReturnUrl(value) {
|
||||
const url = String(value || '').trim();
|
||||
if (!url || url === '/account' || url.indexOf('/account?') === 0) {
|
||||
@@ -22,17 +35,6 @@ module.exports = function renderAccountPage(currentUser, message, returnUrl, all
|
||||
requireNumber: false,
|
||||
requireSymbol: false
|
||||
};
|
||||
const requiredCategories = [];
|
||||
if (requirements.requireLowercase) requiredCategories.push('lowercase');
|
||||
if (requirements.requireUppercase) requiredCategories.push('uppercase');
|
||||
if (requirements.requireNumber) requiredCategories.push('number');
|
||||
if (requirements.requireSymbol) requiredCategories.push('symbol');
|
||||
const passwordRequirementsText = requiredCategories.length
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include ' + requiredCategories.join(', ') + '.'
|
||||
: requirements.minimumCategories === 4
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
|
||||
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
|
||||
|
||||
return renderView('account/index', {
|
||||
title: 'My account',
|
||||
active: 'account',
|
||||
@@ -48,7 +50,9 @@ module.exports = function renderAccountPage(currentUser, message, returnUrl, all
|
||||
allowUserSessionRevocation: Boolean(allowUserSessionRevocation),
|
||||
sessions: Array.isArray(sessions) ? sessions : [],
|
||||
passwordMinimumLength: requirements.minimumLength,
|
||||
passwordRequirementsText: passwordRequirementsText,
|
||||
passwordRequirementsText: buildPasswordRequirementsText(requirements),
|
||||
scripts: ['js/account/account-page.js']
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.buildPasswordRequirementsText = buildPasswordRequirementsText;
|
||||
@@ -28,6 +28,18 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return buildPasswordRequirements(settings);
|
||||
}
|
||||
|
||||
function isPasswordChangeRequired(req) {
|
||||
return Boolean(req && (req.authenticatedUser && req.authenticatedUser.mustChangePassword || req.currentUser && req.currentUser.mustChangePassword));
|
||||
}
|
||||
|
||||
async function sendPasswordError(req, res, message) {
|
||||
if (!isPasswordChangeRequired(req)) {
|
||||
return res.status(400).send(message);
|
||||
}
|
||||
const requirements = await getPasswordRequirements();
|
||||
return res.status(400).send(pages.renderRequiredPasswordPage(req.currentUser, requirements, message));
|
||||
}
|
||||
|
||||
function buildPasswordRequirements(settings) {
|
||||
return {
|
||||
minimumLength: settings['security.password_min_length'],
|
||||
@@ -102,6 +114,18 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/account/password', async function (req, res, next) {
|
||||
if (!isPasswordChangeRequired(req)) {
|
||||
return res.redirect('/account');
|
||||
}
|
||||
try {
|
||||
const requirements = await getPasswordRequirements();
|
||||
res.send(pages.renderRequiredPasswordPage(req.currentUser, requirements));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/account/profile', async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
@@ -268,14 +292,14 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (!verifyPassword(currentPassword, user)) {
|
||||
return res.status(400).send('Current password is incorrect.');
|
||||
return sendPasswordError(req, res, 'Current password is incorrect.');
|
||||
}
|
||||
const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements());
|
||||
if (passwordStrengthMessage) {
|
||||
return res.status(400).send(passwordStrengthMessage);
|
||||
return sendPasswordError(req, res, passwordStrengthMessage);
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
return res.status(400).send('New passwords do not match.');
|
||||
return sendPasswordError(req, res, 'New passwords do not match.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(newPassword);
|
||||
|
||||
@@ -162,7 +162,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
|
||||
app.post('/commands', requirePermission('clients.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')
|
||||
|
||||
@@ -3,6 +3,40 @@
|
||||
const { normalizeReturnToPath, getRequestOrigin } = require('#src/web/lib/auth/session');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
||||
const { hasPermission } = require('#src/rbac');
|
||||
|
||||
const DEFAULT_LANDING_ROUTES = [
|
||||
['dashboard.read', '/dashboard'],
|
||||
['clients.read', '/clients'],
|
||||
['screens.read', '/screens'],
|
||||
['playlists.read', '/playlists'],
|
||||
['slides.read', '/slides'],
|
||||
['templates.read', '/templates'],
|
||||
['canvas-sizes.read', '/canvas-sizes'],
|
||||
['announcements.read', '/announcements'],
|
||||
['media-library.read', '/media-library'],
|
||||
['api-sources.read', '/data-sources/api-sources'],
|
||||
['rss-feeds.read', '/data-sources/rss-feeds'],
|
||||
['timetables.read', '/data-sources/timetables'],
|
||||
['weather.read', '/data-sources/weather'],
|
||||
['users.read', '/settings/users'],
|
||||
['rbac.read', '/settings/roles'],
|
||||
['invitations.read', '/settings/invitations'],
|
||||
['fonts.read', '/settings/fonts'],
|
||||
['weather-icons.read', '/settings/weather-icons'],
|
||||
['background-tasks.read', '/settings/tasks-background'],
|
||||
['scheduled-tasks.read', '/settings/tasks-scheduled'],
|
||||
['audit-log.read', '/settings/audit-log'],
|
||||
['system-settings.read', '/settings/system'],
|
||||
['pairing.allow', '/pairing']
|
||||
];
|
||||
|
||||
function getDefaultLandingPath(currentUser) {
|
||||
const match = DEFAULT_LANDING_ROUTES.find(function (route) {
|
||||
return hasPermission(currentUser, route[0]);
|
||||
});
|
||||
return match ? match[1] : '/no-access';
|
||||
}
|
||||
|
||||
module.exports = function registerAuthRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -219,13 +253,20 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
});
|
||||
|
||||
app.get('/', function (req, res) {
|
||||
res.redirect(req.currentUser ? '/dashboard' : '/login');
|
||||
res.redirect(req.currentUser ? getDefaultLandingPath(req.currentUser) : '/login');
|
||||
});
|
||||
|
||||
app.get('/no-access', function (req, res) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
res.send(pages.renderNoAccessPage(req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/login', function (req, res) {
|
||||
const returnTo = getReturnTo(req);
|
||||
if (req.currentUser) {
|
||||
return res.redirect(returnTo || '/dashboard');
|
||||
return res.redirect(returnTo || '/');
|
||||
}
|
||||
|
||||
const message = typeof consumeAuthMessageCookie === 'function'
|
||||
@@ -336,7 +377,7 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
details: { rememberMe: rememberMe }
|
||||
});
|
||||
}
|
||||
res.redirect(returnTo || '/dashboard');
|
||||
res.redirect(returnTo || '/');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -349,14 +390,15 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
if (token) {
|
||||
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function' && req.currentUser) {
|
||||
const actor = req.authenticatedUser || req.currentUser;
|
||||
if (typeof recordRequestAuditEvent === 'function' && actor) {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'sessions',
|
||||
eventType: 'session.logout',
|
||||
actorUserId: req.currentUser.id,
|
||||
actorUserId: actor.id,
|
||||
targetType: 'user',
|
||||
targetId: req.currentUser.id,
|
||||
targetLabel: req.currentUser.username
|
||||
targetId: actor.id,
|
||||
targetLabel: actor.username
|
||||
});
|
||||
}
|
||||
clearSessionCookie(res);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Signed-in fallback for accounts with no readable application sections.
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderNoAccessPage(currentUser) {
|
||||
return renderView('auth/no-access', {
|
||||
title: 'No application access',
|
||||
currentUser: currentUser || null,
|
||||
scripts: []
|
||||
});
|
||||
};
|
||||
@@ -73,7 +73,7 @@ function buildApiSourceFormViewModel(apiSource, message, currentUser, options, i
|
||||
showSaveSecondaryActions: true,
|
||||
deleteDisabled: !isEdit || Boolean(viewApiSource.inUse),
|
||||
formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources',
|
||||
formAttrs: '',
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"',
|
||||
cancelUrl: '/data-sources/api-sources',
|
||||
deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : ''
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@ const registerMediaLibraryRoutes = require('#src/web/routes/settings/media-libra
|
||||
const registerSettingsPageRoutes = require('#src/web/routes/settings/routes');
|
||||
const registerAuditLogRoutes = require('#src/web/routes/settings/audit-log');
|
||||
const registerAboutRoutes = require('#src/web/routes/settings/about/routes');
|
||||
const registerChangelogRoutes = require('#src/web/routes/settings/changelog/routes');
|
||||
const registerViewAsRoutes = require('#src/web/routes/settings/view-as');
|
||||
const registerInternalSyncRoutes = require('#src/web/routes/internal/sync');
|
||||
const registerScreensRoutes = require('#src/web/routes/signage/screens/routes');
|
||||
const registerOnboardingRoutes = require('#src/web/routes/onboarding');
|
||||
@@ -52,6 +54,14 @@ function registerRoutes(app, deps) {
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie
|
||||
});
|
||||
registerAboutRoutes(app, deps);
|
||||
registerChangelogRoutes(app, deps);
|
||||
registerViewAsRoutes(app, {
|
||||
pool: deps.pool,
|
||||
requirePermission: deps.requirePermission,
|
||||
startViewAsUser: deps.startViewAsUser,
|
||||
stopViewAsUser: deps.stopViewAsUser,
|
||||
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||
});
|
||||
registerInternalSyncRoutes(app, {
|
||||
pool: deps.pool,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Settings page renderer for the project changelog.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
function parseChangelog(markdown) {
|
||||
const entries = [];
|
||||
let entry = null;
|
||||
let section = null;
|
||||
|
||||
String(markdown || '').split(/\r?\n/).forEach(function (line) {
|
||||
const entryMatch = line.match(/^##\s+(.+?)\s+-\s+(.+?)\s*$/);
|
||||
if (entryMatch) {
|
||||
entry = { version: entryMatch[1], date: entryMatch[2], sections: [] };
|
||||
entries.push(entry);
|
||||
section = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionMatch = line.match(/^###\s+(.+?)\s*$/);
|
||||
if (sectionMatch && entry) {
|
||||
section = { title: sectionMatch[1], items: [] };
|
||||
entry.sections.push(section);
|
||||
return;
|
||||
}
|
||||
|
||||
const itemMatch = line.match(/^-\s+(.+?)\s*$/);
|
||||
if (itemMatch && section) {
|
||||
section.items.push(itemMatch[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (section && section.items.length && /^\s{2,}\S/.test(line)) {
|
||||
const lastItemIndex = section.items.length - 1;
|
||||
section.items[lastItemIndex] += ' ' + line.trim();
|
||||
}
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readChangelog() {
|
||||
const changelogPath = path.join(__dirname, '../../../../..', 'CHANGELOG.md');
|
||||
return parseChangelog(fs.readFileSync(changelogPath, 'utf8'));
|
||||
}
|
||||
|
||||
module.exports = function renderChangelogPage(currentUser) {
|
||||
return renderView('settings/changelog/index', {
|
||||
title: 'Changelog',
|
||||
active: 'changelog',
|
||||
currentUser: currentUser || null,
|
||||
entries: readChangelog(),
|
||||
scripts: []
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.parseChangelog = parseChangelog;
|
||||
@@ -0,0 +1,13 @@
|
||||
// Settings route registration for the project changelog.
|
||||
|
||||
module.exports = function registerChangelogRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
|
||||
if (!pages) {
|
||||
throw new Error('registerChangelogRoutes requires pages.');
|
||||
}
|
||||
|
||||
app.get('/settings/changelog', function (req, res) {
|
||||
res.send(pages.renderChangelogPage(req.currentUser));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// Read-only user preview controls.
|
||||
|
||||
const { hasPermission } = require('#src/rbac');
|
||||
|
||||
module.exports = function registerViewAsRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const startViewAsUser = deps.startViewAsUser;
|
||||
const stopViewAsUser = deps.stopViewAsUser;
|
||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||
|
||||
app.post('/settings/users/:id/view-as', requirePermission('users.read'), requirePermission('impersonate.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const actor = req.authenticatedUser || req.currentUser && req.currentUser.authenticatedUser || req.currentUser;
|
||||
const targetUserId = Number(req.params.id);
|
||||
if (!actor || !hasPermission(actor, 'impersonate.allow') || req.currentUser && req.currentUser.isViewingAs) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
if (!Number.isInteger(targetUserId) || targetUserId <= 0 || targetUserId === Number(actor.id)) {
|
||||
return res.status(400).send('Choose another user to preview.');
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id, name, username FROM a_users WHERE id = ? LIMIT 1', [targetUserId]);
|
||||
if (!rows.length) {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
|
||||
const target = rows[0];
|
||||
const started = await startViewAsUser(pool, req, actor.id, targetUserId);
|
||||
if (!started) {
|
||||
return res.status(409).send('The preview could not be started. Sign in again and retry.');
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'users',
|
||||
eventType: 'user.view_as_started',
|
||||
actorUserId: actor.id,
|
||||
targetType: 'user',
|
||||
targetId: targetUserId,
|
||||
targetLabel: target.username,
|
||||
details: { expiresInMinutes: 15 }
|
||||
});
|
||||
}
|
||||
res.redirect('/dashboard');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/view-as/stop', async function (req, res, next) {
|
||||
try {
|
||||
const actor = req.authenticatedUser || req.currentUser && req.currentUser.authenticatedUser;
|
||||
if (!actor || !req.currentUser || !req.currentUser.isViewingAs) {
|
||||
return res.redirect('/dashboard');
|
||||
}
|
||||
|
||||
const target = req.currentUser;
|
||||
const stopped = await stopViewAsUser(pool, req, actor.id);
|
||||
if (!stopped) {
|
||||
return res.status(409).send('The preview has already ended. Refresh the page to continue.');
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'users',
|
||||
eventType: 'user.view_as_stopped',
|
||||
actorUserId: actor.id,
|
||||
targetType: 'user',
|
||||
targetId: target.id,
|
||||
targetLabel: target.username
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/users');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
+3
-2
@@ -4,7 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Handlebars = require('handlebars');
|
||||
const { version: appVersion } = require('#root/package.json');
|
||||
const { hasPermission, hasAnyPermission } = require('#src/rbac');
|
||||
const { hasPermission, hasAnyPermission, hasAssignedPermission } = require('#src/rbac');
|
||||
const { registerHandlebars } = require('#src/web/handlebars');
|
||||
const { getMessageVariant: inferMessageVariant } = require('#src/web/public/js/toast');
|
||||
|
||||
@@ -15,7 +15,8 @@ const SIGNAGE_VIEW_PREFIXES = new Set(['dashboard', 'clients', 'screens', 'playl
|
||||
registerHandlebars(Handlebars, {
|
||||
viewsRoot: VIEWS_ROOT,
|
||||
hasPermission: hasPermission,
|
||||
hasAnyPermission: hasAnyPermission
|
||||
hasAnyPermission: hasAnyPermission,
|
||||
hasAssignedPermission: hasAssignedPermission
|
||||
});
|
||||
|
||||
function resolveTemplatePath(relativePath) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Change your password</h2>
|
||||
<p>Set a new password to continue to Pulse Signage.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-7 col-xl-6">
|
||||
<div class="alert alert-warning d-flex align-items-start gap-2" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill mt-1" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>Password change required</strong>
|
||||
<div>Please change your password before continuing.</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#if inlineMessage}}
|
||||
<div class="alert alert-danger" role="alert">{{inlineMessage}}</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="card card-outline card-warning admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">New password</h3>
|
||||
</div>
|
||||
<form method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="required-current-password" class="form-label">Current password</label>
|
||||
<input id="required-current-password" type="password" name="current_password" class="form-control" autocomplete="current-password" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="required-new-password" class="form-label">New password</label>
|
||||
<input id="required-new-password" type="password" name="new_password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="required-confirm-password" class="form-label">Confirm new password</label>
|
||||
<input id="required-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="form-text mt-2">{{passwordRequirementsText}}</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-warning">Change password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="no-access-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>No application access</h2>
|
||||
<p>Your account is active, but no application sections have been assigned.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="no-access-panel">
|
||||
<div class="card card-outline card-warning no-access-card">
|
||||
<div class="card-body">
|
||||
<p class="mb-3">Ask an administrator to assign a role with access to the pages you need.</p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-outline-secondary" href="/account"><i class="bi bi-person-gear me-1" aria-hidden="true"></i>My account</a>
|
||||
<a class="btn btn-primary" href="/logout"><i class="bi bi-box-arrow-left me-1" aria-hidden="true"></i>Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="card-header bg-body-tertiary d-flex align-items-center">
|
||||
<h3 class="card-title mb-0 me-2 text-break" data-api-request-title>Request - {{name}}</h3>
|
||||
<div class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-api-request-remove {{#if inUse}}data-request-protected disabled title="This request is used by slides"{{else if id}}{{#unless (hasPermission currentUser 'api-sources.delete')}}data-request-protected disabled{{/unless}} title="Delete request"{{else}}title="Delete request"{{/if}} aria-label="Delete request"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-api-request-remove {{#if currentUser.isViewingAs}}data-request-protected disabled title="Unavailable in read-only preview"{{else if inUse}}data-request-protected disabled title="This request is used by slides"{{else if id}}{{#unless (hasPermission currentUser 'api-sources.delete')}}data-request-protected disabled{{/unless}} title="Delete request"{{else}}title="Delete request"{{/if}} aria-label="Delete request"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -35,8 +35,8 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-outline-primary" data-api-source-test-url="{{#if groupId}}/data-sources/api-sources/{{groupId}}/test{{else}}/data-sources/api-sources/test{{/if}}"><i class="bi bi-plug me-1" aria-hidden="true"></i>Test Connection</button>
|
||||
{{#if id}}{{#if (hasPermission currentUser 'api-sources.allow')}}<button type="button" class="btn btn-outline-secondary" data-manual-refresh-url="/data-sources/api-sources/{{groupId}}/requests/{{id}}/refresh" data-manual-refresh-target="#api-request-response-{{id}}" data-source-toggle-owner="api-source-toggle-form" data-source-item-enabled="{{#if enabled}}true{{else}}false{{/if}}" {{#unless enabled}}disabled{{else}}{{#unless source_enabled}}disabled{{/unless}}{{/unless}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}
|
||||
<button type="button" class="btn btn-outline-primary" data-api-source-test-url="{{#if groupId}}/data-sources/api-sources/{{groupId}}/test{{else}}/data-sources/api-sources/test{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-plug me-1" aria-hidden="true"></i>Test Connection</button>
|
||||
{{#if id}}{{#if (hasAssignedPermission currentUser 'api-sources.allow')}}<button type="button" class="btn btn-outline-secondary" data-manual-refresh-url="/data-sources/api-sources/{{groupId}}/requests/{{id}}/refresh" data-manual-refresh-target="#api-request-response-{{id}}" data-source-toggle-owner="api-source-toggle-form" data-source-item-enabled="{{#if enabled}}true{{else}}false{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{else}}{{#unless enabled}}disabled{{else}}{{#unless source_enabled}}disabled{{/unless}}{{/unless}}{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/inline}}
|
||||
@@ -77,9 +77,9 @@
|
||||
</div>
|
||||
<div class="card-footer d-flex align-items-center justify-content-between row-gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 me-2">
|
||||
<button type="button" class="btn btn-outline-primary" data-api-auth-test-url="{{#if isEdit}}/data-sources/api-sources/{{apiSource.id}}/auth-test{{else}}/data-sources/api-sources/auth-test{{/if}}"><i class="bi bi-shield-check me-1" aria-hidden="true"></i>Test Authentication</button>
|
||||
{{#if isEdit}}{{#if (hasPermission currentUser 'api-sources.update')}}<button type="submit" form="api-source-toggle-form" data-async-data-source-toggle data-enabled="{{#if apiSource.enabled}}true{{else}}false{{/if}}" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}}{{/if}}
|
||||
<button type="button" class="btn btn-primary" data-api-request-add><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add request</button>
|
||||
<button type="button" class="btn btn-outline-primary" data-api-auth-test-url="{{#if isEdit}}/data-sources/api-sources/{{apiSource.id}}/auth-test{{else}}/data-sources/api-sources/auth-test{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-shield-check me-1" aria-hidden="true"></i>Test Authentication</button>
|
||||
{{#if isEdit}}{{#if (hasPermission currentUser 'api-sources.update')}}<button type="submit" form="api-source-toggle-form" data-async-data-source-toggle data-enabled="{{#if apiSource.enabled}}true{{else}}false{{/if}}" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}}{{/if}}
|
||||
<button type="button" class="btn btn-primary" data-api-request-add {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add request</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-end flex-wrap ms-auto">
|
||||
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" data-async-data-source-toggle data-enabled="{{#if rssFeed.enabled}}true{{else}}false{{/if}}" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" data-async-data-source-toggle data-enabled="{{#if rssFeed.enabled}}true{{else}}false{{/if}}" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasAssignedPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="row g-3"><div class="col-md-3"><label for="weather-provider" class="form-label">Provider</label><select id="weather-provider" name="provider" class="form-select"><option value="open-meteo" {{#if (eq weatherLocation.provider 'open-meteo')}}selected{{/if}}>Open-Meteo</option><option value="pirate-weather" {{#if (eq weatherLocation.provider 'pirate-weather')}}selected{{/if}} {{#unless providerAvailability.pirateWeather}}disabled{{/unless}}>Pirate Weather{{#unless providerAvailability.pirateWeather}} (API key not configured){{/unless}}</option></select></div><div class="col-md-3"><label for="weather-temperature-unit" class="form-label">Temperature</label><select id="weather-temperature-unit" name="temperature_unit" class="form-select"><option value="celsius" {{#if (eq weatherLocation.temperature_unit 'celsius')}}selected{{/if}}>Celsius</option><option value="fahrenheit" {{#if (eq weatherLocation.temperature_unit 'fahrenheit')}}selected{{/if}}>Fahrenheit</option></select></div><div class="col-md-3"><label for="weather-wind-unit" class="form-label">Wind</label><select id="weather-wind-unit" name="wind_unit" class="form-select"><option value="kmh" {{#if (eq weatherLocation.wind_unit 'kmh')}}selected{{/if}}>km/h</option><option value="mph" {{#if (eq weatherLocation.wind_unit 'mph')}}selected{{/if}}>mph</option><option value="ms" {{#if (eq weatherLocation.wind_unit 'ms')}}selected{{/if}}>m/s</option></select></div><div class="col-md-3"><label for="weather-precipitation-unit" class="form-label">Precipitation</label><select id="weather-precipitation-unit" name="precipitation_unit" class="form-select"><option value="mm" {{#if (eq weatherLocation.precipitation_unit 'mm')}}selected{{/if}}>Millimetres</option><option value="inch" {{#if (eq weatherLocation.precipitation_unit 'inch')}}selected{{/if}}>Inches</option></select></div></div>
|
||||
<div class="row g-3 mt-1"><div class="col-md-6"><label for="weather-interval" class="form-label">Update interval</label><input id="weather-interval" name="update_interval_value" type="number" min="1" max="1440" class="form-control" value="{{weatherLocation.update_interval_value}}" required /></div><div class="col-md-6"><label for="weather-interval-unit" class="form-label">Unit</label><select id="weather-interval-unit" name="update_interval_unit" class="form-select"><option value="minutes" {{#if (eq weatherLocation.update_interval_unit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq weatherLocation.update_interval_unit 'hours')}}selected{{/if}}>Hours</option></select></div></div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" data-async-data-source-toggle data-enabled="{{#if weatherLocation.enabled}}true{{else}}false{{/if}}" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" data-async-data-source-toggle data-enabled="{{#if weatherLocation.enabled}}true{{else}}false{{/if}}" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasAssignedPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if isEdit}}<form id="weather-location-toggle-form" method="post" action="/data-sources/weather/{{weatherLocation.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-primary" href="/dashboard"><i class="bi bi-speedometer2 me-1" aria-hidden="true"></i>Open dashboard</a>
|
||||
<a class="btn btn-outline-secondary" href="{{repositoryUrl}}" target="_blank" rel="noreferrer"><i class="bi bi-github me-1" aria-hidden="true"></i>Repository</a>
|
||||
<a class="btn btn-outline-primary" href="/settings/changelog"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Changelog</a>
|
||||
<a class="btn btn-outline-secondary" href="{{repositoryUrl}}" target="_blank" rel="noreferrer"><i class="bi bi-git me-1" aria-hidden="true"></i>Repository</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="changelog-eyebrow">Release history</div>
|
||||
<h2>Changelog</h2>
|
||||
<p>Updates and fixes across Pulse Signage releases.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="changelog-feed">
|
||||
{{#each entries}}
|
||||
<details class="changelog-release" aria-labelledby="release-{{@index}}" {{#if @first}}open{{/if}}>
|
||||
<summary class="changelog-release-summary">
|
||||
<span class="changelog-release-indicator" aria-hidden="true"></span>
|
||||
<span class="changelog-release-heading" id="release-{{@index}}">
|
||||
<span class="changelog-version">v{{version}}</span>
|
||||
{{#if @first}}<span class="changelog-latest">Latest release</span>{{/if}}
|
||||
</span>
|
||||
<time class="changelog-release-date" datetime="{{date}}">{{date}}</time>
|
||||
<i class="bi bi-chevron-down changelog-release-chevron" aria-hidden="true"></i>
|
||||
</summary>
|
||||
<div class="changelog-release-content">
|
||||
{{#each sections}}
|
||||
<section class="changelog-category">
|
||||
<h3 class="changelog-category-title">{{title}}</h3>
|
||||
<ul class="changelog-items">
|
||||
{{#each items}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</section>
|
||||
{{/each}}
|
||||
</div>
|
||||
</details>
|
||||
{{/each}}
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@
|
||||
<td class="text-center">
|
||||
{{#if this}}
|
||||
<label class="d-inline-flex align-items-center justify-content-center w-100 m-0 p-0">
|
||||
<input class="form-check-input m-0" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" {{#if isSelected}}checked{{/if}} />
|
||||
<input class="form-check-input m-0" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" title="{{description}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="visually-hidden">{{actionLabel}} for {{../title}}</span>
|
||||
</label>
|
||||
{{else}}
|
||||
|
||||
@@ -47,8 +47,15 @@
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="{{#if isEdit}}Profile actions{{else}}User actions{{/if}}">
|
||||
<div class="card-footer d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
{{#if isEdit}}
|
||||
{{#if (hasPermission currentUser 'impersonate.allow')}}
|
||||
{{#unless currentUser.isViewingAs}}
|
||||
<button type="submit" form="view-as-user-form" class="btn btn-sm btn-outline-secondary"><i class="bi bi-person-badge me-1" aria-hidden="true"></i>View as</button>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<div class="btn-group ms-auto" role="group" aria-label="{{#if isEdit}}Profile actions{{else}}User actions{{/if}}">
|
||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteConfirmMessage=footerDeleteConfirmMessage deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions showDelete=footerShowDelete}}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,6 +117,11 @@
|
||||
</form>
|
||||
|
||||
{{#if isEdit}}
|
||||
{{#if (hasPermission currentUser 'impersonate.allow')}}
|
||||
{{#unless currentUser.isViewingAs}}
|
||||
<form id="view-as-user-form" method="post" action="/settings/users/{{user.id}}/view-as" data-confirm-message="View as {{user.username}}? This preview is read-only and ends after 15 minutes."></form>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
<div class="card card-outline card-secondary admin-form-card mt-3">
|
||||
<div class="card-header bg-body-tertiary d-flex align-items-center justify-content-between">
|
||||
<h3 class="card-title">Active sessions</h3>
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}"{{#if authShell}} data-bs-theme="dark" style="color-scheme: dark;"{{else if errorShell}} data-bs-theme="dark" style="color-scheme: dark;"{{/if}}>
|
||||
{{#if errorShell}}
|
||||
<main class="d-flex align-items-center justify-content-center min-vh-100 p-3 {{bodyClass}}">
|
||||
{{{body}}}
|
||||
<div class="w-100">
|
||||
{{> view-as-banner currentUser=currentUser}}
|
||||
{{{body}}}
|
||||
</div>
|
||||
</main>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
<script src="/assets/js/system-status.js"></script>
|
||||
@@ -397,6 +400,7 @@
|
||||
</aside>
|
||||
|
||||
<main class="app-main">
|
||||
{{> view-as-banner currentUser=currentUser}}
|
||||
<div class="app-content">
|
||||
<div class="container-fluid py-3">
|
||||
{{{body}}}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{{#if currentUser.isViewingAs}}
|
||||
<div class="view-as-banner" role="status" aria-live="polite">
|
||||
<div class="view-as-banner-copy">
|
||||
<i class="bi bi-eye-fill" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>Read-only preview</strong>
|
||||
<span>Viewing as {{#if currentUser.name}}{{currentUser.name}}{{else}}{{currentUser.username}}{{/if}} ({{currentUser.username}}), signed in as {{#if currentUser.authenticatedUser.name}}{{currentUser.authenticatedUser.name}}{{else}}{{currentUser.authenticatedUser.username}}{{/if}}.</span>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/settings/view-as/stop">
|
||||
<button type="submit" class="btn btn-sm btn-dark"><i class="bi bi-box-arrow-left me-1" aria-hidden="true"></i>Exit preview</button>
|
||||
</form>
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{#if (hasPermission currentUser "dashboard.allow")}}
|
||||
{{#if (hasPermission currentUser "clients.allow")}}
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-8 mb-4">
|
||||
<div class="card card-outline card-primary dashboard-actions-card">
|
||||
|
||||
@@ -7,6 +7,8 @@ const packageJson = require('../package.json');
|
||||
const pages = require('../src/web/pages');
|
||||
const registerAboutRoutes = require('../src/web/routes/settings/about/routes');
|
||||
const renderAboutPage = require('../src/web/routes/settings/about');
|
||||
const registerChangelogRoutes = require('../src/web/routes/settings/changelog/routes');
|
||||
const renderChangelogPage = require('../src/web/routes/settings/changelog');
|
||||
|
||||
test('about page renders the footer link beside the version and lists bundled libraries', () => {
|
||||
const html = pages.renderAboutPage({ id: 1 });
|
||||
@@ -27,6 +29,10 @@ test('about page renders the footer link beside the version and lists bundled li
|
||||
assert.match(html, new RegExp('v' + packageJson.version.split('.').join('\\.')));
|
||||
assert.match(html, /About Pulse/);
|
||||
assert.match(html, /href="\/settings\/about"/);
|
||||
assert.match(html, /href="\/settings\/changelog"/);
|
||||
assert.match(html, />Changelog<\/a>/);
|
||||
assert.match(html, /bi-git me-1/);
|
||||
assert.doesNotMatch(html, /bi-github/);
|
||||
libraries.forEach(function (library) {
|
||||
assert.ok(library.version, `${library.name} version is missing`);
|
||||
assert.match(html, new RegExp(`>${library.version}<`));
|
||||
@@ -63,3 +69,47 @@ test('about route sends the about page', async () => {
|
||||
|
||||
assert.equal(response.body, 'about-page');
|
||||
});
|
||||
|
||||
test('changelog page renders release notes from the project changelog', () => {
|
||||
const html = pages.renderChangelogPage({ id: 1 });
|
||||
const parsed = renderChangelogPage.parseChangelog('## 1.2.3 - 2026-01-02\n\n### Added\n\n- New feature');
|
||||
|
||||
assert.ok(parsed.length > 0);
|
||||
assert.equal(parsed[0].version, '1.2.3');
|
||||
assert.equal(parsed[0].sections[0].items[0], 'New feature');
|
||||
assert.match(html, /Changelog/);
|
||||
assert.match(html, new RegExp(packageJson.version.replaceAll('.', '\\.')));
|
||||
assert.match(html, /Updates and fixes across Pulse Signage releases/);
|
||||
assert.match(html, /<details class="changelog-release"[^>]*open/);
|
||||
assert.match(html, /Latest release/);
|
||||
assert.match(html, /API source Enable\/Disable action/);
|
||||
});
|
||||
|
||||
test('changelog route sends the changelog page', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
registerChangelogRoutes(app, {
|
||||
pages: {
|
||||
renderChangelogPage(currentUser) {
|
||||
return currentUser && currentUser.id === 7 ? 'changelog-page' : 'unexpected';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const response = {
|
||||
body: null,
|
||||
send(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await handlers['/settings/changelog'][0]({ currentUser: { id: 7 } }, response, function () {});
|
||||
|
||||
assert.equal(response.body, 'changelog-page');
|
||||
});
|
||||
@@ -4,8 +4,69 @@ const assert = require('node:assert/strict');
|
||||
require('../src/common');
|
||||
|
||||
const registerAccountRoutes = require('../src/web/routes/admin/account');
|
||||
const pages = require('../src/web/pages');
|
||||
const { validatePasswordStrength } = require('../src/auth');
|
||||
|
||||
test('required-password page shows only password controls and uses an inline warning', () => {
|
||||
const html = pages.renderRequiredPasswordPage({ id: 5, username: 'demo' }, {
|
||||
minimumLength: 12,
|
||||
minimumCategories: 3,
|
||||
requireLowercase: false,
|
||||
requireUppercase: false,
|
||||
requireNumber: false,
|
||||
requireSymbol: false
|
||||
}, 'Current password is incorrect.');
|
||||
|
||||
assert.match(html, /Password change required/);
|
||||
assert.match(html, /Please change your password before continuing\./);
|
||||
assert.match(html, /Current password is incorrect\./);
|
||||
assert.match(html, /name="current_password"/);
|
||||
assert.match(html, /name="new_password"/);
|
||||
assert.match(html, /name="confirm_password"/);
|
||||
assert.doesNotMatch(html, /account-profile-form|name="email"|Active sessions/);
|
||||
assert.doesNotMatch(html, /id="app-toast"/);
|
||||
});
|
||||
|
||||
test('required-password route renders the password-only page and redirects after the requirement clears', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
|
||||
post() {}
|
||||
};
|
||||
const deps = {
|
||||
pool: { async query() { return [[]]; } },
|
||||
common: {},
|
||||
pages: {
|
||||
renderRequiredPasswordPage(user, requirements) {
|
||||
return user.username + ':' + requirements.minimumLength;
|
||||
}
|
||||
},
|
||||
formatDashboardDate: () => '',
|
||||
getAuditUserId: () => 5,
|
||||
verifyPassword: () => true,
|
||||
validatePasswordStrength: validatePasswordStrength,
|
||||
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1 }),
|
||||
createUserSession: async () => 'token',
|
||||
setSessionCookie: () => {}
|
||||
};
|
||||
registerAccountRoutes(app, deps);
|
||||
|
||||
const forcedResponse = {
|
||||
send(value) { this.body = value; },
|
||||
redirect(url) { this.redirectedTo = url; }
|
||||
};
|
||||
await handlers['/account/password'][0]({ currentUser: { id: 5, username: 'demo', mustChangePassword: true } }, forcedResponse, (error) => { throw error; });
|
||||
|
||||
const regularResponse = {
|
||||
send(value) { this.body = value; },
|
||||
redirect(url) { this.redirectedTo = url; }
|
||||
};
|
||||
await handlers['/account/password'][0]({ currentUser: { id: 5, username: 'demo', mustChangePassword: false } }, regularResponse, (error) => { throw error; });
|
||||
|
||||
assert.equal(forcedResponse.body, 'demo:10');
|
||||
assert.equal(regularResponse.redirectedTo, '/account');
|
||||
});
|
||||
|
||||
test('account password update redirects back to the account page', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
|
||||
@@ -97,9 +97,9 @@ test('2.16.2 adds an enabled master switch without rewriting individual requests
|
||||
return [[]];
|
||||
} };
|
||||
const pending = await getPendingMigrations(pool, { currentVersion: '2.16.1' });
|
||||
assert.equal(pending.length, 1);
|
||||
assert.equal(pending[0].version, '2.16.2');
|
||||
await pending[0].run(pool);
|
||||
const migration = pending.find(value => value.version === '2.16.2');
|
||||
assert.ok(migration);
|
||||
await migration.run(pool);
|
||||
assert.ok(calls.some(sql => sql.includes('ALTER TABLE i_api_source_groups ADD COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1')));
|
||||
assert.ok(!calls.some(sql => sql.includes('UPDATE i_api_sources')));
|
||||
});
|
||||
|
||||
@@ -209,11 +209,16 @@ test('API source add and edit forms render connection diagnostics with the corre
|
||||
const user = { permissionKeys: ['api-sources.create', 'api-sources.update'] };
|
||||
const add = renderAdd({}, '', user);
|
||||
const edit = renderEdit({ id: 42, name: 'Campaign' }, {}, '', user);
|
||||
const preview = renderEdit({ id: 42, name: 'Campaign' }, {}, '', {
|
||||
isViewingAs: true,
|
||||
permissionKeys: ['api-sources.update', 'api-sources.allow', 'api-sources.delete']
|
||||
});
|
||||
assert.match(add, /<p>Configure shared authentication and the requests for this API source\.<\/p>/);
|
||||
assert.match(edit, /<p>Manage shared authentication, request URLs, and latest responses\.<\/p>/);
|
||||
assert.match(add, /data-api-source-test-url="\/data-sources\/api-sources\/test"/);
|
||||
assert.match(edit, /data-api-source-test-url="\/data-sources\/api-sources\/42\/test"/);
|
||||
for (const html of [add, edit]) {
|
||||
assert.match(html, /<form id="api-source-form" method="post" action="[^"]+" data-async-save/);
|
||||
assert.match(html, /data-api-source-diagnostics hidden/);
|
||||
assert.match(html, /data-api-source-copy-report disabled/);
|
||||
assert.match(html, /data-api-source-test-status role="status"/);
|
||||
@@ -237,6 +242,12 @@ test('API source add and edit forms render connection diagnostics with the corre
|
||||
assert.doesNotMatch(html, /data-api-requests-header/);
|
||||
assert.match(html, /data-api-request-title>Request - /);
|
||||
}
|
||||
assert.match(preview, /data-manual-refresh-url="\/data-sources\/api-sources\/42\/requests\/42\/refresh"[^>]*disabled/);
|
||||
assert.match(preview, /data-api-source-test-url="\/data-sources\/api-sources\/42\/test"[^>]*disabled/);
|
||||
assert.match(preview, /data-api-auth-test-url="\/data-sources\/api-sources\/42\/auth-test"[^>]*disabled/);
|
||||
assert.match(preview, /data-async-data-source-toggle[^>]*disabled/);
|
||||
assert.match(preview, /data-api-request-add disabled/);
|
||||
assert.match(preview, /data-api-request-remove data-request-protected disabled/);
|
||||
});
|
||||
|
||||
test('saved requests show visible full-width response trees with independent expand controls', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
|
||||
require('../src/common');
|
||||
|
||||
const registerAuthRoutes = require('../src/web/routes/auth');
|
||||
const pages = require('../src/web/pages');
|
||||
const renderLoginPage = require('../src/web/routes/auth/login');
|
||||
const { createSessionService, normalizeReturnToPath } = require('../src/web/lib/auth/session');
|
||||
|
||||
@@ -45,6 +46,149 @@ test('requireAuth preserves the original request path when redirecting to login'
|
||||
assert.equal(res.redirectedTo, '/login?returnTo=%2Fslides%2F6%2Fedit%3Ftab%3Dcontent');
|
||||
});
|
||||
|
||||
test('session loader keeps the administrator identity while loading preview-user permissions', async () => {
|
||||
const service = createSessionService({
|
||||
sessionCookieName: 'pulse_session',
|
||||
sessionMaxAgeMs: 60 * 60 * 1000,
|
||||
hashSessionToken: (value) => 'hash:' + value,
|
||||
createSessionToken: () => 'token'
|
||||
});
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
if (sql.includes('FROM a_sessions s')) {
|
||||
return [[{
|
||||
user_id: 7,
|
||||
id: 7,
|
||||
name: 'Administrator',
|
||||
username: 'admin',
|
||||
view_as_user_id: 19,
|
||||
view_as_expires_at: new Date(Date.now() + 60 * 1000),
|
||||
preview_id: 19,
|
||||
preview_name: 'Screen operator',
|
||||
preview_username: 'operator',
|
||||
preview_email: 'operator@example.com',
|
||||
preview_must_change_password: 0
|
||||
}]];
|
||||
}
|
||||
if (sql.includes('SELECT r.role_key')) {
|
||||
return [[{ role_key: Number(params[0]) === 7 ? 'super-admin' : 'operator' }]];
|
||||
}
|
||||
if (sql.includes('SELECT p.permission_key')) {
|
||||
return [[
|
||||
{ permission_key: Number(params[0]) === 7 ? 'impersonate.allow' : 'screens.read' }
|
||||
]];
|
||||
}
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
};
|
||||
|
||||
const currentUser = await service.loadCurrentUser(pool, {
|
||||
headers: { cookie: 'pulse_session=token' }
|
||||
});
|
||||
|
||||
assert.equal(currentUser.id, 19);
|
||||
assert.equal(currentUser.username, 'operator');
|
||||
assert.deepEqual(currentUser.permissionKeys, ['screens.read']);
|
||||
assert.equal(currentUser.isViewingAs, true);
|
||||
assert.equal(currentUser.authenticatedUser.id, 7);
|
||||
assert.deepEqual(currentUser.authenticatedUser.permissionKeys, ['impersonate.allow']);
|
||||
});
|
||||
|
||||
test('session loader exits preview when the administrator loses the preview permission', async () => {
|
||||
const queries = [];
|
||||
const service = createSessionService({
|
||||
sessionCookieName: 'pulse_session',
|
||||
sessionMaxAgeMs: 60 * 60 * 1000,
|
||||
hashSessionToken: (value) => 'hash:' + value,
|
||||
createSessionToken: () => 'token'
|
||||
});
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
queries.push([sql, params]);
|
||||
if (sql.includes('FROM a_sessions s')) {
|
||||
return [[{
|
||||
user_id: 7,
|
||||
id: 7,
|
||||
name: 'Administrator',
|
||||
username: 'admin',
|
||||
view_as_user_id: 19,
|
||||
view_as_expires_at: new Date(Date.now() + 60 * 1000),
|
||||
preview_id: 19,
|
||||
preview_name: 'Screen operator',
|
||||
preview_username: 'operator',
|
||||
preview_must_change_password: 0
|
||||
}]];
|
||||
}
|
||||
if (sql.includes('SELECT r.role_key')) return [[{ role_key: 'users-viewer' }]];
|
||||
if (sql.includes('SELECT p.permission_key')) return [[{ permission_key: 'users.read' }]];
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
};
|
||||
|
||||
const currentUser = await service.loadCurrentUser(pool, {
|
||||
headers: { cookie: 'pulse_session=token' }
|
||||
});
|
||||
|
||||
assert.equal(currentUser.id, 7);
|
||||
assert.equal(currentUser.isViewingAs, undefined);
|
||||
assert.ok(queries.some(function ([sql]) {
|
||||
return sql.includes('SET view_as_user_id = NULL, view_as_expires_at = NULL');
|
||||
}));
|
||||
});
|
||||
|
||||
test('view-as session helpers bind changes to the authenticated session', async () => {
|
||||
const queries = [];
|
||||
const service = createSessionService({
|
||||
sessionCookieName: 'pulse_session',
|
||||
sessionMaxAgeMs: 60 * 60 * 1000,
|
||||
hashSessionToken: (value) => 'hash:' + value,
|
||||
createSessionToken: () => 'token'
|
||||
});
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
queries.push([sql, params]);
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
};
|
||||
const req = { headers: { cookie: 'pulse_session=token' } };
|
||||
|
||||
assert.equal(await service.startViewAsUser(pool, req, 7, 19), true);
|
||||
assert.equal(await service.stopViewAsUser(pool, req, 7), true);
|
||||
assert.deepEqual(queries[0][1], [19, 7, 'hash:token', 7]);
|
||||
assert.deepEqual(queries[1][1], [7, 'hash:token', 7]);
|
||||
});
|
||||
|
||||
test('logout audit records the authenticated administrator during a preview', async () => {
|
||||
const handlers = {};
|
||||
let auditEvent = null;
|
||||
const app = {
|
||||
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
|
||||
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
|
||||
};
|
||||
registerAuthRoutes(app, {
|
||||
pool: { async query() { return [[]]; } },
|
||||
parseCookies() { return { pulse_session: 'session-token' }; },
|
||||
sessionCookieName: 'pulse_session',
|
||||
hashSessionToken(value) { return 'hash:' + value; },
|
||||
clearSessionCookie() {},
|
||||
setAuthMessageCookie() {},
|
||||
recordRequestAuditEvent: async (_pool, _req, event) => { auditEvent = event; }
|
||||
});
|
||||
const res = {
|
||||
redirect(path) { this.redirectedTo = path; }
|
||||
};
|
||||
|
||||
await handlers['GET /logout'][0]({
|
||||
headers: { cookie: 'pulse_session=session-token' },
|
||||
currentUser: { id: 19, username: 'operator', isViewingAs: true },
|
||||
authenticatedUser: { id: 7, username: 'admin' }
|
||||
}, res, (error) => { throw error; });
|
||||
|
||||
assert.equal(auditEvent.actorUserId, 7);
|
||||
assert.equal(auditEvent.targetId, 7);
|
||||
assert.equal(res.redirectedTo, '/login');
|
||||
});
|
||||
|
||||
test('normalizeReturnToPath keeps same-origin absolute urls and strips the origin', () => {
|
||||
assert.equal(
|
||||
normalizeReturnToPath('https://signage.example.com/slides/6/edit?tab=content', 'https://signage.example.com'),
|
||||
@@ -56,6 +200,37 @@ test('normalizeReturnToPath keeps same-origin absolute urls and strips the origi
|
||||
);
|
||||
});
|
||||
|
||||
test('root route sends users to their first permitted page or the no-access fallback', () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
|
||||
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
|
||||
};
|
||||
registerAuthRoutes(app, {});
|
||||
|
||||
[
|
||||
[{ permissionKeys: ['dashboard.read', 'clients.read'] }, '/dashboard'],
|
||||
[{ permissionKeys: ['clients.read'] }, '/clients'],
|
||||
[{ permissionKeys: [] }, '/no-access']
|
||||
].forEach(function ([currentUser, expectedPath]) {
|
||||
const response = { redirect(path) { this.redirectedTo = path; } };
|
||||
handlers['GET /'][0]({ currentUser }, response);
|
||||
assert.equal(response.redirectedTo, expectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
test('no-access page explains the missing permissions and offers account and sign-out links', () => {
|
||||
const html = pages.renderNoAccessPage({ id: 7, username: 'operator' });
|
||||
|
||||
assert.match(html, /No application access/);
|
||||
assert.match(html, /no application sections have been assigned/);
|
||||
assert.match(html, /href="\/account"/);
|
||||
assert.match(html, /href="\/logout"/);
|
||||
assert.match(html, /class="no-access-page"/);
|
||||
assert.match(html, /class="no-access-panel"/);
|
||||
assert.match(html, /class="btn btn-primary" href="\/logout"/);
|
||||
});
|
||||
|
||||
test('successful login returns to the requested link instead of the dashboard', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
@@ -157,6 +332,7 @@ test('successful login returns to the requested link instead of the dashboard',
|
||||
await handlers['POST /login'][0](rememberedRequest, rememberedResponse, () => {});
|
||||
|
||||
assert.equal(rememberedResponse.sessionMaxAgeMs, 7 * 24 * 60 * 60 * 1000);
|
||||
assert.equal(rememberedResponse.redirectedTo, '/');
|
||||
});
|
||||
|
||||
test('accept invite page renders a valid invitation with password requirements', async () => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const formTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/rbac/form.hbs'), 'utf8');
|
||||
const { buildPermissionSections } = require('../src/web/routes/settings/rbac/form-view-model');
|
||||
|
||||
test('permission checkboxes expose their descriptions as hover titles', () => {
|
||||
const sections = buildPermissionSections([{
|
||||
categoryName: 'Settings',
|
||||
title: 'Impersonate',
|
||||
permissions: [{
|
||||
permission_key: 'impersonate.allow',
|
||||
actionKey: 'allow',
|
||||
description: 'Preview a user account in read-only mode.'
|
||||
}]
|
||||
}]);
|
||||
|
||||
assert.match(formTemplate, /name="permission_keys\[\]"[^>]*title="\{\{description\}\}"/);
|
||||
assert.equal(sections[0].groups[0].cells[4].description, 'Preview a user account in read-only mode.');
|
||||
});
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { runMigrations } = require('../src/db/migrations');
|
||||
const { getPendingMigrations, runMigrations } = require('../src/db/migrations');
|
||||
|
||||
function createPool(responses) {
|
||||
const queries = [];
|
||||
@@ -143,3 +143,31 @@ test('timetable timezone migrations use timetable tables when legacy tables are
|
||||
return String(sql).includes('i_schedule_groups') || String(sql).includes('i_schedule_entries');
|
||||
}));
|
||||
});
|
||||
|
||||
test('2.17.0 migration adds the expiring view-as session columns', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql) {
|
||||
return sql.includes('FROM information_schema.COLUMNS');
|
||||
},
|
||||
result: [[{ column_count: 0 }]]
|
||||
},
|
||||
{
|
||||
match(sql) {
|
||||
return sql.includes('FROM information_schema.TABLES');
|
||||
},
|
||||
result: [[{ table_count: 0 }]]
|
||||
}
|
||||
]);
|
||||
|
||||
const pending = await getPendingMigrations(pool, { currentVersion: '2.16.2' });
|
||||
assert.deepEqual(pending.map(function (migration) { return migration.version; }), ['2.17.0']);
|
||||
await pending[0].run(pool);
|
||||
|
||||
assert.ok(pool.queries.some(function ([sql]) {
|
||||
return String(sql).includes('ALTER TABLE a_sessions ADD COLUMN view_as_user_id INT NULL AFTER user_id');
|
||||
}));
|
||||
assert.ok(pool.queries.some(function ([sql]) {
|
||||
return String(sql).includes('ALTER TABLE a_sessions ADD COLUMN view_as_expires_at DATETIME NULL AFTER view_as_user_id');
|
||||
}));
|
||||
});
|
||||
@@ -34,4 +34,5 @@ test('user form keeps username editable in the admin section', () => {
|
||||
assert.ok(usersFormTemplate.includes('name="username"'));
|
||||
assert.ok(!usersFormTemplate.includes('{{#if isEdit}}disabled{{else}}required{{/if}}'));
|
||||
assert.ok(usersFormTemplate.includes('required />'));
|
||||
assert.match(usersFormTemplate, /class="btn-group ms-auto" role="group" aria-label=/);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const pages = require('../src/web/pages');
|
||||
const registerViewAsRoutes = require('../src/web/routes/settings/view-as');
|
||||
const renderUsersPage = require('../src/web/routes/settings/users/list');
|
||||
const renderUsersEditPage = require('../src/web/routes/settings/users/edit');
|
||||
const renderRssFeedEditPage = require('../src/web/routes/data-sources/rss-feeds/edit');
|
||||
const renderWeatherLocationEditPage = require('../src/web/routes/data-sources/weather/edit');
|
||||
const { hasAssignedPermission, hasPermission, PERMISSIONS } = require('../src/rbac');
|
||||
|
||||
test('preview permission is Impersonate > Allow and read-only execution stays denied', () => {
|
||||
const permission = PERMISSIONS.find(function (item) { return item.key === 'impersonate.allow'; });
|
||||
assert.ok(permission);
|
||||
assert.equal(permission.resourceName, 'Impersonate');
|
||||
assert.equal(permission.actionName, 'Allow');
|
||||
assert.ok(!PERMISSIONS.some(function (item) {
|
||||
return item.key === 'users.allow' || item.key === 'users.impersonate' || item.key === 'dashboard.allow';
|
||||
}));
|
||||
|
||||
const previewUser = { isViewingAs: true, permissionKeys: ['clients.allow'] };
|
||||
assert.equal(hasAssignedPermission(previewUser, 'clients.allow'), true);
|
||||
assert.equal(hasPermission(previewUser, 'clients.allow'), false);
|
||||
assert.equal(hasPermission({ permissionKeys: ['clients.allow'] }, 'clients.allow'), true);
|
||||
});
|
||||
|
||||
test('View as is on the user edit footer, not the Users table', () => {
|
||||
const user = { id: 19, name: 'Screen operator', username: 'operator', roleNames: 'Operators' };
|
||||
const administrator = { id: 7, permissionKeys: ['users.read', 'impersonate.allow'] };
|
||||
const adminPage = renderUsersPage({ users: [user] }, '', administrator);
|
||||
const editPage = renderUsersEditPage(user, '', administrator, [], []);
|
||||
const previewPage = renderUsersPage({ users: [user] }, '', Object.assign({}, administrator, { isViewingAs: true }));
|
||||
const previewEditPage = renderUsersEditPage(user, '', Object.assign({}, administrator, { isViewingAs: true }), [], []);
|
||||
|
||||
assert.doesNotMatch(adminPage, /view-as/);
|
||||
assert.match(editPage, /form="view-as-user-form"/);
|
||||
assert.match(editPage, /<form id="view-as-user-form" method="post" action="\/settings\/users\/19\/view-as"/);
|
||||
assert.ok(editPage.indexOf('View as') < editPage.indexOf('Profile actions'));
|
||||
assert.doesNotMatch(previewPage, /action="\/settings\/users\/19\/view-as"/);
|
||||
assert.doesNotMatch(previewEditPage, /view-as-user-form/);
|
||||
});
|
||||
|
||||
test('view-as banner identifies both users and keeps an exit control on regular and error pages', () => {
|
||||
const previewUser = {
|
||||
id: 19,
|
||||
name: 'Screen operator',
|
||||
username: 'operator',
|
||||
isViewingAs: true,
|
||||
authenticatedUser: { id: 7, name: 'Administrator', username: 'admin' }
|
||||
};
|
||||
|
||||
const regularPage = pages.renderAboutPage(previewUser);
|
||||
const errorPage = pages.renderErrorPage({ statusCode: 403 }, previewUser);
|
||||
|
||||
[regularPage, errorPage].forEach(function (html) {
|
||||
assert.match(html, /Read-only preview/);
|
||||
assert.match(html, /Screen operator/);
|
||||
assert.match(html, /signed in as Administrator/);
|
||||
assert.match(html, /action="\/settings\/view-as\/stop"/);
|
||||
assert.match(html, /class="btn btn-sm btn-dark"/);
|
||||
assert.doesNotMatch(html, /btn-outline-dark/);
|
||||
});
|
||||
});
|
||||
|
||||
test('view-as start and stop routes audit the authenticated administrator', async () => {
|
||||
const handlers = {};
|
||||
const permissionKeys = [];
|
||||
const auditEvents = [];
|
||||
const helperCalls = [];
|
||||
const app = {
|
||||
post(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
const administrator = {
|
||||
id: 7,
|
||||
name: 'Administrator',
|
||||
username: 'admin',
|
||||
permissionKeys: ['users.read', 'impersonate.allow']
|
||||
};
|
||||
|
||||
registerViewAsRoutes(app, {
|
||||
pool: {
|
||||
async query(sql, params) {
|
||||
assert.match(sql, /FROM a_users/);
|
||||
assert.deepEqual(params, [19]);
|
||||
return [[{ id: 19, name: 'Screen operator', username: 'operator' }]];
|
||||
}
|
||||
},
|
||||
requirePermission(permissionKey) {
|
||||
permissionKeys.push(permissionKey);
|
||||
return function (_req, _res, next) { next(); };
|
||||
},
|
||||
async startViewAsUser(_pool, _req, actorId, targetId) {
|
||||
helperCalls.push(['start', actorId, targetId]);
|
||||
return true;
|
||||
},
|
||||
async stopViewAsUser(_pool, _req, actorId) {
|
||||
helperCalls.push(['stop', actorId]);
|
||||
return true;
|
||||
},
|
||||
async recordRequestAuditEvent(_pool, _req, event) {
|
||||
auditEvents.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
const startResponse = {
|
||||
redirect(path) { this.redirectedTo = path; },
|
||||
status(statusCode) { this.statusCode = statusCode; return this; },
|
||||
send(body) { this.body = body; return this; },
|
||||
sendStatus(statusCode) { this.statusCode = statusCode; return this; }
|
||||
};
|
||||
await handlers['/settings/users/:id/view-as'][2]({
|
||||
params: { id: '19' },
|
||||
authenticatedUser: administrator,
|
||||
currentUser: administrator
|
||||
}, startResponse, (error) => { throw error; });
|
||||
|
||||
const stopResponse = {
|
||||
redirect(path) { this.redirectedTo = path; },
|
||||
status(statusCode) { this.statusCode = statusCode; return this; },
|
||||
send(body) { this.body = body; return this; }
|
||||
};
|
||||
await handlers['/settings/view-as/stop'][0]({
|
||||
authenticatedUser: administrator,
|
||||
currentUser: { id: 19, username: 'operator', isViewingAs: true }
|
||||
}, stopResponse, (error) => { throw error; });
|
||||
|
||||
assert.deepEqual(permissionKeys, ['users.read', 'impersonate.allow']);
|
||||
assert.deepEqual(helperCalls, [['start', 7, 19], ['stop', 7]]);
|
||||
assert.deepEqual(auditEvents.map(function (event) { return event.actorUserId; }), [7, 7]);
|
||||
assert.deepEqual(auditEvents.map(function (event) { return event.eventType; }), ['user.view_as_started', 'user.view_as_stopped']);
|
||||
assert.equal(startResponse.redirectedTo, '/dashboard');
|
||||
assert.equal(stopResponse.redirectedTo, '/settings/users');
|
||||
});
|
||||
|
||||
test('RSS and weather refresh actions stay visible but disabled when authorized previewing', () => {
|
||||
const rssPage = renderRssFeedEditPage({ id: 31, name: 'Feed', enabled: 1 }, {}, '', {
|
||||
isViewingAs: true,
|
||||
permissionKeys: ['rss-feeds.update', 'rss-feeds.allow']
|
||||
});
|
||||
const weatherPage = renderWeatherLocationEditPage({ id: 12, name: 'Forecast', enabled: 1 }, '', {
|
||||
isViewingAs: true,
|
||||
permissionKeys: ['weather.update', 'weather.allow']
|
||||
});
|
||||
|
||||
assert.match(rssPage, /data-manual-refresh-url="\/data-sources\/rss-feeds\/31\/refresh"[^>]*disabled/);
|
||||
assert.match(rssPage, /data-async-data-source-toggle[^>]*disabled/);
|
||||
assert.match(weatherPage, /data-weather-refresh-url="\/data-sources\/weather\/12\/refresh"[^>]*disabled/);
|
||||
assert.match(weatherPage, /data-async-data-source-toggle[^>]*disabled/);
|
||||
});
|
||||
@@ -80,7 +80,7 @@ test('dashboard kiosk launcher includes connected player choices', () => {
|
||||
connectedPlayersCount: 1
|
||||
},
|
||||
'',
|
||||
{ id: 1, permissions: ['screens.read', 'clients.read', 'dashboard.allow'] }
|
||||
{ id: 1, permissions: ['screens.read', 'clients.read', 'clients.allow'] }
|
||||
);
|
||||
|
||||
assert.match(html, /dashboard-kiosk-launcher-player-select/);
|
||||
|
||||
@@ -76,6 +76,7 @@ test('screen update keeps the existing slug on edit', async () => {
|
||||
};
|
||||
|
||||
const calls = [];
|
||||
const permissionKeys = [];
|
||||
const pages = { renderScreenFormPage() {}, renderScreenEditPage() {} };
|
||||
const deps = {
|
||||
pool,
|
||||
@@ -132,7 +133,8 @@ test('screen update keeps the existing slug on edit', async () => {
|
||||
return { ok: true };
|
||||
},
|
||||
playerPublicBaseUrl: 'http://player.example',
|
||||
requirePermission() {
|
||||
requirePermission(permissionKey) {
|
||||
permissionKeys.push(permissionKey);
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
@@ -178,6 +180,7 @@ test('dashboard commands fan out to each connected bridge player', async () => {
|
||||
};
|
||||
|
||||
const calls = [];
|
||||
const permissionKeys = [];
|
||||
registerManageRoutes(app, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
@@ -251,7 +254,8 @@ test('dashboard commands fan out to each connected bridge player', async () => {
|
||||
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
|
||||
return { ok: true };
|
||||
},
|
||||
requirePermission() {
|
||||
requirePermission(permissionKey) {
|
||||
permissionKeys.push(permissionKey);
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
@@ -260,6 +264,8 @@ test('dashboard commands fan out to each connected bridge player', async () => {
|
||||
|
||||
const routeHandlers = handlers['/commands'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
assert.ok(permissionKeys.includes('clients.allow'));
|
||||
assert.ok(!permissionKeys.includes('dashboard.allow'));
|
||||
|
||||
const req = {
|
||||
body: { command: 'pause' },
|
||||
|
||||
@@ -42,3 +42,136 @@ test('web middleware bypasses auth for internal player sync routes', () => {
|
||||
assert.deepEqual(nextCalls, ['media', 'font', 'dashboard']);
|
||||
assert.deepEqual(calls, ['/dashboard']);
|
||||
});
|
||||
|
||||
test('view-as blocks writes but allows the stop-preview request', () => {
|
||||
const app = {
|
||||
middlewares: [],
|
||||
use(...args) {
|
||||
this.middlewares.push(args);
|
||||
}
|
||||
};
|
||||
registerMiddleware(app, {
|
||||
pool: {},
|
||||
loadCurrentUser: async () => null,
|
||||
requireAuth(req, _res, next) { next(); },
|
||||
MEDIA_DIR: path.join(process.cwd(), 'media'),
|
||||
UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'),
|
||||
THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'),
|
||||
ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public')
|
||||
});
|
||||
|
||||
const authGate = app.middlewares[app.middlewares.length - 1][0];
|
||||
const errors = [];
|
||||
let stopAllowed = false;
|
||||
['/slides/12', '/api/internal/sync/player-control'].forEach(function (requestPath) {
|
||||
authGate({
|
||||
method: 'POST',
|
||||
path: requestPath,
|
||||
currentUser: { id: 19, isViewingAs: true },
|
||||
authenticatedUser: { id: 7 }
|
||||
}, {}, function (error) {
|
||||
errors.push(error);
|
||||
});
|
||||
});
|
||||
authGate({
|
||||
method: 'POST',
|
||||
path: '/settings/view-as/stop',
|
||||
currentUser: { id: 19, isViewingAs: true },
|
||||
authenticatedUser: { id: 7 }
|
||||
}, {}, function (error) {
|
||||
stopAllowed = !error;
|
||||
});
|
||||
|
||||
assert.equal(errors.length, 2);
|
||||
assert.equal(errors[0].statusCode, 403);
|
||||
assert.equal(stopAllowed, true);
|
||||
});
|
||||
|
||||
test('view-as ignores the preview user password-change requirement and permits exit', () => {
|
||||
const app = {
|
||||
middlewares: [],
|
||||
use(...args) { this.middlewares.push(args); }
|
||||
};
|
||||
registerMiddleware(app, {
|
||||
pool: {},
|
||||
loadCurrentUser: async () => null,
|
||||
requireAuth(_req, _res, next) { next(); },
|
||||
MEDIA_DIR: path.join(process.cwd(), 'media'),
|
||||
UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'),
|
||||
THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'),
|
||||
ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public')
|
||||
});
|
||||
const authGate = app.middlewares[app.middlewares.length - 1][0];
|
||||
let pageAllowed = false;
|
||||
const response = { redirect(path) { this.redirectedTo = path; } };
|
||||
|
||||
authGate({
|
||||
method: 'GET',
|
||||
path: '/screens',
|
||||
currentUser: { id: 19, isViewingAs: true, mustChangePassword: true },
|
||||
authenticatedUser: { id: 7 }
|
||||
}, response, function (error) { assert.ifError(error); pageAllowed = true; });
|
||||
|
||||
assert.equal(pageAllowed, true);
|
||||
assert.equal(response.redirectedTo, undefined);
|
||||
const adminMustChangeResponse = { redirect(path) { this.redirectedTo = path; } };
|
||||
authGate({
|
||||
method: 'GET',
|
||||
path: '/screens',
|
||||
currentUser: { id: 19, isViewingAs: true, mustChangePassword: false },
|
||||
authenticatedUser: { id: 7, mustChangePassword: true }
|
||||
}, adminMustChangeResponse, function (error) { assert.ifError(error); });
|
||||
assert.equal(adminMustChangeResponse.redirectedTo, '/account/password');
|
||||
let stopAllowed = false;
|
||||
authGate({
|
||||
method: 'POST',
|
||||
path: '/settings/view-as/stop',
|
||||
currentUser: { id: 19, isViewingAs: true, mustChangePassword: true },
|
||||
authenticatedUser: { id: 7, mustChangePassword: true }
|
||||
}, {}, function (error) { assert.ifError(error); stopAllowed = true; });
|
||||
assert.equal(stopAllowed, true);
|
||||
});
|
||||
|
||||
test('required-password gate blocks account details and allows only password page and logout', () => {
|
||||
const app = {
|
||||
middlewares: [],
|
||||
use(...args) { this.middlewares.push(args); }
|
||||
};
|
||||
registerMiddleware(app, {
|
||||
pool: {},
|
||||
loadCurrentUser: async () => null,
|
||||
requireAuth(_req, _res, next) { next(); },
|
||||
MEDIA_DIR: path.join(process.cwd(), 'media'),
|
||||
UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'),
|
||||
THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'),
|
||||
ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public')
|
||||
});
|
||||
const authGate = app.middlewares[app.middlewares.length - 1][0];
|
||||
const requests = [
|
||||
{ method: 'GET', path: '/account' },
|
||||
{ method: 'POST', path: '/account/profile' },
|
||||
{ method: 'GET', path: '/screens' }
|
||||
];
|
||||
requests.forEach(function (req) {
|
||||
const response = { redirect(path) { this.redirectedTo = path; } };
|
||||
Object.assign(req, { currentUser: { id: 5, mustChangePassword: true }, authenticatedUser: { id: 5, mustChangePassword: true } });
|
||||
authGate(req, response, function (error) { assert.ifError(error); });
|
||||
assert.equal(response.redirectedTo, '/account/password');
|
||||
});
|
||||
|
||||
const passwordPageResponse = {};
|
||||
authGate({
|
||||
method: 'GET',
|
||||
path: '/account/password',
|
||||
currentUser: { id: 5, mustChangePassword: true },
|
||||
authenticatedUser: { id: 5, mustChangePassword: true }
|
||||
}, passwordPageResponse, function (error) { assert.ifError(error); });
|
||||
|
||||
const logoutResponse = {};
|
||||
authGate({
|
||||
method: 'GET',
|
||||
path: '/logout',
|
||||
currentUser: { id: 5, mustChangePassword: true },
|
||||
authenticatedUser: { id: 5, mustChangePassword: true }
|
||||
}, logoutResponse, function (error) { assert.ifError(error); });
|
||||
});
|
||||
Reference in new issue
Block a user