Release v2.17.0: add read-only user previews and access fallbacks
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m47s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
lzstealth committed 2026-10-23 01:55:33 +01:00
1 parent 11dd801f6d
commit 9c28eca3ef
52 files changed
+1623 -139

No files matched your search

+1
View File
@@ -7,4 +7,5 @@
!src/**
!scripts/
!scripts/**
!CHANGELOG.md
/local-examples/
+17
View File
@@ -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
+1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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": {
+2 -2
View File
@@ -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
View File
@@ -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": {
+2
View File
@@ -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,
+8
View File
@@ -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
View File
@@ -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,
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
});
});
+3
View File
@@ -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')),
+333 -52
View File
@@ -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: []
});
};
+16 -12
View File
@@ -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;
+27 -3
View File
@@ -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);
+1 -1
View File
@@ -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')
+49 -7
View File
@@ -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);
+11
View File
@@ -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' : ''
};
+10
View File
@@ -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));
});
};
+77
View File
@@ -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
View File
@@ -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>
+20
View File
@@ -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>
+1 -1
View File
@@ -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}}
+2 -1
View File
@@ -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>
+1 -1
View File
@@ -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}}
+14 -2
View File
@@ -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>
+5 -1
View File
@@ -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}}}
+14
View File
@@ -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}}
+1 -1
View File
@@ -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">
+50
View File
@@ -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');
});
+61
View File
@@ -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 = {
+3 -3
View File
@@ -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')));
});
+11
View File
@@ -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', () => {
+176
View File
@@ -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 () => {
+23
View File
@@ -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.');
});
+29 -1
View File
@@ -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');
}));
});
+1
View File
@@ -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=/);
});
+153
View File
@@ -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/);
});
+1 -1
View File
@@ -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/);
+8 -2
View File
@@ -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' },
+133
View File
@@ -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); });
});