Release v2.17.0: add read-only user previews and access fallbacks
This commit is contained in:
1 parent
11dd801f6d
commit
9c28eca3ef
52 files changed
+1624
-140
No files matched your search
+11
-1
@@ -71,6 +71,7 @@ function registerUrlHelpers(Handlebars) {
|
||||
function registerPermissionHelpers(Handlebars, deps) {
|
||||
const hasPermission = deps && deps.hasPermission;
|
||||
const hasAnyPermission = deps && deps.hasAnyPermission;
|
||||
const hasAssignedPermission = deps && deps.hasAssignedPermission;
|
||||
|
||||
Handlebars.registerHelper('hasPermission', function (currentUser, permissionKey) {
|
||||
return hasPermission(currentUser, permissionKey);
|
||||
@@ -81,6 +82,10 @@ function registerPermissionHelpers(Handlebars, deps) {
|
||||
args.pop();
|
||||
return hasAnyPermission(currentUser, args);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('hasAssignedPermission', function (currentUser, permissionKey) {
|
||||
return hasAssignedPermission(currentUser, permissionKey);
|
||||
});
|
||||
}
|
||||
|
||||
function registerUserHelpers(Handlebars) {
|
||||
@@ -171,6 +176,7 @@ function registerActionHelpers(Handlebars) {
|
||||
|
||||
// Shared partials are registered once at startup.
|
||||
function registerPartials(Handlebars, viewsRoot) {
|
||||
Handlebars.registerPartial('view-as-banner', fs.readFileSync(path.join(viewsRoot, 'shared', 'view-as-banner.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('media-picker-modal', fs.readFileSync(path.join(viewsRoot, 'shared', 'media-picker-modal.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
|
||||
@@ -192,6 +198,10 @@ function registerHandlebars(Handlebars, deps) {
|
||||
const viewsRoot = String(deps && deps.viewsRoot || '').trim();
|
||||
const hasPermission = deps && deps.hasPermission;
|
||||
const hasAnyPermission = deps && deps.hasAnyPermission;
|
||||
const hasAssignedPermission = deps && deps.hasAssignedPermission || function (currentUser, permissionKey) {
|
||||
if (!currentUser) return false;
|
||||
return hasPermission(Object.assign({}, currentUser, { isViewingAs: false }), permissionKey);
|
||||
};
|
||||
|
||||
if (!Handlebars || !viewsRoot || typeof hasPermission !== 'function' || typeof hasAnyPermission !== 'function') {
|
||||
throw new Error('registerHandlebars requires Handlebars, viewsRoot, hasPermission, and hasAnyPermission.');
|
||||
@@ -199,7 +209,7 @@ function registerHandlebars(Handlebars, deps) {
|
||||
|
||||
registerGeneralHelpers(Handlebars);
|
||||
registerUrlHelpers(Handlebars);
|
||||
registerPermissionHelpers(Handlebars, { hasPermission: hasPermission, hasAnyPermission: hasAnyPermission });
|
||||
registerPermissionHelpers(Handlebars, { hasPermission: hasPermission, hasAnyPermission: hasAnyPermission, hasAssignedPermission: hasAssignedPermission });
|
||||
registerUserHelpers(Handlebars);
|
||||
registerActionHelpers(Handlebars);
|
||||
registerPartials(Handlebars, viewsRoot);
|
||||
|
||||
+108
-28
@@ -1,6 +1,6 @@
|
||||
// Session cookie and current-user helpers for the web auth flow.
|
||||
|
||||
const { normalizePermissionKeys } = require('#src/rbac');
|
||||
const { hasPermission, normalizePermissionKeys } = require('#src/rbac');
|
||||
|
||||
function getRequestOrigin(req) {
|
||||
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||
@@ -128,9 +128,14 @@ function createSessionService(options) {
|
||||
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.user_id, u.id, u.name, u.username, u.email, u.email_verified_at, u.pending_email, u.must_change_password
|
||||
`SELECT s.user_id, s.view_as_user_id, s.view_as_expires_at,
|
||||
u.id, u.name, u.username, u.email, u.email_verified_at, u.pending_email, u.must_change_password,
|
||||
preview.id AS preview_id, preview.name AS preview_name, preview.username AS preview_username,
|
||||
preview.email AS preview_email, preview.email_verified_at AS preview_email_verified_at,
|
||||
preview.pending_email AS preview_pending_email, preview.must_change_password AS preview_must_change_password
|
||||
FROM a_sessions s
|
||||
JOIN a_users u ON u.id = s.user_id
|
||||
LEFT JOIN a_users preview ON preview.id = s.view_as_user_id AND s.view_as_expires_at > NOW()
|
||||
WHERE s.session_hash = ?
|
||||
AND s.expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
@@ -141,34 +146,107 @@ function createSessionService(options) {
|
||||
}
|
||||
|
||||
const userId = Number(rows[0].id);
|
||||
const [roleRows] = await pool.query(
|
||||
`SELECT r.role_key
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY r.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
const [permissionRows] = await pool.query(
|
||||
`SELECT p.permission_key
|
||||
FROM a_user_roles ur
|
||||
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN a_permissions p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY p.section_name ASC, p.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
async function loadAccess(userId) {
|
||||
const [roleRows] = await pool.query(
|
||||
`SELECT r.role_key
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY r.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
const [permissionRows] = await pool.query(
|
||||
`SELECT p.permission_key
|
||||
FROM a_user_roles ur
|
||||
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN a_permissions p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY p.section_name ASC, p.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
return {
|
||||
roleKeys: roleRows.map(function (row) {
|
||||
return String(row.role_key || '').trim();
|
||||
}).filter(Boolean),
|
||||
permissionKeys: normalizePermissionKeys(permissionRows.map(function (row) {
|
||||
return String(row.permission_key || '').trim();
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
const actorAccess = await loadAccess(userId);
|
||||
const authenticatedUser = Object.assign({}, rows[0], {
|
||||
mustChangePassword: Boolean(rows[0].must_change_password),
|
||||
roleKeys: actorAccess.roleKeys,
|
||||
permissionKeys: actorAccess.permissionKeys
|
||||
});
|
||||
const previewUserId = Number(rows[0].preview_id);
|
||||
const canContinuePreview = previewUserId > 0
|
||||
&& previewUserId !== userId
|
||||
&& hasPermission(authenticatedUser, 'impersonate.allow');
|
||||
|
||||
if (Number(rows[0].view_as_user_id) > 0 && !canContinuePreview) {
|
||||
await pool.query(
|
||||
'UPDATE a_sessions SET view_as_user_id = NULL, view_as_expires_at = NULL WHERE session_hash = ? AND user_id = ?',
|
||||
[tokenHash, userId]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
||||
return Object.assign({}, rows[0], {
|
||||
mustChangePassword: Boolean(rows[0].must_change_password),
|
||||
roleKeys: roleRows.map(function (row) {
|
||||
return String(row.role_key || '').trim();
|
||||
}).filter(Boolean),
|
||||
permissionKeys: normalizePermissionKeys(permissionRows.map(function (row) {
|
||||
return String(row.permission_key || '').trim();
|
||||
}))
|
||||
});
|
||||
|
||||
if (!canContinuePreview) {
|
||||
return authenticatedUser;
|
||||
}
|
||||
|
||||
const previewAccess = await loadAccess(previewUserId);
|
||||
return {
|
||||
id: previewUserId,
|
||||
name: rows[0].preview_name,
|
||||
username: rows[0].preview_username,
|
||||
email: rows[0].preview_email,
|
||||
email_verified_at: rows[0].preview_email_verified_at,
|
||||
pending_email: rows[0].preview_pending_email,
|
||||
mustChangePassword: Boolean(rows[0].preview_must_change_password),
|
||||
roleKeys: previewAccess.roleKeys,
|
||||
permissionKeys: previewAccess.permissionKeys,
|
||||
authenticatedUser: authenticatedUser,
|
||||
isViewingAs: true,
|
||||
viewAsExpiresAt: rows[0].view_as_expires_at
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionHash(req) {
|
||||
const cookies = parseCookies(req && req.headers && req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
return token ? hashSessionToken(token) : '';
|
||||
}
|
||||
|
||||
async function startViewAsUser(pool, req, actorUserId, targetUserId) {
|
||||
const sessionHash = getSessionHash(req);
|
||||
if (!sessionHash) {
|
||||
return false;
|
||||
}
|
||||
const [result] = await pool.query(
|
||||
`UPDATE a_sessions
|
||||
SET view_as_user_id = ?, view_as_expires_at = DATE_ADD(NOW(), INTERVAL 15 MINUTE), modified_by = ?
|
||||
WHERE session_hash = ? AND user_id = ? AND expires_at > NOW() AND view_as_user_id IS NULL`,
|
||||
[targetUserId, actorUserId, sessionHash, actorUserId]
|
||||
);
|
||||
return Number(result && result.affectedRows) === 1;
|
||||
}
|
||||
|
||||
async function stopViewAsUser(pool, req, actorUserId) {
|
||||
const sessionHash = getSessionHash(req);
|
||||
if (!sessionHash) {
|
||||
return false;
|
||||
}
|
||||
const [result] = await pool.query(
|
||||
`UPDATE a_sessions
|
||||
SET view_as_user_id = NULL, view_as_expires_at = NULL, modified_by = ?
|
||||
WHERE session_hash = ? AND user_id = ? AND view_as_user_id IS NOT NULL`,
|
||||
[actorUserId, sessionHash, actorUserId]
|
||||
);
|
||||
return Number(result && result.affectedRows) === 1;
|
||||
}
|
||||
|
||||
async function createUserSession(pool, userId, configuredMaxAgeMs, metadata) {
|
||||
@@ -219,6 +297,8 @@ function createSessionService(options) {
|
||||
setAuthMessageCookie: setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
startViewAsUser: startViewAsUser,
|
||||
stopViewAsUser: stopViewAsUser,
|
||||
createUserSession: createUserSession,
|
||||
getSessionMaxAgeMs: getSessionMaxAgeMs,
|
||||
requireAuth: requireAuth,
|
||||
|
||||
+17
-2
@@ -23,6 +23,9 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
app.use(async function (req, _res, next) {
|
||||
try {
|
||||
req.currentUser = await loadCurrentUser(pool, req);
|
||||
req.authenticatedUser = req.currentUser && req.currentUser.authenticatedUser
|
||||
? req.currentUser.authenticatedUser
|
||||
: req.currentUser;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -32,14 +35,26 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
registerErrorPageMiddleware(app, deps.pages);
|
||||
|
||||
app.use(function (req, res, next) {
|
||||
const isViewAsStop = req.method === 'POST' && req.path === '/settings/view-as/stop';
|
||||
if (req.currentUser && req.currentUser.isViewingAs && !['GET', 'HEAD', 'OPTIONS'].includes(String(req.method || '').toUpperCase()) && !isViewAsStop) {
|
||||
const error = new Error('This preview is read-only. Exit View as to make changes.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
return next(error);
|
||||
}
|
||||
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/verify-email' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') {
|
||||
return next();
|
||||
}
|
||||
|
||||
return requireAuth(req, res, function () {
|
||||
if (req.currentUser && req.currentUser.mustChangePassword && req.path !== '/account' && req.path !== '/account/password' && req.path !== '/account/sessions/revoke' && req.path !== '/logout') {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Please change your password before continuing.'));
|
||||
const mustChangePassword = req.currentUser && req.currentUser.isViewingAs
|
||||
? Boolean(req.authenticatedUser && req.authenticatedUser.mustChangePassword)
|
||||
: Boolean(req.authenticatedUser && req.authenticatedUser.mustChangePassword || req.currentUser && req.currentUser.mustChangePassword);
|
||||
if (mustChangePassword && req.path !== '/account/password' && req.path !== '/settings/view-as/stop' && req.path !== '/logout') {
|
||||
return res.redirect('/account/password');
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,14 @@ module.exports = {
|
||||
renderForgotPasswordPage: require(routePath('auth', 'forgot-password')),
|
||||
renderResetPasswordPage: require(routePath('auth', 'reset-password')),
|
||||
renderAcceptInvitePage: require(routePath('auth', 'accept-invite')),
|
||||
renderNoAccessPage: require(routePath('auth', 'no-access')),
|
||||
renderEmailVerifiedPage: require(routePath('auth', 'email-verified')),
|
||||
renderEmailVerificationErrorPage: require(routePath('auth', 'email-verification-error')),
|
||||
renderAccountPage: require(routePath('account', 'password')),
|
||||
renderRequiredPasswordPage: require(routePath('account', 'password-required')),
|
||||
renderSettingsPage: require(routePath('settings', 'index')),
|
||||
renderAboutPage: require(routePath('settings', 'about', 'index')),
|
||||
renderChangelogPage: require(routePath('settings', 'changelog', 'index')),
|
||||
renderUsersPage: require(routePath('settings', 'users', 'list')),
|
||||
renderInvitationsPage: require(routePath('settings', 'invitations', 'list')),
|
||||
renderUsersAddPage: require(routePath('settings', 'users', 'add')),
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/* Error pages */
|
||||
|
||||
.error-page-content {
|
||||
width: 100%;
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
/* Media picker and gallery */
|
||||
|
||||
.media-picker-item {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: .375rem;
|
||||
@@ -59,10 +63,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-gallery-bulk-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.media-gallery-controls {
|
||||
flex: 1 1 auto;
|
||||
@@ -236,17 +236,6 @@
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.media-gallery-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.media-gallery-footer .media-gallery-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-gallery-empty {
|
||||
padding: 3rem 1rem;
|
||||
border: 1px dashed var(--bs-border-color);
|
||||
@@ -259,10 +248,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-gallery-bulk-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-gallery-search {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -287,6 +272,8 @@
|
||||
max-height: min(72vh, 48rem);
|
||||
object-fit: contain;
|
||||
}
|
||||
/* Connection status indicators */
|
||||
|
||||
.status-dot {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -349,6 +336,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Account menu */
|
||||
|
||||
.user-menu-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -399,6 +388,8 @@
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
/* Application shell and sidebar */
|
||||
|
||||
.app-header .navbar-nav {
|
||||
--bs-navbar-nav-link-padding-x: 10px;
|
||||
}
|
||||
@@ -443,6 +434,8 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Application settings */
|
||||
|
||||
.settings-nav-card {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
@@ -474,6 +467,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Cards, authentication and email previews */
|
||||
|
||||
.card {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -579,6 +574,8 @@
|
||||
padding: 0.65rem 1.1rem;
|
||||
}
|
||||
|
||||
/* Shared card headers and search controls */
|
||||
|
||||
.card-header {
|
||||
position: relative;
|
||||
}
|
||||
@@ -604,6 +601,8 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Template animation preview controls */
|
||||
|
||||
.template-preview-card .card-body {
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
@@ -637,6 +636,8 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Audit log change comparisons */
|
||||
|
||||
.audit-change-list {
|
||||
display: grid;
|
||||
padding: 0.08rem 0.3rem;
|
||||
@@ -663,6 +664,8 @@
|
||||
.audit-change-to ins {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
/* Template preview states and permission tables */
|
||||
|
||||
.template-preview-card .btn-group .btn {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -712,6 +715,8 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Announcement icon, type and screen pickers */
|
||||
|
||||
.announcement-icon-picker {
|
||||
position: relative;
|
||||
}
|
||||
@@ -1088,6 +1093,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Background task controls */
|
||||
|
||||
.background-tasks-task-tools {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
@@ -1102,6 +1109,8 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Paginated page layout */
|
||||
|
||||
.table-pagination-page .app-main {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
@@ -1122,6 +1131,8 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Background task page layout */
|
||||
|
||||
.background-tasks-page .app-content .container-fluid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1141,6 +1152,8 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Media library page layout */
|
||||
|
||||
.media-library-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1179,6 +1192,8 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Shared table pagination and responsive search */
|
||||
|
||||
[data-table-pagination-card] {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
@@ -1291,6 +1306,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Device onboarding, pairing PIN and QR scanner */
|
||||
|
||||
.onboarding-pairing-card > .card-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
@@ -1377,6 +1394,8 @@
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Timetable entry tables */
|
||||
|
||||
.timetable-entries-table-shell {
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: calc(var(--bs-border-radius) - 1px);
|
||||
@@ -1409,19 +1428,7 @@
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.api-source-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
padding-bottom: 0.35rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Dashboard and connected screen controls */
|
||||
|
||||
.dashboard-hero {
|
||||
overflow: hidden;
|
||||
@@ -2018,6 +2025,8 @@
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Shared page headers, tables and text utilities */
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -2041,6 +2050,248 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.no-access-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: max(28rem, calc(100dvh - 8rem));
|
||||
}
|
||||
|
||||
.no-access-panel {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
place-items: center;
|
||||
padding: 1.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.no-access-card {
|
||||
width: min(100%, 36rem);
|
||||
}
|
||||
|
||||
.view-as-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1020;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 1.25rem;
|
||||
border-bottom: 1px solid var(--bs-warning-border-subtle);
|
||||
background: var(--bs-warning-bg-subtle);
|
||||
color: var(--bs-warning-text-emphasis);
|
||||
}
|
||||
|
||||
.view-as-banner-copy {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.view-as-banner-copy > i {
|
||||
margin-top: 0.15rem;
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
.view-as-banner-copy > div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.view-as-banner-copy span {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.view-as-banner {
|
||||
align-items: flex-start;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.view-as-banner .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.changelog-eyebrow {
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.changelog-feed {
|
||||
max-width: 76rem;
|
||||
margin: 1rem auto 0 0.35rem;
|
||||
border-left: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.changelog-release {
|
||||
position: relative;
|
||||
margin-left: 1rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.changelog-release-summary {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.85rem 1rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.changelog-release-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog-release-summary:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.changelog-release-summary:focus-visible {
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.changelog-release-indicator {
|
||||
position: absolute;
|
||||
top: 1.55rem;
|
||||
left: -1.36rem;
|
||||
width: 0.7rem;
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid var(--bs-secondary-color);
|
||||
border-radius: 50%;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.changelog-release[open] .changelog-release-indicator {
|
||||
border-color: var(--bs-primary);
|
||||
background: var(--bs-primary);
|
||||
box-shadow: 0 0 0 3px var(--bs-body-bg), 0 0 0 4px var(--bs-primary);
|
||||
}
|
||||
|
||||
.changelog-release-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.changelog-version {
|
||||
color: var(--bs-emphasis-color);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.changelog-latest {
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.changelog-release-date {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.changelog-release-chevron {
|
||||
color: var(--bs-secondary-color);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.changelog-release[open] .changelog-release-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.changelog-release-content {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 1rem 1.35rem;
|
||||
}
|
||||
|
||||
.changelog-category {
|
||||
display: grid;
|
||||
grid-template-columns: 7rem minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.changelog-category-title {
|
||||
margin: 0.1rem 0 0;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.changelog-items {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.changelog-items li {
|
||||
position: relative;
|
||||
padding-left: 1rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.changelog-items li::before {
|
||||
position: absolute;
|
||||
top: 0.62em;
|
||||
left: 0;
|
||||
width: 0.35rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.changelog-feed {
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
|
||||
.changelog-release-summary {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
column-gap: 0.65rem;
|
||||
row-gap: 0.15rem;
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.changelog-release-date {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.changelog-release-chevron {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.changelog-release-content {
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.changelog-category {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card.card-outline > .card-header {
|
||||
align-items: center;
|
||||
min-height: 3.25rem;
|
||||
@@ -2088,11 +2339,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip[data-time-date-placeholder-chip] {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
@@ -2136,6 +2382,8 @@ td[data-label="Slides"] {
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide editor layout */
|
||||
|
||||
.slide-editor-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
|
||||
@@ -2177,6 +2425,8 @@ td[data-label="Slides"] {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* TinyMCE integration, including library-generated markup */
|
||||
|
||||
.slide-form-stack .editor-holder,
|
||||
.template-field-card .editor-holder {
|
||||
position: relative;
|
||||
@@ -2351,6 +2601,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
}
|
||||
|
||||
|
||||
/* Slide preview and layout / animation dialogs */
|
||||
|
||||
.slide-preview-dimensions-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2510,6 +2762,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
}
|
||||
}
|
||||
|
||||
/* Template snapping and shared alignment guides */
|
||||
|
||||
.template-designer-main-header::after {
|
||||
display: none;
|
||||
}
|
||||
@@ -2593,6 +2847,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide preview canvas and popup */
|
||||
|
||||
.slide-preview-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -2707,6 +2963,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Image cropping, uploads and media picker dialogs */
|
||||
|
||||
.slide-image-cropper-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -2995,6 +3253,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Template designer canvas, selection and resize handles */
|
||||
|
||||
.template-designer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 25rem;
|
||||
@@ -3129,6 +3389,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
background: rgba(13, 110, 253, 0.08);
|
||||
}
|
||||
|
||||
/* Template designer sidebar and region fields */
|
||||
|
||||
.template-designer-sidebar {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
@@ -3371,6 +3633,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Announcement color picker and list icons */
|
||||
|
||||
.announcement-color-picker {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -3448,6 +3712,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* API region placeholders and sample responses */
|
||||
|
||||
.api-region-placeholder-section {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
@@ -3518,13 +3784,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.template-field-card.is-expanded .tox-edit-area__iframe {
|
||||
min-height: 32rem;
|
||||
}
|
||||
|
||||
.template-field-card.is-expanded .card-body {
|
||||
min-height: 36rem;
|
||||
}
|
||||
/* Region field controls and responsive editor layout */
|
||||
|
||||
.template-field-card .slide-image-file,
|
||||
.region-item .slide-image-file {
|
||||
@@ -3590,6 +3850,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
|
||||
}
|
||||
|
||||
/* Popup frames, toasts and visibility utilities */
|
||||
|
||||
.frame-popup-card-body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -3612,6 +3874,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Playlist slide picker */
|
||||
|
||||
.playlist-slide-picker-modal-body {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -3793,6 +4057,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Playlist scheduling controls and dialog */
|
||||
|
||||
.schedule-day-grid .schedule-day-button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
@@ -3918,6 +4184,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Shared table actions */
|
||||
|
||||
.actions,
|
||||
.playlist-item-actions,
|
||||
.users-row-actions {
|
||||
@@ -3950,6 +4218,8 @@ td[data-label="Actions"] > div {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Playlist duration and playback configuration */
|
||||
|
||||
.playlist-duration-input,
|
||||
.playlist-group-block-quota {
|
||||
width: 4rem !important;
|
||||
@@ -4025,6 +4295,8 @@ table.table > :not(caption) > * > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Slide table content and thumbnails */
|
||||
|
||||
.playlist-slide-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4057,7 +4329,9 @@ table.table > :not(caption) > * > * {
|
||||
}
|
||||
|
||||
.card .table td[data-label="Thumbnail"] {
|
||||
padding-inline: 1.5rem;
|
||||
width: 1%;
|
||||
padding-inline: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td[data-label="Thumbnail"] > .playlist-slide-thumb {
|
||||
@@ -4087,6 +4361,8 @@ td[data-label="Thumbnail"] > .playlist-slide-thumb {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Playlist ordering and groups */
|
||||
|
||||
.playlist-order-cell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
@@ -4129,13 +4405,6 @@ td[data-label="Thumbnail"] > .playlist-slide-thumb {
|
||||
padding-left: 1.5rem !important;
|
||||
}
|
||||
|
||||
.playlist-group-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.playlist-group-block-slide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4192,6 +4461,8 @@ td[data-label="Thumbnail"] > .playlist-slide-thumb {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Playlist drag-and-drop states and previews */
|
||||
|
||||
.playlist-group-block-dragging > td {
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -4335,6 +4606,8 @@ td[data-label="Thumbnail"] > .playlist-slide-thumb {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Table sorting */
|
||||
|
||||
table.table thead th.sortable {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@@ -4350,6 +4623,8 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Account menu and responsive dashboard overrides */
|
||||
|
||||
.navbar-nav>.user-menu>.dropdown-menu>li.user-header {
|
||||
min-height: 100px;
|
||||
}
|
||||
@@ -4379,6 +4654,8 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
}
|
||||
}
|
||||
|
||||
/* Weather previews */
|
||||
|
||||
.weather-preview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4430,6 +4707,8 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
width: 7.5rem;
|
||||
}
|
||||
|
||||
/* Gradient stop editor */
|
||||
|
||||
.gradient-stop-bar {
|
||||
position: relative;
|
||||
height: 2.25rem;
|
||||
@@ -4469,6 +4748,8 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Media selection states and large dialogs */
|
||||
|
||||
.media-picker-item-selected-card .media-picker-item {
|
||||
border-color: var(--bs-success);
|
||||
box-shadow: 0 0 0 .15rem rgba(var(--bs-success-rgb), .2);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Password-only page shown before a user may access the rest of the app.
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { buildPasswordRequirementsText } = require('#src/web/routes/account/password');
|
||||
|
||||
module.exports = function renderRequiredPasswordPage(currentUser, requirements, inlineMessage) {
|
||||
const passwordRequirements = requirements || {
|
||||
minimumLength: 10,
|
||||
minimumCategories: 3,
|
||||
requireLowercase: false,
|
||||
requireUppercase: false,
|
||||
requireNumber: false,
|
||||
requireSymbol: false
|
||||
};
|
||||
|
||||
return renderView('account/password-required', {
|
||||
title: 'Change your password',
|
||||
currentUser: currentUser || null,
|
||||
passwordMinimumLength: passwordRequirements.minimumLength,
|
||||
passwordRequirementsText: buildPasswordRequirementsText(passwordRequirements),
|
||||
inlineMessage: String(inlineMessage || ''),
|
||||
message: '',
|
||||
messageVariant: 'warning',
|
||||
scripts: []
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
function buildPasswordRequirementsText(requirements) {
|
||||
const requiredCategories = [];
|
||||
if (requirements.requireLowercase) requiredCategories.push('lowercase');
|
||||
if (requirements.requireUppercase) requiredCategories.push('uppercase');
|
||||
if (requirements.requireNumber) requiredCategories.push('number');
|
||||
if (requirements.requireSymbol) requiredCategories.push('symbol');
|
||||
return requiredCategories.length
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include ' + requiredCategories.join(', ') + '.'
|
||||
: requirements.minimumCategories === 4
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
|
||||
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
|
||||
}
|
||||
|
||||
function normalizeReturnUrl(value) {
|
||||
const url = String(value || '').trim();
|
||||
if (!url || url === '/account' || url.indexOf('/account?') === 0) {
|
||||
@@ -22,17 +35,6 @@ module.exports = function renderAccountPage(currentUser, message, returnUrl, all
|
||||
requireNumber: false,
|
||||
requireSymbol: false
|
||||
};
|
||||
const requiredCategories = [];
|
||||
if (requirements.requireLowercase) requiredCategories.push('lowercase');
|
||||
if (requirements.requireUppercase) requiredCategories.push('uppercase');
|
||||
if (requirements.requireNumber) requiredCategories.push('number');
|
||||
if (requirements.requireSymbol) requiredCategories.push('symbol');
|
||||
const passwordRequirementsText = requiredCategories.length
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include ' + requiredCategories.join(', ') + '.'
|
||||
: requirements.minimumCategories === 4
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
|
||||
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
|
||||
|
||||
return renderView('account/index', {
|
||||
title: 'My account',
|
||||
active: 'account',
|
||||
@@ -48,7 +50,9 @@ module.exports = function renderAccountPage(currentUser, message, returnUrl, all
|
||||
allowUserSessionRevocation: Boolean(allowUserSessionRevocation),
|
||||
sessions: Array.isArray(sessions) ? sessions : [],
|
||||
passwordMinimumLength: requirements.minimumLength,
|
||||
passwordRequirementsText: passwordRequirementsText,
|
||||
passwordRequirementsText: buildPasswordRequirementsText(requirements),
|
||||
scripts: ['js/account/account-page.js']
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
module.exports.buildPasswordRequirementsText = buildPasswordRequirementsText;
|
||||
@@ -28,6 +28,18 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return buildPasswordRequirements(settings);
|
||||
}
|
||||
|
||||
function isPasswordChangeRequired(req) {
|
||||
return Boolean(req && (req.authenticatedUser && req.authenticatedUser.mustChangePassword || req.currentUser && req.currentUser.mustChangePassword));
|
||||
}
|
||||
|
||||
async function sendPasswordError(req, res, message) {
|
||||
if (!isPasswordChangeRequired(req)) {
|
||||
return res.status(400).send(message);
|
||||
}
|
||||
const requirements = await getPasswordRequirements();
|
||||
return res.status(400).send(pages.renderRequiredPasswordPage(req.currentUser, requirements, message));
|
||||
}
|
||||
|
||||
function buildPasswordRequirements(settings) {
|
||||
return {
|
||||
minimumLength: settings['security.password_min_length'],
|
||||
@@ -102,6 +114,18 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/account/password', async function (req, res, next) {
|
||||
if (!isPasswordChangeRequired(req)) {
|
||||
return res.redirect('/account');
|
||||
}
|
||||
try {
|
||||
const requirements = await getPasswordRequirements();
|
||||
res.send(pages.renderRequiredPasswordPage(req.currentUser, requirements));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/account/profile', async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
@@ -268,14 +292,14 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (!verifyPassword(currentPassword, user)) {
|
||||
return res.status(400).send('Current password is incorrect.');
|
||||
return sendPasswordError(req, res, 'Current password is incorrect.');
|
||||
}
|
||||
const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements());
|
||||
if (passwordStrengthMessage) {
|
||||
return res.status(400).send(passwordStrengthMessage);
|
||||
return sendPasswordError(req, res, passwordStrengthMessage);
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
return res.status(400).send('New passwords do not match.');
|
||||
return sendPasswordError(req, res, 'New passwords do not match.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(newPassword);
|
||||
|
||||
@@ -162,7 +162,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
|
||||
app.post('/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
|
||||
@@ -3,6 +3,40 @@
|
||||
const { normalizeReturnToPath, getRequestOrigin } = require('#src/web/lib/auth/session');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
||||
const { hasPermission } = require('#src/rbac');
|
||||
|
||||
const DEFAULT_LANDING_ROUTES = [
|
||||
['dashboard.read', '/dashboard'],
|
||||
['clients.read', '/clients'],
|
||||
['screens.read', '/screens'],
|
||||
['playlists.read', '/playlists'],
|
||||
['slides.read', '/slides'],
|
||||
['templates.read', '/templates'],
|
||||
['canvas-sizes.read', '/canvas-sizes'],
|
||||
['announcements.read', '/announcements'],
|
||||
['media-library.read', '/media-library'],
|
||||
['api-sources.read', '/data-sources/api-sources'],
|
||||
['rss-feeds.read', '/data-sources/rss-feeds'],
|
||||
['timetables.read', '/data-sources/timetables'],
|
||||
['weather.read', '/data-sources/weather'],
|
||||
['users.read', '/settings/users'],
|
||||
['rbac.read', '/settings/roles'],
|
||||
['invitations.read', '/settings/invitations'],
|
||||
['fonts.read', '/settings/fonts'],
|
||||
['weather-icons.read', '/settings/weather-icons'],
|
||||
['background-tasks.read', '/settings/tasks-background'],
|
||||
['scheduled-tasks.read', '/settings/tasks-scheduled'],
|
||||
['audit-log.read', '/settings/audit-log'],
|
||||
['system-settings.read', '/settings/system'],
|
||||
['pairing.allow', '/pairing']
|
||||
];
|
||||
|
||||
function getDefaultLandingPath(currentUser) {
|
||||
const match = DEFAULT_LANDING_ROUTES.find(function (route) {
|
||||
return hasPermission(currentUser, route[0]);
|
||||
});
|
||||
return match ? match[1] : '/no-access';
|
||||
}
|
||||
|
||||
module.exports = function registerAuthRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -219,13 +253,20 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
});
|
||||
|
||||
app.get('/', function (req, res) {
|
||||
res.redirect(req.currentUser ? '/dashboard' : '/login');
|
||||
res.redirect(req.currentUser ? getDefaultLandingPath(req.currentUser) : '/login');
|
||||
});
|
||||
|
||||
app.get('/no-access', function (req, res) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
res.send(pages.renderNoAccessPage(req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/login', function (req, res) {
|
||||
const returnTo = getReturnTo(req);
|
||||
if (req.currentUser) {
|
||||
return res.redirect(returnTo || '/dashboard');
|
||||
return res.redirect(returnTo || '/');
|
||||
}
|
||||
|
||||
const message = typeof consumeAuthMessageCookie === 'function'
|
||||
@@ -336,7 +377,7 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
details: { rememberMe: rememberMe }
|
||||
});
|
||||
}
|
||||
res.redirect(returnTo || '/dashboard');
|
||||
res.redirect(returnTo || '/');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -349,14 +390,15 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
if (token) {
|
||||
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function' && req.currentUser) {
|
||||
const actor = req.authenticatedUser || req.currentUser;
|
||||
if (typeof recordRequestAuditEvent === 'function' && actor) {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'sessions',
|
||||
eventType: 'session.logout',
|
||||
actorUserId: req.currentUser.id,
|
||||
actorUserId: actor.id,
|
||||
targetType: 'user',
|
||||
targetId: req.currentUser.id,
|
||||
targetLabel: req.currentUser.username
|
||||
targetId: actor.id,
|
||||
targetLabel: actor.username
|
||||
});
|
||||
}
|
||||
clearSessionCookie(res);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Signed-in fallback for accounts with no readable application sections.
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderNoAccessPage(currentUser) {
|
||||
return renderView('auth/no-access', {
|
||||
title: 'No application access',
|
||||
currentUser: currentUser || null,
|
||||
scripts: []
|
||||
});
|
||||
};
|
||||
@@ -73,7 +73,7 @@ function buildApiSourceFormViewModel(apiSource, message, currentUser, options, i
|
||||
showSaveSecondaryActions: true,
|
||||
deleteDisabled: !isEdit || Boolean(viewApiSource.inUse),
|
||||
formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources',
|
||||
formAttrs: '',
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"',
|
||||
cancelUrl: '/data-sources/api-sources',
|
||||
deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : ''
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@ const registerMediaLibraryRoutes = require('#src/web/routes/settings/media-libra
|
||||
const registerSettingsPageRoutes = require('#src/web/routes/settings/routes');
|
||||
const registerAuditLogRoutes = require('#src/web/routes/settings/audit-log');
|
||||
const registerAboutRoutes = require('#src/web/routes/settings/about/routes');
|
||||
const registerChangelogRoutes = require('#src/web/routes/settings/changelog/routes');
|
||||
const registerViewAsRoutes = require('#src/web/routes/settings/view-as');
|
||||
const registerInternalSyncRoutes = require('#src/web/routes/internal/sync');
|
||||
const registerScreensRoutes = require('#src/web/routes/signage/screens/routes');
|
||||
const registerOnboardingRoutes = require('#src/web/routes/onboarding');
|
||||
@@ -52,6 +54,14 @@ function registerRoutes(app, deps) {
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie
|
||||
});
|
||||
registerAboutRoutes(app, deps);
|
||||
registerChangelogRoutes(app, deps);
|
||||
registerViewAsRoutes(app, {
|
||||
pool: deps.pool,
|
||||
requirePermission: deps.requirePermission,
|
||||
startViewAsUser: deps.startViewAsUser,
|
||||
stopViewAsUser: deps.stopViewAsUser,
|
||||
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||
});
|
||||
registerInternalSyncRoutes(app, {
|
||||
pool: deps.pool,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Settings page renderer for the project changelog.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
function parseChangelog(markdown) {
|
||||
const entries = [];
|
||||
let entry = null;
|
||||
let section = null;
|
||||
|
||||
String(markdown || '').split(/\r?\n/).forEach(function (line) {
|
||||
const entryMatch = line.match(/^##\s+(.+?)\s+-\s+(.+?)\s*$/);
|
||||
if (entryMatch) {
|
||||
entry = { version: entryMatch[1], date: entryMatch[2], sections: [] };
|
||||
entries.push(entry);
|
||||
section = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionMatch = line.match(/^###\s+(.+?)\s*$/);
|
||||
if (sectionMatch && entry) {
|
||||
section = { title: sectionMatch[1], items: [] };
|
||||
entry.sections.push(section);
|
||||
return;
|
||||
}
|
||||
|
||||
const itemMatch = line.match(/^-\s+(.+?)\s*$/);
|
||||
if (itemMatch && section) {
|
||||
section.items.push(itemMatch[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (section && section.items.length && /^\s{2,}\S/.test(line)) {
|
||||
const lastItemIndex = section.items.length - 1;
|
||||
section.items[lastItemIndex] += ' ' + line.trim();
|
||||
}
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readChangelog() {
|
||||
const changelogPath = path.join(__dirname, '../../../../..', 'CHANGELOG.md');
|
||||
return parseChangelog(fs.readFileSync(changelogPath, 'utf8'));
|
||||
}
|
||||
|
||||
module.exports = function renderChangelogPage(currentUser) {
|
||||
return renderView('settings/changelog/index', {
|
||||
title: 'Changelog',
|
||||
active: 'changelog',
|
||||
currentUser: currentUser || null,
|
||||
entries: readChangelog(),
|
||||
scripts: []
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.parseChangelog = parseChangelog;
|
||||
@@ -0,0 +1,13 @@
|
||||
// Settings route registration for the project changelog.
|
||||
|
||||
module.exports = function registerChangelogRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
|
||||
if (!pages) {
|
||||
throw new Error('registerChangelogRoutes requires pages.');
|
||||
}
|
||||
|
||||
app.get('/settings/changelog', function (req, res) {
|
||||
res.send(pages.renderChangelogPage(req.currentUser));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// Read-only user preview controls.
|
||||
|
||||
const { hasPermission } = require('#src/rbac');
|
||||
|
||||
module.exports = function registerViewAsRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const startViewAsUser = deps.startViewAsUser;
|
||||
const stopViewAsUser = deps.stopViewAsUser;
|
||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||
|
||||
app.post('/settings/users/:id/view-as', requirePermission('users.read'), requirePermission('impersonate.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const actor = req.authenticatedUser || req.currentUser && req.currentUser.authenticatedUser || req.currentUser;
|
||||
const targetUserId = Number(req.params.id);
|
||||
if (!actor || !hasPermission(actor, 'impersonate.allow') || req.currentUser && req.currentUser.isViewingAs) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
if (!Number.isInteger(targetUserId) || targetUserId <= 0 || targetUserId === Number(actor.id)) {
|
||||
return res.status(400).send('Choose another user to preview.');
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id, name, username FROM a_users WHERE id = ? LIMIT 1', [targetUserId]);
|
||||
if (!rows.length) {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
|
||||
const target = rows[0];
|
||||
const started = await startViewAsUser(pool, req, actor.id, targetUserId);
|
||||
if (!started) {
|
||||
return res.status(409).send('The preview could not be started. Sign in again and retry.');
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'users',
|
||||
eventType: 'user.view_as_started',
|
||||
actorUserId: actor.id,
|
||||
targetType: 'user',
|
||||
targetId: targetUserId,
|
||||
targetLabel: target.username,
|
||||
details: { expiresInMinutes: 15 }
|
||||
});
|
||||
}
|
||||
res.redirect('/dashboard');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/view-as/stop', async function (req, res, next) {
|
||||
try {
|
||||
const actor = req.authenticatedUser || req.currentUser && req.currentUser.authenticatedUser;
|
||||
if (!actor || !req.currentUser || !req.currentUser.isViewingAs) {
|
||||
return res.redirect('/dashboard');
|
||||
}
|
||||
|
||||
const target = req.currentUser;
|
||||
const stopped = await stopViewAsUser(pool, req, actor.id);
|
||||
if (!stopped) {
|
||||
return res.status(409).send('The preview has already ended. Refresh the page to continue.');
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'users',
|
||||
eventType: 'user.view_as_stopped',
|
||||
actorUserId: actor.id,
|
||||
targetType: 'user',
|
||||
targetId: target.id,
|
||||
targetLabel: target.username
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/users');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
+3
-2
@@ -4,7 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Handlebars = require('handlebars');
|
||||
const { version: appVersion } = require('#root/package.json');
|
||||
const { hasPermission, hasAnyPermission } = require('#src/rbac');
|
||||
const { hasPermission, hasAnyPermission, hasAssignedPermission } = require('#src/rbac');
|
||||
const { registerHandlebars } = require('#src/web/handlebars');
|
||||
const { getMessageVariant: inferMessageVariant } = require('#src/web/public/js/toast');
|
||||
|
||||
@@ -15,7 +15,8 @@ const SIGNAGE_VIEW_PREFIXES = new Set(['dashboard', 'clients', 'screens', 'playl
|
||||
registerHandlebars(Handlebars, {
|
||||
viewsRoot: VIEWS_ROOT,
|
||||
hasPermission: hasPermission,
|
||||
hasAnyPermission: hasAnyPermission
|
||||
hasAnyPermission: hasAnyPermission,
|
||||
hasAssignedPermission: hasAssignedPermission
|
||||
});
|
||||
|
||||
function resolveTemplatePath(relativePath) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Change your password</h2>
|
||||
<p>Set a new password to continue to Pulse Signage.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-7 col-xl-6">
|
||||
<div class="alert alert-warning d-flex align-items-start gap-2" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill mt-1" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>Password change required</strong>
|
||||
<div>Please change your password before continuing.</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#if inlineMessage}}
|
||||
<div class="alert alert-danger" role="alert">{{inlineMessage}}</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="card card-outline card-warning admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">New password</h3>
|
||||
</div>
|
||||
<form method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="required-current-password" class="form-label">Current password</label>
|
||||
<input id="required-current-password" type="password" name="current_password" class="form-control" autocomplete="current-password" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="required-new-password" class="form-label">New password</label>
|
||||
<input id="required-new-password" type="password" name="new_password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="required-confirm-password" class="form-label">Confirm new password</label>
|
||||
<input id="required-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="form-text mt-2">{{passwordRequirementsText}}</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-warning">Change password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="no-access-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>No application access</h2>
|
||||
<p>Your account is active, but no application sections have been assigned.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="no-access-panel">
|
||||
<div class="card card-outline card-warning no-access-card">
|
||||
<div class="card-body">
|
||||
<p class="mb-3">Ask an administrator to assign a role with access to the pages you need.</p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-outline-secondary" href="/account"><i class="bi bi-person-gear me-1" aria-hidden="true"></i>My account</a>
|
||||
<a class="btn btn-primary" href="/logout"><i class="bi bi-box-arrow-left me-1" aria-hidden="true"></i>Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="card-header bg-body-tertiary d-flex align-items-center">
|
||||
<h3 class="card-title mb-0 me-2 text-break" data-api-request-title>Request - {{name}}</h3>
|
||||
<div class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-api-request-remove {{#if inUse}}data-request-protected disabled title="This request is used by slides"{{else if id}}{{#unless (hasPermission currentUser 'api-sources.delete')}}data-request-protected disabled{{/unless}} title="Delete request"{{else}}title="Delete request"{{/if}} aria-label="Delete request"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-api-request-remove {{#if currentUser.isViewingAs}}data-request-protected disabled title="Unavailable in read-only preview"{{else if inUse}}data-request-protected disabled title="This request is used by slides"{{else if id}}{{#unless (hasPermission currentUser 'api-sources.delete')}}data-request-protected disabled{{/unless}} title="Delete request"{{else}}title="Delete request"{{/if}} aria-label="Delete request"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -35,8 +35,8 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-outline-primary" data-api-source-test-url="{{#if groupId}}/data-sources/api-sources/{{groupId}}/test{{else}}/data-sources/api-sources/test{{/if}}"><i class="bi bi-plug me-1" aria-hidden="true"></i>Test Connection</button>
|
||||
{{#if id}}{{#if (hasPermission currentUser 'api-sources.allow')}}<button type="button" class="btn btn-outline-secondary" data-manual-refresh-url="/data-sources/api-sources/{{groupId}}/requests/{{id}}/refresh" data-manual-refresh-target="#api-request-response-{{id}}" data-source-toggle-owner="api-source-toggle-form" data-source-item-enabled="{{#if enabled}}true{{else}}false{{/if}}" {{#unless enabled}}disabled{{else}}{{#unless source_enabled}}disabled{{/unless}}{{/unless}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}
|
||||
<button type="button" class="btn btn-outline-primary" data-api-source-test-url="{{#if groupId}}/data-sources/api-sources/{{groupId}}/test{{else}}/data-sources/api-sources/test{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-plug me-1" aria-hidden="true"></i>Test Connection</button>
|
||||
{{#if id}}{{#if (hasAssignedPermission currentUser 'api-sources.allow')}}<button type="button" class="btn btn-outline-secondary" data-manual-refresh-url="/data-sources/api-sources/{{groupId}}/requests/{{id}}/refresh" data-manual-refresh-target="#api-request-response-{{id}}" data-source-toggle-owner="api-source-toggle-form" data-source-item-enabled="{{#if enabled}}true{{else}}false{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{else}}{{#unless enabled}}disabled{{else}}{{#unless source_enabled}}disabled{{/unless}}{{/unless}}{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/inline}}
|
||||
@@ -77,9 +77,9 @@
|
||||
</div>
|
||||
<div class="card-footer d-flex align-items-center justify-content-between row-gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 me-2">
|
||||
<button type="button" class="btn btn-outline-primary" data-api-auth-test-url="{{#if isEdit}}/data-sources/api-sources/{{apiSource.id}}/auth-test{{else}}/data-sources/api-sources/auth-test{{/if}}"><i class="bi bi-shield-check me-1" aria-hidden="true"></i>Test Authentication</button>
|
||||
{{#if isEdit}}{{#if (hasPermission currentUser 'api-sources.update')}}<button type="submit" form="api-source-toggle-form" data-async-data-source-toggle data-enabled="{{#if apiSource.enabled}}true{{else}}false{{/if}}" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}}{{/if}}
|
||||
<button type="button" class="btn btn-primary" data-api-request-add><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add request</button>
|
||||
<button type="button" class="btn btn-outline-primary" data-api-auth-test-url="{{#if isEdit}}/data-sources/api-sources/{{apiSource.id}}/auth-test{{else}}/data-sources/api-sources/auth-test{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-shield-check me-1" aria-hidden="true"></i>Test Authentication</button>
|
||||
{{#if isEdit}}{{#if (hasPermission currentUser 'api-sources.update')}}<button type="submit" form="api-source-toggle-form" data-async-data-source-toggle data-enabled="{{#if apiSource.enabled}}true{{else}}false{{/if}}" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}}{{/if}}
|
||||
<button type="button" class="btn btn-primary" data-api-request-add {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add request</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-end flex-wrap ms-auto">
|
||||
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" data-async-data-source-toggle data-enabled="{{#if rssFeed.enabled}}true{{else}}false{{/if}}" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" data-async-data-source-toggle data-enabled="{{#if rssFeed.enabled}}true{{else}}false{{/if}}" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasAssignedPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="row g-3"><div class="col-md-3"><label for="weather-provider" class="form-label">Provider</label><select id="weather-provider" name="provider" class="form-select"><option value="open-meteo" {{#if (eq weatherLocation.provider 'open-meteo')}}selected{{/if}}>Open-Meteo</option><option value="pirate-weather" {{#if (eq weatherLocation.provider 'pirate-weather')}}selected{{/if}} {{#unless providerAvailability.pirateWeather}}disabled{{/unless}}>Pirate Weather{{#unless providerAvailability.pirateWeather}} (API key not configured){{/unless}}</option></select></div><div class="col-md-3"><label for="weather-temperature-unit" class="form-label">Temperature</label><select id="weather-temperature-unit" name="temperature_unit" class="form-select"><option value="celsius" {{#if (eq weatherLocation.temperature_unit 'celsius')}}selected{{/if}}>Celsius</option><option value="fahrenheit" {{#if (eq weatherLocation.temperature_unit 'fahrenheit')}}selected{{/if}}>Fahrenheit</option></select></div><div class="col-md-3"><label for="weather-wind-unit" class="form-label">Wind</label><select id="weather-wind-unit" name="wind_unit" class="form-select"><option value="kmh" {{#if (eq weatherLocation.wind_unit 'kmh')}}selected{{/if}}>km/h</option><option value="mph" {{#if (eq weatherLocation.wind_unit 'mph')}}selected{{/if}}>mph</option><option value="ms" {{#if (eq weatherLocation.wind_unit 'ms')}}selected{{/if}}>m/s</option></select></div><div class="col-md-3"><label for="weather-precipitation-unit" class="form-label">Precipitation</label><select id="weather-precipitation-unit" name="precipitation_unit" class="form-select"><option value="mm" {{#if (eq weatherLocation.precipitation_unit 'mm')}}selected{{/if}}>Millimetres</option><option value="inch" {{#if (eq weatherLocation.precipitation_unit 'inch')}}selected{{/if}}>Inches</option></select></div></div>
|
||||
<div class="row g-3 mt-1"><div class="col-md-6"><label for="weather-interval" class="form-label">Update interval</label><input id="weather-interval" name="update_interval_value" type="number" min="1" max="1440" class="form-control" value="{{weatherLocation.update_interval_value}}" required /></div><div class="col-md-6"><label for="weather-interval-unit" class="form-label">Unit</label><select id="weather-interval-unit" name="update_interval_unit" class="form-select"><option value="minutes" {{#if (eq weatherLocation.update_interval_unit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq weatherLocation.update_interval_unit 'hours')}}selected{{/if}}>Hours</option></select></div></div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" data-async-data-source-toggle data-enabled="{{#if weatherLocation.enabled}}true{{else}}false{{/if}}" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" data-async-data-source-toggle data-enabled="{{#if weatherLocation.enabled}}true{{else}}false{{/if}}" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasAssignedPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if isEdit}}<form id="weather-location-toggle-form" method="post" action="/data-sources/weather/{{weatherLocation.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-primary" href="/dashboard"><i class="bi bi-speedometer2 me-1" aria-hidden="true"></i>Open dashboard</a>
|
||||
<a class="btn btn-outline-secondary" href="{{repositoryUrl}}" target="_blank" rel="noreferrer"><i class="bi bi-github me-1" aria-hidden="true"></i>Repository</a>
|
||||
<a class="btn btn-outline-primary" href="/settings/changelog"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Changelog</a>
|
||||
<a class="btn btn-outline-secondary" href="{{repositoryUrl}}" target="_blank" rel="noreferrer"><i class="bi bi-git me-1" aria-hidden="true"></i>Repository</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="changelog-eyebrow">Release history</div>
|
||||
<h2>Changelog</h2>
|
||||
<p>Updates and fixes across Pulse Signage releases.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="changelog-feed">
|
||||
{{#each entries}}
|
||||
<details class="changelog-release" aria-labelledby="release-{{@index}}" {{#if @first}}open{{/if}}>
|
||||
<summary class="changelog-release-summary">
|
||||
<span class="changelog-release-indicator" aria-hidden="true"></span>
|
||||
<span class="changelog-release-heading" id="release-{{@index}}">
|
||||
<span class="changelog-version">v{{version}}</span>
|
||||
{{#if @first}}<span class="changelog-latest">Latest release</span>{{/if}}
|
||||
</span>
|
||||
<time class="changelog-release-date" datetime="{{date}}">{{date}}</time>
|
||||
<i class="bi bi-chevron-down changelog-release-chevron" aria-hidden="true"></i>
|
||||
</summary>
|
||||
<div class="changelog-release-content">
|
||||
{{#each sections}}
|
||||
<section class="changelog-category">
|
||||
<h3 class="changelog-category-title">{{title}}</h3>
|
||||
<ul class="changelog-items">
|
||||
{{#each items}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</section>
|
||||
{{/each}}
|
||||
</div>
|
||||
</details>
|
||||
{{/each}}
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@
|
||||
<td class="text-center">
|
||||
{{#if this}}
|
||||
<label class="d-inline-flex align-items-center justify-content-center w-100 m-0 p-0">
|
||||
<input class="form-check-input m-0" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" {{#if isSelected}}checked{{/if}} />
|
||||
<input class="form-check-input m-0" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" title="{{description}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="visually-hidden">{{actionLabel}} for {{../title}}</span>
|
||||
</label>
|
||||
{{else}}
|
||||
|
||||
@@ -47,8 +47,15 @@
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="{{#if isEdit}}Profile actions{{else}}User actions{{/if}}">
|
||||
<div class="card-footer d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
{{#if isEdit}}
|
||||
{{#if (hasPermission currentUser 'impersonate.allow')}}
|
||||
{{#unless currentUser.isViewingAs}}
|
||||
<button type="submit" form="view-as-user-form" class="btn btn-sm btn-outline-secondary"><i class="bi bi-person-badge me-1" aria-hidden="true"></i>View as</button>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<div class="btn-group ms-auto" role="group" aria-label="{{#if isEdit}}Profile actions{{else}}User actions{{/if}}">
|
||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteConfirmMessage=footerDeleteConfirmMessage deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions showDelete=footerShowDelete}}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,6 +117,11 @@
|
||||
</form>
|
||||
|
||||
{{#if isEdit}}
|
||||
{{#if (hasPermission currentUser 'impersonate.allow')}}
|
||||
{{#unless currentUser.isViewingAs}}
|
||||
<form id="view-as-user-form" method="post" action="/settings/users/{{user.id}}/view-as" data-confirm-message="View as {{user.username}}? This preview is read-only and ends after 15 minutes."></form>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
<div class="card card-outline card-secondary admin-form-card mt-3">
|
||||
<div class="card-header bg-body-tertiary d-flex align-items-center justify-content-between">
|
||||
<h3 class="card-title">Active sessions</h3>
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}"{{#if authShell}} data-bs-theme="dark" style="color-scheme: dark;"{{else if errorShell}} data-bs-theme="dark" style="color-scheme: dark;"{{/if}}>
|
||||
{{#if errorShell}}
|
||||
<main class="d-flex align-items-center justify-content-center min-vh-100 p-3 {{bodyClass}}">
|
||||
{{{body}}}
|
||||
<div class="w-100">
|
||||
{{> view-as-banner currentUser=currentUser}}
|
||||
{{{body}}}
|
||||
</div>
|
||||
</main>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
<script src="/assets/js/system-status.js"></script>
|
||||
@@ -397,6 +400,7 @@
|
||||
</aside>
|
||||
|
||||
<main class="app-main">
|
||||
{{> view-as-banner currentUser=currentUser}}
|
||||
<div class="app-content">
|
||||
<div class="container-fluid py-3">
|
||||
{{{body}}}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{{#if currentUser.isViewingAs}}
|
||||
<div class="view-as-banner" role="status" aria-live="polite">
|
||||
<div class="view-as-banner-copy">
|
||||
<i class="bi bi-eye-fill" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>Read-only preview</strong>
|
||||
<span>Viewing as {{#if currentUser.name}}{{currentUser.name}}{{else}}{{currentUser.username}}{{/if}} ({{currentUser.username}}), signed in as {{#if currentUser.authenticatedUser.name}}{{currentUser.authenticatedUser.name}}{{else}}{{currentUser.authenticatedUser.username}}{{/if}}.</span>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/settings/view-as/stop">
|
||||
<button type="submit" class="btn btn-sm btn-dark"><i class="bi bi-box-arrow-left me-1" aria-hidden="true"></i>Exit preview</button>
|
||||
</form>
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{#if (hasPermission currentUser "dashboard.allow")}}
|
||||
{{#if (hasPermission currentUser "clients.allow")}}
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-8 mb-4">
|
||||
<div class="card card-outline card-primary dashboard-actions-card">
|
||||
|
||||
Reference in new issue
Block a user