diff --git a/.dockerignore b/.dockerignore index cf8ce7f..bcaa732 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,5 @@ !src/** !scripts/ !scripts/** +!CHANGELOG.md /local-examples/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f06a38..81f3482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/build/Dockerfile b/build/Dockerfile index 268081e..e7b8707 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -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 diff --git a/build/package.player.json b/build/package.player.json index 6284fa9..d839679 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -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": { diff --git a/build/package.web.json b/build/package.web.json index 04942b3..cf2c350 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -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": { diff --git a/package-lock.json b/package-lock.json index ff17dcc..a5daa86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 057ba47..bd7c65f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/db/index.js b/src/db/index.js index 42452a2..71e5827 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -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, diff --git a/src/db/migrations.js b/src/db/migrations.js index f133bd8..f86d594 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -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'); + } } ]; diff --git a/src/rbac.js b/src/rbac.js index 305a2c7..3963d40 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -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, diff --git a/src/web.js b/src/web.js index 4029422..b47590a 100644 --- a/src/web.js +++ b/src/web.js @@ -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, diff --git a/src/web/handlebars.js b/src/web/handlebars.js index 430c955..30ce29d 100644 --- a/src/web/handlebars.js +++ b/src/web/handlebars.js @@ -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); diff --git a/src/web/lib/auth/session.js b/src/web/lib/auth/session.js index 47ccbc9..fa1db51 100644 --- a/src/web/lib/auth/session.js +++ b/src/web/lib/auth/session.js @@ -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, diff --git a/src/web/middleware.js b/src/web/middleware.js index eb4f6e3..d9dd95f 100644 --- a/src/web/middleware.js +++ b/src/web/middleware.js @@ -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(); }); }); diff --git a/src/web/pages/index.js b/src/web/pages/index.js index 18d39b0..1259f62 100644 --- a/src/web/pages/index.js +++ b/src/web/pages/index.js @@ -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')), diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 054b949..5649843 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -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); diff --git a/src/web/routes/account/password-required.js b/src/web/routes/account/password-required.js new file mode 100644 index 0000000..64485e7 --- /dev/null +++ b/src/web/routes/account/password-required.js @@ -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: [] + }); +}; \ No newline at end of file diff --git a/src/web/routes/account/password.js b/src/web/routes/account/password.js index 7eafb5b..41ea422 100644 --- a/src/web/routes/account/password.js +++ b/src/web/routes/account/password.js @@ -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'] }); -}; \ No newline at end of file +}; + +module.exports.buildPasswordRequirementsText = buildPasswordRequirementsText; \ No newline at end of file diff --git a/src/web/routes/admin/account.js b/src/web/routes/admin/account.js index 1a16ea5..3145a81 100644 --- a/src/web/routes/admin/account.js +++ b/src/web/routes/admin/account.js @@ -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); diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js index f5b02be..ed7e213 100644 --- a/src/web/routes/admin/manage.js +++ b/src/web/routes/admin/manage.js @@ -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') diff --git a/src/web/routes/auth/index.js b/src/web/routes/auth/index.js index 8eb6896..062a3c7 100644 --- a/src/web/routes/auth/index.js +++ b/src/web/routes/auth/index.js @@ -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); diff --git a/src/web/routes/auth/no-access.js b/src/web/routes/auth/no-access.js new file mode 100644 index 0000000..121de46 --- /dev/null +++ b/src/web/routes/auth/no-access.js @@ -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: [] + }); +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/api-sources/form-view-model.js b/src/web/routes/data-sources/api-sources/form-view-model.js index 3877dc0..a0cb99e 100644 --- a/src/web/routes/data-sources/api-sources/form-view-model.js +++ b/src/web/routes/data-sources/api-sources/form-view-model.js @@ -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' : '' }; diff --git a/src/web/routes/register.js b/src/web/routes/register.js index 9f1c7cd..a388d60 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -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, diff --git a/src/web/routes/settings/changelog/index.js b/src/web/routes/settings/changelog/index.js new file mode 100644 index 0000000..72c3b23 --- /dev/null +++ b/src/web/routes/settings/changelog/index.js @@ -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; \ No newline at end of file diff --git a/src/web/routes/settings/changelog/routes.js b/src/web/routes/settings/changelog/routes.js new file mode 100644 index 0000000..bb5e0e1 --- /dev/null +++ b/src/web/routes/settings/changelog/routes.js @@ -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)); + }); +}; \ No newline at end of file diff --git a/src/web/routes/settings/view-as.js b/src/web/routes/settings/view-as.js new file mode 100644 index 0000000..c91adb1 --- /dev/null +++ b/src/web/routes/settings/view-as.js @@ -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); + } + }); +}; \ No newline at end of file diff --git a/src/web/view.js b/src/web/view.js index 99d1dfa..572fcd6 100644 --- a/src/web/view.js +++ b/src/web/view.js @@ -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) { diff --git a/src/web/views/account/password-required.hbs b/src/web/views/account/password-required.hbs new file mode 100644 index 0000000..92fd03b --- /dev/null +++ b/src/web/views/account/password-required.hbs @@ -0,0 +1,47 @@ +
Set a new password to continue to Pulse Signage.
+Your account is active, but no application sections have been assigned.
+Ask an administrator to assign a role with access to the pages you need.
+Updates and fixes across Pulse Signage releases.
+