release: v2.11.2

This commit is contained in:
2026-09-05 18:14:38 +01:00
parent 7fec2154e0
commit 47fd6e7e66
40 changed files with 314 additions and 352 deletions
+9
View File
@@ -2,6 +2,15 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.11.2 - 2026-09-05
### Changed
- Refreshed the vendored AdminLTE assets to 4.9.1, including the print-layout fixes and extended palette updates.
- Added independently configurable verification, password-reset, and invitation email expiry periods, with an `[[expiry_time]]` template variable.
- Updated default account email templates with inline formatting, action buttons, and links, and improved rendering when both button and URL placeholders are used.
- Shortened user-agent labels across audit logs and account session lists, and removed red/green change highlighting from audit entries.
## 2.11.1 - 2026-09-02 ## 2.11.1 - 2026-09-02
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.11.1", "version": "2.11.2",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.11.1", "version": "2.11.2",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.11.1", "version": "2.11.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.11.1", "version": "2.11.2",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.11.1", "version": "2.11.2",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+12 -4
View File
@@ -1,10 +1,17 @@
const DEFAULT_ACCOUNT_EMAIL_TEMPLATES = { const DEFAULT_ACCOUNT_EMAIL_TEMPLATES = {
verificationSubject: 'Verify your Pulse Signage email address', verificationSubject: 'Verify your Pulse Signage email address',
verificationBody: 'Hello [[display_name]]!\n\nConfirm this email address:\n\n[[action_button]]\n\nThis link expires in 30 minutes.', verificationBody: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nConfirm this email address: [[url]]',
resetSubject: 'Reset your Pulse Signage password', resetSubject: 'Reset your Pulse Signage password',
resetBody: 'Hello [[display_name]]!\n\nUse this link to choose a new password:\n\n[[action_button]]\n\nThis link expires in 30 minutes.' resetBody: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nChoose a new password: [[url]]'
}; };
function formatAccountEmailExpiry(value, unit) {
const amount = Number(value);
const normalizedAmount = Number.isFinite(amount) && amount > 0 ? Math.round(amount) : 30;
const normalizedUnit = unit === 'hours' ? 'hour' : 'minute';
return normalizedAmount + ' ' + normalizedUnit + (normalizedAmount === 1 ? '' : 's');
}
function renderAccountEmailTemplate(subject, body, variables) { function renderAccountEmailTemplate(subject, body, variables) {
const values = variables || {}; const values = variables || {};
const replaceVariables = function (value) { const replaceVariables = function (value) {
@@ -22,10 +29,11 @@ function renderAccountEmailTemplate(subject, body, variables) {
const actionButton = actionUrl ? '<a href="' + actionUrl + '" style="display:inline-block;background:#111827;color:#ffffff;padding:12px 22px;text-decoration:none;border-radius:4px;font-weight:600;">' + actionLabel + '</a>' : ''; const actionButton = actionUrl ? '<a href="' + actionUrl + '" style="display:inline-block;background:#111827;color:#ffffff;padding:12px 22px;text-decoration:none;border-radius:4px;font-weight:600;">' + actionLabel + '</a>' : '';
const actionBlock = actionButton ? '<div style="margin:24px 0;text-align:' + actionAlignment + ';">' + actionButton + '</div>' : ''; const actionBlock = actionButton ? '<div style="margin:24px 0;text-align:' + actionAlignment + ';">' + actionButton + '</div>' : '';
const plainLink = actionUrl ? '<a href="' + actionUrl + '">' + actionUrl + '</a>' : ''; const plainLink = actionUrl ? '<a href="' + actionUrl + '">' + actionUrl + '</a>' : '';
const escapedBodyUrl = values.url ? String(values.url).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') : '';
const hasButtonPlaceholder = escapedBody.indexOf('[[action_button]]') !== -1; const hasButtonPlaceholder = escapedBody.indexOf('[[action_button]]') !== -1;
const hasUrlPlaceholder = String(body || '').indexOf('[[url]]') !== -1; const hasUrlPlaceholder = String(body || '').indexOf('[[url]]') !== -1;
const bodyWithAction = hasButtonPlaceholder const bodyWithAction = hasButtonPlaceholder
? escapedBody.replace(/\[\[action_button\]\]/g, actionBlock) ? escapedBody.split(escapedBodyUrl).join(plainLink).replace(/\[\[action_button\]\]/g, actionBlock).replace(/\[\[action_link\]\]/g, plainLink)
: hasUrlPlaceholder : hasUrlPlaceholder
? escapedBody.split(actionUrl).join(actionBlock + plainLink) ? escapedBody.split(actionUrl).join(actionBlock + plainLink)
: escapedBody.replace(/\[\[action_link\]\]/g, plainLink).replace(actionUrl, plainLink); : escapedBody.replace(/\[\[action_link\]\]/g, plainLink).replace(actionUrl, plainLink);
@@ -43,4 +51,4 @@ function renderAccountEmailTemplate(subject, body, variables) {
return { subject: renderedSubject, text: plainText, html: html }; return { subject: renderedSubject, text: plainText, html: html };
} }
module.exports = { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, renderAccountEmailTemplate }; module.exports = { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, formatAccountEmailExpiry, renderAccountEmailTemplate };
+6 -3
View File
@@ -30,16 +30,19 @@ const SETTING_DEFINITIONS = [
{ key: 'email.smtp_password', type: 'string', defaultValue: '' }, { key: 'email.smtp_password', type: 'string', defaultValue: '' },
{ key: 'email.from_address', type: 'string', defaultValue: '' }, { key: 'email.from_address', type: 'string', defaultValue: '' },
{ key: 'email.from_name', type: 'string', defaultValue: 'Pulse Signage' }, { key: 'email.from_name', type: 'string', defaultValue: 'Pulse Signage' },
{ key: 'email.verification_expiry_minutes', type: 'integer', min: 1, defaultValue: 30 },
{ key: 'email.reset_expiry_minutes', type: 'integer', min: 1, defaultValue: 30 },
{ key: 'email.invitation_expiry_hours', type: 'integer', min: 1, defaultValue: 24 },
{ key: 'email.verification_subject', type: 'string', defaultValue: 'Verify your Pulse Signage email address' }, { key: 'email.verification_subject', type: 'string', defaultValue: 'Verify your Pulse Signage email address' },
{ key: 'email.verification_body', type: 'string', defaultValue: 'Hello [[display_name]]!\n\nConfirm this email address:\n\n[[action_button]]\n\nThis link expires in 30 minutes.' }, { key: 'email.verification_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nConfirm this email address: [[url]]' },
{ key: 'email.reset_subject', type: 'string', defaultValue: 'Reset your Pulse Signage password' }, { key: 'email.reset_subject', type: 'string', defaultValue: 'Reset your Pulse Signage password' },
{ key: 'email.reset_body', type: 'string', defaultValue: 'Hello [[display_name]]!\n\nUse this link to choose a new password:\n\n[[action_button]]\n\nThis link expires in 30 minutes.' }, { key: 'email.reset_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nChoose a new password: [[url]]' },
{ key: 'email.verification_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' }, { key: 'email.verification_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' },
{ key: 'email.verification_button_text', type: 'string', defaultValue: 'Verify email address' }, { key: 'email.verification_button_text', type: 'string', defaultValue: 'Verify email address' },
{ key: 'email.reset_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' }, { key: 'email.reset_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' },
{ key: 'email.reset_button_text', type: 'string', defaultValue: 'Reset password' }, { key: 'email.reset_button_text', type: 'string', defaultValue: 'Reset password' },
{ key: 'email.invitation_subject', type: 'string', defaultValue: 'You have been invited to Pulse Signage' }, { key: 'email.invitation_subject', type: 'string', defaultValue: 'You have been invited to Pulse Signage' },
{ key: 'email.invitation_body', type: 'string', defaultValue: 'Hello [[display_name]]!\n\nYou have been invited to create a Pulse Signage account.\n\n[[action_button]]\n\nThis invitation expires in 24 hours.' }, { key: 'email.invitation_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]invitation link[/u] expires in [i][[expiry_time]][/i].\nCreate your account: [[url]]' },
{ key: 'email.invitation_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' }, { key: 'email.invitation_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' },
{ key: 'email.invitation_button_text', type: 'string', defaultValue: 'Accept invitation' }, { key: 'email.invitation_button_text', type: 'string', defaultValue: 'Accept invitation' },
{ key: 'audit.enabled', type: 'boolean', defaultValue: true }, { key: 'audit.enabled', type: 'boolean', defaultValue: true },
+22
View File
@@ -50,6 +50,27 @@ const SCREEN_CONTROL_COMMAND_LABELS = Object.freeze({
}); });
const { fetchAppSettings } = require('./app-settings'); const { fetchAppSettings } = require('./app-settings');
function formatUserAgentLabel(userAgent) {
const value = String(userAgent || '').trim();
if (!value) return '';
const browserMatch = value.match(/(?:Edg|OPR|Chrome|Firefox|Version|Electron)\/([\d.]+)/i);
let browser = '';
if (/Edg\//i.test(value)) browser = 'Edge';
else if (/OPR\//i.test(value)) browser = 'Opera';
else if (/Electron\//i.test(value)) browser = 'Electron';
else if (/Chrome\//i.test(value)) browser = 'Chrome';
else if (/Firefox\//i.test(value)) browser = 'Firefox';
else if (/Version\/.*Safari\//i.test(value)) browser = 'Safari';
const browserLabel = browserMatch && browser ? browser + ' ' + browserMatch[1] : browser;
let operatingSystem = '';
if (/Windows NT/i.test(value)) operatingSystem = 'Windows';
else if (/Macintosh|Mac OS X/i.test(value)) operatingSystem = 'macOS';
else if (/Android/i.test(value)) operatingSystem = 'Android';
else if (/iPhone|iPad|iPod/i.test(value)) operatingSystem = 'iOS';
else if (/Linux/i.test(value)) operatingSystem = 'Linux';
return [browserLabel, operatingSystem].filter(Boolean).join(' on ') || value;
}
function normalizeDetails(details) { function normalizeDetails(details) {
if (details === undefined || details === null) { if (details === undefined || details === null) {
return null; return null;
@@ -136,6 +157,7 @@ module.exports = {
AUDIT_CATEGORY_LABELS, AUDIT_CATEGORY_LABELS,
SCREEN_CONTROL_COMMAND_KEYS, SCREEN_CONTROL_COMMAND_KEYS,
SCREEN_CONTROL_COMMAND_LABELS, SCREEN_CONTROL_COMMAND_LABELS,
formatUserAgentLabel,
getRequestMetadata, getRequestMetadata,
buildAuditChanges, buildAuditChanges,
recordAuditEvent, recordAuditEvent,
+1 -1
View File
@@ -1,5 +1,5 @@
@charset "UTF-8";/*! @charset "UTF-8";/*!
* AdminLTE v4.8.5 — Extended color palette * AdminLTE v4.9.1 — Extended color palette
* Opt-in. Nothing here is in adminlte.css: load this sheet after it and you * Opt-in. Nothing here is in adminlte.css: load this sheet after it and you
* get fourteen additional colours (orange, amber, olive, teal, sky, indigo, * get fourteen additional colours (orange, amber, olive, teal, sky, indigo,
* violet, fuchsia, pink, navy, steel, slate, graphite, midnight) as `--bs-*` * violet, fuchsia, pink, navy, steel, slate, graphite, midnight) as `--bs-*`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
/*! /*!
* AdminLTE v4.3.1 (https://adminlte.io) * AdminLTE v4.9.1 (https://adminlte.io)
* Copyright 2014-2026 Colorlib <https://colorlib.com> * Copyright 2014-2026 Colorlib <https://colorlib.com>
* Licensed under MIT (https://github.com/ColorlibHQ/AdminLTE/blob/master/LICENSE) * Licensed under MIT (https://github.com/ColorlibHQ/AdminLTE/blob/master/LICENSE)
*/ */
+64 -268
View File
@@ -89,15 +89,6 @@
text-align: center; text-align: center;
} }
.user-menu-dropdown .user-body {
padding: 0.75rem 1rem;
}
.user-menu-dropdown .user-body a {
color: inherit;
text-decoration: none;
}
.user-menu-dropdown .user-header p { .user-menu-dropdown .user-header p {
margin: 0.75rem 0 0; margin: 0.75rem 0 0;
line-height: 1.2; line-height: 1.2;
@@ -280,12 +271,6 @@
text-align: center; text-align: center;
} }
.email-template-preview__subject {
color: #111827;
font-weight: 600;
margin-bottom: 1rem;
}
.email-template-preview__message p { .email-template-preview__message p {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
white-space: pre-wrap; white-space: pre-wrap;
@@ -338,10 +323,6 @@
border-color: rgba(var(--bs-primary-rgb), 0.45); border-color: rgba(var(--bs-primary-rgb), 0.45);
} }
.template-preview-card.is-previewing .card-header {
position: relative;
}
.template-preview-card.is-previewing .card-title { .template-preview-card.is-previewing .card-title {
position: relative; position: relative;
padding-right: 6.25rem; padding-right: 6.25rem;
@@ -364,6 +345,9 @@
.template-preview-card .btn-group { .template-preview-card .btn-group {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.audit-change-list { .audit-change-list {
display: grid; display: grid;
padding: 0.08rem 0.3rem; padding: 0.08rem 0.3rem;
@@ -374,32 +358,22 @@
.audit-change-row { .audit-change-row {
display: grid; display: grid;
grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr); grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
background: var(--bs-danger-bg-subtle);
gap: 0.35rem; gap: 0.35rem;
align-items: baseline; align-items: baseline;
font-size: 0.78rem; font-size: 0.78rem;
} }
background: var(--bs-success-bg-subtle);
.audit-change-to { .audit-change-to {
overflow-wrap: anywhere; overflow-wrap: anywhere;
white-space: pre-wrap; white-space: pre-wrap;
} }
.audit-change-from { .audit-change-from {
color: var(--bs-danger-text-emphasis); overflow-wrap: anywhere;
} white-space: pre-wrap;
.audit-change-to {
color: var(--bs-success-text-emphasis);
} }
.audit-change-from del, .audit-change-from del,
.audit-change-to ins { .audit-change-to ins {
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
} }
.audit-change-arrow {
color: var(--bs-secondary-color);
}
gap: 0.5rem;
}
.template-preview-card .btn-group .btn { .template-preview-card .btn-group .btn {
width: 100%; width: 100%;
} }
@@ -457,20 +431,7 @@
position: relative; position: relative;
} }
.announcement-icon-picker__toggle { .announcement-icon-picker__toggle,
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
min-height: calc(2.375rem + 2px);
padding: 0.375rem 0.75rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.875rem;
background: var(--bs-body-bg);
color: var(--bs-body-color);
text-align: left;
}
.announcement-type-picker__toggle { .announcement-type-picker__toggle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -485,29 +446,13 @@
text-align: left; text-align: left;
} }
.announcement-icon-picker__toggle:focus-visible { .announcement-icon-picker__toggle:focus-visible,
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
}
.announcement-type-picker__toggle:focus-visible { .announcement-type-picker__toggle:focus-visible {
outline: 0; outline: 0;
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25); box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
} }
.announcement-icon-picker__toggle-icon { .announcement-icon-picker__toggle-icon,
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.7rem;
background: rgba(var(--bs-primary-rgb), 0.1);
color: var(--bs-primary);
font-size: 1rem;
flex: 0 0 auto;
}
.announcement-type-picker__toggle-icon { .announcement-type-picker__toggle-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -521,14 +466,7 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.announcement-icon-picker__toggle-content { .announcement-icon-picker__toggle-content,
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1 1 auto;
min-width: 0;
}
.announcement-type-picker__toggle-content { .announcement-type-picker__toggle-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -537,60 +475,27 @@
min-width: 0; min-width: 0;
} }
.announcement-icon-picker__toggle-label { .announcement-icon-picker__toggle-label,
font-weight: 600;
line-height: 1.1;
}
.announcement-type-picker__toggle-label { .announcement-type-picker__toggle-label {
font-weight: 600; font-weight: 600;
line-height: 1.1; line-height: 1.1;
} }
.announcement-icon-picker__toggle-hint { .announcement-icon-picker__toggle-hint,
color: var(--bs-secondary-color);
font-size: 0.78rem;
line-height: 1.1;
}
.announcement-type-picker__toggle-hint { .announcement-type-picker__toggle-hint {
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1.1; line-height: 1.1;
} }
.announcement-icon-picker__caret { .announcement-icon-picker__caret,
flex: 0 0 auto;
color: var(--bs-secondary-color);
font-size: 0.8rem;
}
.announcement-type-picker__caret { .announcement-type-picker__caret {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
font-size: 0.8rem; font-size: 0.8rem;
} }
.announcement-icon-picker__menu { .announcement-icon-picker__menu,
position: absolute;
z-index: 20;
top: calc(100% + 0.5rem);
bottom: auto;
left: 0;
width: min(100%, 28rem);
max-width: calc(100vw - 1rem);
padding: 0.75rem;
border: 1px solid var(--bs-border-color);
border-radius: 1rem;
background: var(--bs-body-bg);
box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.18);
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: min(32rem, calc(100vh - 3rem));
overflow: hidden;
}
.announcement-type-picker__menu { .announcement-type-picker__menu {
position: absolute; position: absolute;
z-index: 20; z-index: 20;
@@ -606,11 +511,15 @@
box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.18); box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.18);
} }
.announcement-type-picker__menu.is-open-above { .announcement-icon-picker__menu {
top: auto; display: flex;
bottom: calc(100% + 0.5rem); flex-direction: column;
gap: 0.75rem;
max-height: min(32rem, calc(100vh - 3rem));
overflow: hidden;
} }
.announcement-type-picker__menu.is-open-above,
.announcement-icon-picker__menu.is-open-above { .announcement-icon-picker__menu.is-open-above {
top: auto; top: auto;
bottom: calc(100% + 0.5rem); bottom: calc(100% + 0.5rem);
@@ -634,13 +543,6 @@
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
} }
.announcement-icon-picker__section {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
}
.announcement-icon-picker__section-header { .announcement-icon-picker__section-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -666,12 +568,6 @@
align-content: start; align-content: start;
} }
.announcement-icon-picker__search-grid {
max-height: none;
overflow: hidden;
padding-right: 0;
}
.announcement-icon-picker__search-empty { .announcement-icon-picker__search-empty {
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
font-size: 0.875rem; font-size: 0.875rem;
@@ -684,6 +580,13 @@
gap: 0.5rem; gap: 0.5rem;
} }
.announcement-icon-picker__option,
.announcement-type-picker__option,
.announcement-screen-chip,
.announcement-color-picker__option {
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
}
.announcement-icon-picker__option { .announcement-icon-picker__option {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -694,51 +597,65 @@
border-radius: 0.75rem; border-radius: 0.75rem;
background: var(--bs-body-bg); background: var(--bs-body-bg);
color: var(--bs-body-color); color: var(--bs-body-color);
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
} }
.announcement-type-picker__option { .announcement-type-picker__option,
.announcement-color-picker__option {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
border-radius: 0.75rem; border-radius: 0.75rem;
background: var(--bs-body-bg); background: var(--bs-body-bg);
color: var(--bs-body-color); color: var(--bs-body-color);
text-align: left; text-align: left;
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease; }
.announcement-type-picker__option {
gap: 0.5rem;
padding: 0.5rem;
}
.announcement-icon-picker__option:hover,
.announcement-icon-picker__option:focus-visible,
.announcement-type-picker__option:hover,
.announcement-type-picker__option:focus-visible,
.announcement-screen-chip:hover,
.announcement-screen-chip:focus-visible,
.announcement-color-picker__option:hover,
.announcement-color-picker__option:focus-visible {
border-color: rgba(var(--bs-primary-rgb), 0.7);
transform: translateY(-1px);
}
.announcement-icon-picker__option:hover,
.announcement-icon-picker__option:focus-visible,
.announcement-type-picker__option:hover,
.announcement-type-picker__option:focus-visible {
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
} }
.announcement-icon-picker__option:hover, .announcement-icon-picker__option:hover,
.announcement-icon-picker__option:focus-visible { .announcement-icon-picker__option:focus-visible {
border-color: rgba(var(--bs-primary-rgb), 0.7);
background: rgba(var(--bs-primary-rgb), 0.08); background: rgba(var(--bs-primary-rgb), 0.08);
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
transform: translateY(-1px);
} }
.announcement-type-picker__option:hover, .announcement-type-picker__option:hover,
.announcement-type-picker__option:focus-visible { .announcement-type-picker__option:focus-visible {
border-color: rgba(var(--bs-primary-rgb), 0.7);
background: rgba(var(--bs-primary-rgb), 0.04); background: rgba(var(--bs-primary-rgb), 0.04);
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
transform: translateY(-1px);
}
.announcement-icon-picker__option.is-selected {
border-color: var(--bs-primary);
background: rgba(var(--bs-primary-rgb), 0.12);
color: var(--bs-primary);
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12);
} }
.announcement-icon-picker__option.is-selected,
.announcement-type-picker__option.is-selected { .announcement-type-picker__option.is-selected {
border-color: var(--bs-primary); border-color: var(--bs-primary);
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12); box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12);
} }
.announcement-icon-picker__option.is-selected {
background: rgba(var(--bs-primary-rgb), 0.12);
color: var(--bs-primary);
}
.announcement-icon-picker__option i { .announcement-icon-picker__option i {
font-size: 1.05rem; font-size: 1.05rem;
line-height: 1; line-height: 1;
@@ -810,15 +727,12 @@
background: var(--bs-body-bg); background: var(--bs-body-bg);
color: var(--bs-body-color); color: var(--bs-body-color);
cursor: pointer; cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
} }
.announcement-screen-chip:hover, .announcement-screen-chip:hover,
.announcement-screen-chip:focus-visible { .announcement-screen-chip:focus-visible {
border-color: rgba(var(--bs-primary-rgb), 0.7);
background: rgba(var(--bs-primary-rgb), 0.04); background: rgba(var(--bs-primary-rgb), 0.04);
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12); box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12);
transform: translateY(-1px);
} }
.announcement-screen-option > .btn-check:checked + .announcement-screen-chip { .announcement-screen-option > .btn-check:checked + .announcement-screen-chip {
@@ -868,10 +782,9 @@
.announcement-screen-picker { .announcement-screen-picker {
max-height: 16rem; max-height: 16rem;
} }
}
@media (max-width: 576px) { .announcement-type-picker__menu,
.announcement-type-picker__menu { .announcement-icon-picker__menu {
width: calc(100vw - 2rem); width: calc(100vw - 2rem);
right: 0; right: 0;
left: auto; left: auto;
@@ -880,26 +793,12 @@
.announcement-type-picker__grid { .announcement-type-picker__grid {
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
} }
}
@media (max-width: 576px) {
.announcement-icon-picker__menu {
width: calc(100vw - 2rem);
right: 0;
left: auto;
}
.announcement-icon-picker__grid { .announcement-icon-picker__grid {
grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr));
} }
} }
.fonts-table-responsive {
border-bottom-left-radius: var(--bs-card-border-radius);
border-bottom-right-radius: var(--bs-card-border-radius);
overflow: hidden;
}
.background-tasks-task-tools { .background-tasks-task-tools {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
@@ -910,10 +809,6 @@
max-width: 12rem; max-width: 12rem;
} }
.background-tasks-task-status {
flex: 0 0 auto;
}
.background-tasks-page { .background-tasks-page {
overflow: hidden; overflow: hidden;
} }
@@ -1069,14 +964,6 @@
} }
} }
.admin-form-card {
box-shadow: none;
}
.admin-form-card .card-header {
background: var(--bs-tertiary-bg);
}
.onboarding-pairing-card > .card-header { .onboarding-pairing-card > .card-header {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
@@ -1098,13 +985,6 @@
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
@media (max-width: 575.98px) {
.onboarding-client-list-link {
flex-basis: 100%;
text-align: center;
}
}
.onboarding-pin-inputs { .onboarding-pin-inputs {
display: flex; display: flex;
width: fit-content; width: fit-content;
@@ -1380,10 +1260,6 @@
opacity: 0.6; opacity: 0.6;
} }
.dashboard-launcher-download {
width: 9rem;
}
.screen-command-card .card-header { .screen-command-card .card-header {
padding-top: 1rem; padding-top: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
@@ -1675,6 +1551,7 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.25; line-height: 1.25;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
@@ -1764,14 +1641,6 @@
line-height: 1.2; line-height: 1.2;
} }
.dashboard-screen-link {
display: inline-block;
margin-top: 0.25rem;
color: var(--bs-secondary-color);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.dashboard-screen-pill { .dashboard-screen-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2682,20 +2551,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
} }
.template-actions-group > .btn-warning, .template-actions-group > .btn-warning,
.template-actions-group > .btn-danger, .template-actions-group > .btn-danger {
.playlist-actions-group > .btn-warning,
.playlist-actions-group > .btn-danger,
.screen-actions-group > .btn-warning,
.screen-actions-group > .btn-danger {
flex: 1 1 0; flex: 1 1 0;
}
.template-actions-group > .btn-warning,
.template-actions-group > .btn-danger,
.playlist-actions-group > .btn-warning,
.playlist-actions-group > .btn-danger,
.screen-actions-group > .btn-warning,
.screen-actions-group > .btn-danger {
text-align: center; text-align: center;
} }
@@ -2717,14 +2574,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
min-width: 3rem; min-width: 3rem;
} }
.template-designer-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.template-designer-actions .btn {
width: 100%;
}
.region-info-card .card-header { .region-info-card .card-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -2735,10 +2584,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
margin-bottom: 0.15rem; margin-bottom: 0.15rem;
} }
.region-info-card .card-subtitle {
margin-bottom: 0;
}
.region-selector-block .form-label { .region-selector-block .form-label {
font-weight: 600; font-weight: 600;
} }
@@ -2916,17 +2761,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
height: 100% !important; height: 100% !important;
} }
.announcement-color-preview {
display: flex;
align-items: center;
justify-content: center;
min-width: 5.5rem;
height: calc(2.375rem + 2px);
box-sizing: border-box;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
}
.announcement-color-picker { .announcement-color-picker {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -2939,25 +2773,14 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
} }
.announcement-color-picker__option { .announcement-color-picker__option {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.375rem; gap: 0.375rem;
padding: 0.375rem; padding: 0.375rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.75rem;
background: var(--bs-body-bg);
color: var(--bs-body-color);
text-align: left;
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
} }
.announcement-color-picker__option:hover, .announcement-color-picker__option:hover,
.announcement-color-picker__option:focus-visible { .announcement-color-picker__option:focus-visible {
border-color: rgba(var(--bs-primary-rgb), 0.7);
background: rgba(var(--bs-primary-rgb), 0.04); background: rgba(var(--bs-primary-rgb), 0.04);
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15); box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
transform: translateY(-1px);
} }
.announcement-color-picker__option.is-selected { .announcement-color-picker__option.is-selected {
@@ -3004,18 +2827,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
} }
} }
.announcement-icon-preview {
display: flex;
align-items: center;
justify-content: center;
min-width: 5.5rem;
height: calc(2.375rem + 2px);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
background: var(--bs-tertiary-bg);
font-size: 1.35rem;
}
.announcement-list-icon { .announcement-list-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -3167,9 +2978,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
justify-self: stretch; justify-self: stretch;
} }
.template-designer-actions {
grid-template-columns: 1fr;
}
} }
.frame-popup-card-body { .frame-popup-card-body {
@@ -3194,16 +3002,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
display: none !important; display: none !important;
} }
.playlist-add-slide-form {
display: flex;
}
.playlist-add-slide-form.is-hidden,
.playlist-add-slide-empty.is-hidden,
.schedule-panel.is-hidden {
display: none !important;
}
.playlist-slide-picker-modal-body { .playlist-slide-picker-modal-body {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@@ -3233,15 +3031,13 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
background: var(--bs-body-bg); background: var(--bs-body-bg);
color: var(--bs-body-color); color: var(--bs-body-color);
padding: 0; padding: 0;
display: grid;
gap: 0.3rem; gap: 0.3rem;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
} }
.playlist-slide-picker-card:hover, .playlist-slide-picker-card:hover {
.playlist-slide-picker-card:focus-visible {
border-color: var(--bs-primary); border-color: var(--bs-primary);
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.15); box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.15);
transform: translateY(-1px); transform: translateY(-1px);
+10 -5
View File
@@ -389,7 +389,10 @@
var signedInUser = { var signedInUser = {
username: previewUser ? previewUser.getAttribute('data-username') : '', username: previewUser ? previewUser.getAttribute('data-username') : '',
display_name: previewUser ? previewUser.getAttribute('data-display-name') : '', display_name: previewUser ? previewUser.getAttribute('data-display-name') : '',
email: previewUser ? previewUser.getAttribute('data-email') : '' email: previewUser ? previewUser.getAttribute('data-email') : '',
verification_expiry: previewUser ? previewUser.getAttribute('data-verification-expiry') : '30',
reset_expiry: previewUser ? previewUser.getAttribute('data-reset-expiry') : '30',
invitation_expiry: previewUser ? previewUser.getAttribute('data-invitation-expiry') : '24'
}; };
document.querySelectorAll('[data-email-template-editor]').forEach(function (editor) { document.querySelectorAll('[data-email-template-editor]').forEach(function (editor) {
var subject = editor.querySelector('[data-email-template-subject]'); var subject = editor.querySelector('[data-email-template-subject]');
@@ -398,6 +401,9 @@
var buttonText = editor.querySelector('[data-email-template-button-text]'); var buttonText = editor.querySelector('[data-email-template-button-text]');
var preview = editor.querySelector('[data-email-template-preview]'); var preview = editor.querySelector('[data-email-template-preview]');
if (!subject || !body || !preview) return; if (!subject || !body || !preview) return;
var isVerificationTemplate = Boolean(editor.querySelector('[name="verification_subject"]'));
var isResetTemplate = Boolean(editor.querySelector('[name="reset_subject"]'));
var isInvitationTemplate = Boolean(editor.querySelector('[name="invitation_subject"]'));
function render() { function render() {
preview.innerHTML = ''; preview.innerHTML = '';
@@ -411,7 +417,8 @@
var sampleValues = { var sampleValues = {
username: signedInUser.username || 'username', username: signedInUser.username || 'username',
display_name: signedInUser.display_name || signedInUser.username || 'Your name', display_name: signedInUser.display_name || signedInUser.username || 'Your name',
email: signedInUser.email || 'your-email@example.com' email: signedInUser.email || 'your-email@example.com',
expiry_time: isInvitationTemplate ? signedInUser.invitation_expiry + ' hours' : (isResetTemplate ? signedInUser.reset_expiry : signedInUser.verification_expiry) + ' minutes'
}; };
var button = document.createElement('div'); var button = document.createElement('div');
button.className = 'email-template-preview__button'; button.className = 'email-template-preview__button';
@@ -420,8 +427,6 @@
buttonWrap.className = 'email-template-preview__button-wrap'; buttonWrap.className = 'email-template-preview__button-wrap';
buttonWrap.style.textAlign = buttonAlignment; buttonWrap.style.textAlign = buttonAlignment;
buttonWrap.appendChild(button); buttonWrap.appendChild(button);
var isVerificationTemplate = Boolean(editor.querySelector('[name="verification_subject"]'));
var isInvitationTemplate = Boolean(editor.querySelector('[name="invitation_subject"]'));
var previewActionPlaceholder = '[[url]]'; var previewActionPlaceholder = '[[url]]';
button.textContent = buttonText && buttonText.value ? buttonText.value : (isVerificationTemplate ? 'Verify email address' : (isInvitationTemplate ? 'Accept invitation' : 'Reset password')); button.textContent = buttonText && buttonText.value ? buttonText.value : (isVerificationTemplate ? 'Verify email address' : (isInvitationTemplate ? 'Accept invitation' : 'Reset password'));
var previewOrigin = window.location.origin || 'https://pulse-signage.example'; var previewOrigin = window.location.origin || 'https://pulse-signage.example';
@@ -430,7 +435,7 @@
var previewActionUrl = isVerificationTemplate ? previewVerificationUrl : (isInvitationTemplate ? previewOrigin + '/accept-invite?token=preview-token' : previewResetUrl); var previewActionUrl = isVerificationTemplate ? previewVerificationUrl : (isInvitationTemplate ? previewOrigin + '/accept-invite?token=preview-token' : previewResetUrl);
var hasExplicitButton = body.value.indexOf('[[action_button]]') !== -1; var hasExplicitButton = body.value.indexOf('[[action_button]]') !== -1;
body.value.split(/\r?\n(?:[ \t]*\r?\n)+/).forEach(function (sourceParagraph) { body.value.split(/\r?\n(?:[ \t]*\r?\n)+/).forEach(function (sourceParagraph) {
var line = sourceParagraph.replace(/\[\[(username|display_name|email)\]\]/g, function (_match, key) { return sampleValues[key]; }).replace(previewActionPlaceholder, previewActionUrl); var line = sourceParagraph.replace(/\[\[(username|display_name|email|expiry_time)\]\]/g, function (_match, key) { return sampleValues[key]; }).replace(previewActionPlaceholder, previewActionUrl);
var hasButtonToken = sourceParagraph.indexOf('[[action_button]]') !== -1; var hasButtonToken = sourceParagraph.indexOf('[[action_button]]') !== -1;
var hasUrlToken = !hasExplicitButton && sourceParagraph.indexOf(previewActionPlaceholder) !== -1; var hasUrlToken = !hasExplicitButton && sourceParagraph.indexOf(previewActionPlaceholder) !== -1;
if (hasButtonToken) { if (hasButtonToken) {
+9 -6
View File
@@ -1,7 +1,8 @@
// Admin account route registration and profile helpers. // Admin account route registration and profile helpers.
const { fetchAppSettings } = require('#src/data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { renderAccountEmailTemplate } = require('#src/data/account-email-templates'); const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const { formatUserAgentLabel } = require('#src/data/audit-log');
module.exports = function registerAccountRoutes(app, deps) { module.exports = function registerAccountRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -59,9 +60,10 @@ module.exports = function registerAccountRoutes(app, deps) {
const settings = await fetchAppSettings(pool); const settings = await fetchAppSettings(pool);
if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') return res.status(400).send('Email delivery is not configured.'); if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') return res.status(400).send('Email delivery is not configured.');
const token = createOneTimeToken(); const token = createOneTimeToken();
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE id = ?', [email, hashSessionToken(token), req.currentUser.id]); const expiryMinutes = Number(settings['email.verification_expiry_minutes']);
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE) WHERE id = ?', [email, hashSessionToken(token), req.currentUser.id]);
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token); const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] }))); await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.email_change_requested', actorUserId: req.currentUser.id, targetType: 'user', targetId: req.currentUser.id, targetLabel: email }); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.email_change_requested', actorUserId: req.currentUser.id, targetType: 'user', targetId: req.currentUser.id, targetLabel: email });
res.redirect('/account?message=' + encodeURIComponent('Check your new email address for a verification link.')); res.redirect('/account?message=' + encodeURIComponent('Check your new email address for a verification link.'));
} catch (error) { } catch (error) {
@@ -87,7 +89,7 @@ module.exports = function registerAccountRoutes(app, deps) {
id: Number(session.id), id: Number(session.id),
isCurrent: Boolean(tokenHash && session.session_hash === tokenHash), isCurrent: Boolean(tokenHash && session.session_hash === tokenHash),
ipAddress: String(session.ip_address || 'Unknown'), ipAddress: String(session.ip_address || 'Unknown'),
userAgent: String(session.user_agent || 'Unknown browser'), userAgent: formatUserAgentLabel(session.user_agent) || 'Unknown browser',
createdAtLabel: formatDashboardDate(session.created_at), createdAtLabel: formatDashboardDate(session.created_at),
lastUsedAtLabel: formatDashboardDate(session.last_used_at), lastUsedAtLabel: formatDashboardDate(session.last_used_at),
expiresAtLabel: formatDashboardDate(session.expires_at) expiresAtLabel: formatDashboardDate(session.expires_at)
@@ -134,9 +136,10 @@ module.exports = function registerAccountRoutes(app, deps) {
await pool.query('UPDATE a_users SET pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]); await pool.query('UPDATE a_users SET pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
} else { } else {
const token = createOneTimeToken(); const token = createOneTimeToken();
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE id = ?', [email, hashSessionToken(token), user.id]); const expiryMinutes = Number(settings['email.verification_expiry_minutes']);
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE) WHERE id = ?', [email, hashSessionToken(token), user.id]);
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token); const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] }))); await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
} }
} }
if (emailChanged && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: email ? 'user.email_change_requested' : 'user.email_cleared', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: email || user.email, details: { previousEmail: confirmedEmail || null, email: email || null, verificationRequired: Boolean(email) } }); if (emailChanged && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: email ? 'user.email_change_requested' : 'user.email_cleared', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: email || user.email, details: { previousEmail: confirmedEmail || null, email: email || null, verificationRequired: Boolean(email) } });
+9 -7
View File
@@ -1,8 +1,8 @@
// Admin user route registration and user-role management. // Admin user route registration and user-role management.
const { fetchAppSettings } = require('#src/data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { renderAccountEmailTemplate } = require('#src/data/account-email-templates'); const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const { buildAuditChanges } = require('#src/data/audit-log'); const { buildAuditChanges, formatUserAgentLabel } = require('#src/data/audit-log');
module.exports = function registerUsersRoutes(app, deps) { module.exports = function registerUsersRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -187,8 +187,9 @@
const invitation = rows[0]; const invitation = rows[0];
const token = createOneTimeToken(); const token = createOneTimeToken();
const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token); const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token);
await sendAccountEmail(settings, Object.assign({ to: invitation.email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: invitation.name || 'there', email: invitation.email, action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] }))); const expiryHours = Number(settings['email.invitation_expiry_hours']);
await pool.query('UPDATE a_user_invitations SET token_hash = ?, expires_at = DATE_ADD(NOW(), INTERVAL 24 HOUR), created_at = NOW(), created_by = ? WHERE id = ? AND used_at IS NULL', [hashSessionToken(token), getAuditUserId(req), invitationId]); await sendAccountEmail(settings, Object.assign({ to: invitation.email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: invitation.name || 'there', email: invitation.email, expiry_time: formatAccountEmailExpiry(expiryHours, 'hours'), action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] })));
await pool.query('UPDATE a_user_invitations SET token_hash = ?, expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryHours + ' HOUR), created_at = NOW(), created_by = ? WHERE id = ? AND used_at IS NULL', [hashSessionToken(token), getAuditUserId(req), invitationId]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_resent', actorUserId: getAuditUserId(req), targetType: 'invitation', targetId: invitationId, targetLabel: invitation.email }); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_resent', actorUserId: getAuditUserId(req), targetType: 'invitation', targetId: invitationId, targetLabel: invitation.email });
res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation resent.')); res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation resent.'));
} catch (error) { } catch (error) {
@@ -236,10 +237,11 @@
if (Number(recentInvites[0] && recentInvites[0].invite_count) >= 25) return renderInviteError('Invitation sending is temporarily limited. Try again later.'); if (Number(recentInvites[0] && recentInvites[0].invite_count) >= 25) return renderInviteError('Invitation sending is temporarily limited. Try again later.');
const token = createOneTimeToken(); const token = createOneTimeToken();
await pool.query('UPDATE a_user_invitations SET used_at = NOW() WHERE email = ? AND used_at IS NULL', [email]); await pool.query('UPDATE a_user_invitations SET used_at = NOW() WHERE email = ? AND used_at IS NULL', [email]);
await pool.query('INSERT INTO a_user_invitations (email, name, role_ids_json, token_hash, expires_at, created_by) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR), ?)', [email, name || null, JSON.stringify(roleCheck.roleIds), hashSessionToken(token), getAuditUserId(req)]); const expiryHours = Number(settings['email.invitation_expiry_hours']);
await pool.query('INSERT INTO a_user_invitations (email, name, role_ids_json, token_hash, expires_at, created_by) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ' + expiryHours + ' HOUR), ?)', [email, name || null, JSON.stringify(roleCheck.roleIds), hashSessionToken(token), getAuditUserId(req)]);
const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token); const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token);
try { try {
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: name || 'there', email: email, action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] }))); await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: name || 'there', email: email, expiry_time: formatAccountEmailExpiry(expiryHours, 'hours'), action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] })));
} catch (mailError) { } catch (mailError) {
await pool.query('DELETE FROM a_user_invitations WHERE token_hash = ?', [hashSessionToken(token)]); await pool.query('DELETE FROM a_user_invitations WHERE token_hash = ?', [hashSessionToken(token)]);
throw mailError; throw mailError;
@@ -307,7 +309,7 @@
return { return {
id: Number(session.id), id: Number(session.id),
ipAddress: String(session.ip_address || 'Unknown'), ipAddress: String(session.ip_address || 'Unknown'),
userAgent: String(session.user_agent || 'Unknown browser'), userAgent: formatUserAgentLabel(session.user_agent) || 'Unknown browser',
createdAtLabel: formatDashboardDate(session.created_at), createdAtLabel: formatDashboardDate(session.created_at),
lastUsedAtLabel: formatDashboardDate(session.last_used_at), lastUsedAtLabel: formatDashboardDate(session.last_used_at),
expiresAtLabel: formatDashboardDate(session.expires_at) expiresAtLabel: formatDashboardDate(session.expires_at)
+4 -3
View File
@@ -2,7 +2,7 @@
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session'); const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
const { fetchAppSettings } = require('#src/data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { renderAccountEmailTemplate } = require('#src/data/account-email-templates'); const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
module.exports = function registerAuthRoutes(app, deps) { module.exports = function registerAuthRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -157,10 +157,11 @@ module.exports = function registerAuthRoutes(app, deps) {
if (user && user.email && user.email_verified_at && typeof sendAccountEmail === 'function' && typeof createOneTimeToken === 'function') { if (user && user.email && user.email_verified_at && typeof sendAccountEmail === 'function' && typeof createOneTimeToken === 'function') {
const token = createOneTimeToken(); const token = createOneTimeToken();
await pool.query('DELETE FROM a_account_tokens WHERE user_id = ? AND token_type = ?', [user.id, 'password-reset']); await pool.query('DELETE FROM a_account_tokens WHERE user_id = ? AND token_type = ?', [user.id, 'password-reset']);
await pool.query('INSERT INTO a_account_tokens (user_id, token_type, token_hash, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 MINUTE))', [user.id, 'password-reset', hashSessionToken(token)]); const expiryMinutes = Number(settings['email.reset_expiry_minutes']);
await pool.query('INSERT INTO a_account_tokens (user_id, token_type, token_hash, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE))', [user.id, 'password-reset', hashSessionToken(token)]);
const resetUrl = getRequestOrigin(req) + '/reset-password?token=' + encodeURIComponent(token); const resetUrl = getRequestOrigin(req) + '/reset-password?token=' + encodeURIComponent(token);
try { try {
await sendAccountEmail(settings, Object.assign({ to: user.email }, renderAccountEmailTemplate(settings['email.reset_subject'], settings['email.reset_body'], { url: resetUrl, username: user.username, display_name: user.name, email: user.email, action_alignment: settings['email.reset_button_alignment'], action_label: settings['email.reset_button_text'] }))); await sendAccountEmail(settings, Object.assign({ to: user.email }, renderAccountEmailTemplate(settings['email.reset_subject'], settings['email.reset_body'], { url: resetUrl, username: user.username, display_name: user.name, email: user.email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.reset_button_alignment'], action_label: settings['email.reset_button_text'] })));
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.reset_requested', targetType: 'user', targetId: user.id, targetLabel: user.username }); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.reset_requested', targetType: 'user', targetId: user.id, targetLabel: user.username });
} catch (_mailError) { } catch (_mailError) {
await pool.query('DELETE FROM a_account_tokens WHERE token_hash = ?', [hashSessionToken(token)]); await pool.query('DELETE FROM a_account_tokens WHERE token_hash = ?', [hashSessionToken(token)]);
+2 -1
View File
@@ -1,6 +1,6 @@
const { renderView } = require('#src/web/view'); const { renderView } = require('#src/web/view');
const { hasPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); const { hasPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { AUDIT_CATEGORY_KEYS } = require('#src/data/audit-log'); const { AUDIT_CATEGORY_KEYS, formatUserAgentLabel } = require('#src/data/audit-log');
const { buildPagination } = require('#src/web/lib/pagination'); const { buildPagination } = require('#src/web/lib/pagination');
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query'); const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
@@ -160,6 +160,7 @@ function mapAuditRow(row, formatDashboardDate) {
occurredAtValue: Number.isNaN(occurredAt.getTime()) ? '' : occurredAt.toISOString(), occurredAtValue: Number.isNaN(occurredAt.getTime()) ? '' : occurredAt.toISOString(),
occurredAtLabel: formatDashboardDate(row.occurred_at), occurredAtLabel: formatDashboardDate(row.occurred_at),
actorLabel: row.actor_name || row.actor_username || 'System', actorLabel: row.actor_name || row.actor_username || 'System',
userAgentLabel: formatUserAgentLabel(row.user_agent),
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '), eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''), targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
detailsDisplay: details, detailsDisplay: details,
+11 -2
View File
@@ -175,6 +175,9 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
smtpConfigured: Boolean(settings['email.smtp_password']), smtpConfigured: Boolean(settings['email.smtp_password']),
emailFromAddress: String(settings['email.from_address'] || ''), emailFromAddress: String(settings['email.from_address'] || ''),
emailFromName: String(settings['email.from_name'] || ''), emailFromName: String(settings['email.from_name'] || ''),
verificationExpiryMinutes: Number(settings['email.verification_expiry_minutes']) || 30,
resetExpiryMinutes: Number(settings['email.reset_expiry_minutes']) || 30,
invitationExpiryHours: Number(settings['email.invitation_expiry_hours']) || 24,
verificationEmailSubject: String(settings['email.verification_subject'] || ''), verificationEmailSubject: String(settings['email.verification_subject'] || ''),
verificationEmailBody: String(settings['email.verification_body'] || ''), verificationEmailBody: String(settings['email.verification_body'] || ''),
verificationButtonAlignment: String(settings['email.verification_button_alignment'] || 'center'), verificationButtonAlignment: String(settings['email.verification_button_alignment'] || 'center'),
@@ -428,7 +431,10 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
const smtpPassword = String(req.body && req.body.smtp_password || ''); const smtpPassword = String(req.body && req.body.smtp_password || '');
const fromAddress = String(req.body && req.body.from_address || '').trim(); const fromAddress = String(req.body && req.body.from_address || '').trim();
const fromName = String(req.body && req.body.from_name || '').trim(); const fromName = String(req.body && req.body.from_name || '').trim();
if (smtpEnabled && (!smtpHost || !Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535 || !fromAddress)) { const verificationExpiryMinutes = Number(req.body && req.body.verification_expiry_minutes);
const resetExpiryMinutes = Number(req.body && req.body.reset_expiry_minutes);
const invitationExpiryHours = Number(req.body && req.body.invitation_expiry_hours);
if (!Number.isInteger(verificationExpiryMinutes) || verificationExpiryMinutes < 1 || verificationExpiryMinutes > 10080 || !Number.isInteger(resetExpiryMinutes) || resetExpiryMinutes < 1 || resetExpiryMinutes > 10080 || !Number.isInteger(invitationExpiryHours) || invitationExpiryHours < 1 || invitationExpiryHours > 720 || (smtpEnabled && (!smtpHost || !Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535 || !fromAddress))) {
const error = new Error('Enabled email delivery requires an SMTP host, valid port, and from address.'); const error = new Error('Enabled email delivery requires an SMTP host, valid port, and from address.');
error.statusCode = 400; error.statusCode = 400;
error.expose = true; error.expose = true;
@@ -442,7 +448,10 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
'email.smtp_username': smtpUsername, 'email.smtp_username': smtpUsername,
'email.smtp_password': smtpPassword || previousSettings['email.smtp_password'], 'email.smtp_password': smtpPassword || previousSettings['email.smtp_password'],
'email.from_address': fromAddress, 'email.from_address': fromAddress,
'email.from_name': fromName 'email.from_name': fromName,
'email.verification_expiry_minutes': verificationExpiryMinutes,
'email.reset_expiry_minutes': resetExpiryMinutes,
'email.invitation_expiry_hours': invitationExpiryHours
}, req.currentUser && req.currentUser.id); }, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section); await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Email settings saved.') + '#email-delivery'); return res.redirect('/settings/system?message=' + encodeURIComponent('Email settings saved.') + '#email-delivery');
+4 -4
View File
@@ -8,7 +8,7 @@
<div class="row g-4"> <div class="row g-4">
<div class="col-12 col-xl-6"> <div class="col-12 col-xl-6">
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Account details</h3> <h3 class="card-title">Account details</h3>
</div> </div>
<form id="account-profile-form" method="post" action="/account/profile" autocomplete="off" data-async-save data-async-save-refresh-page="true"> <form id="account-profile-form" method="post" action="/account/profile" autocomplete="off" data-async-save data-async-save-refresh-page="true">
@@ -36,7 +36,7 @@
{{#if allowUserSessionRevocation}} {{#if allowUserSessionRevocation}}
<div class="card card-outline card-secondary admin-form-card mt-3"> <div class="card card-outline card-secondary admin-form-card mt-3">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Active sessions</h3> <h3 class="card-title">Active sessions</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -74,7 +74,7 @@
<div class="col-12 col-xl-6"> <div class="col-12 col-xl-6">
<div class="card card-outline card-warning admin-form-card"> <div class="card card-outline card-warning admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Password</h3> <h3 class="card-title">Password</h3>
</div> </div>
<form id="account-password-form" method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true"> <form id="account-password-form" method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true">
@@ -93,7 +93,7 @@
</form> </form>
</div> </div>
<div class="card card-outline card-secondary admin-form-card mt-3"> <div class="card card-outline card-secondary admin-form-card mt-3">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Confirmation</h3> <h3 class="card-title">Confirmation</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -11,7 +11,7 @@
<div class="col-12"> <div class="col-12">
<form id="api-source-form" method="post" action="{{formAction}}" {{{formAttrs}}}> <form id="api-source-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Connection</h3> <h3 class="card-title">Connection</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -89,7 +89,7 @@
</div> </div>
</div> </div>
<div class="card card-outline card-info admin-form-card mt-3 overflow-hidden api-source-auth-card{{#if isEdit}} collapsed-card{{/if}}" data-api-source-auth-card hidden> <div class="card card-outline card-info admin-form-card mt-3 overflow-hidden api-source-auth-card{{#if isEdit}} collapsed-card{{/if}}" data-api-source-auth-card hidden>
<div class="card-header d-flex align-items-center"> <div class="card-header bg-body-tertiary d-flex align-items-center">
<h3 class="card-title mb-0">Authentication</h3> <h3 class="card-title mb-0">Authentication</h3>
<div class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0"> <div class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0">
<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-label="Toggle authentication settings" title="Toggle authentication settings"> <button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-label="Toggle authentication settings" title="Toggle authentication settings">
@@ -176,7 +176,7 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="card card-outline card-secondary api-source-response-card" id="api-source-response-panel"> <div class="card card-outline card-secondary api-source-response-card" id="api-source-response-panel">
<div class="card-header d-flex align-items-center"> <div class="card-header bg-body-tertiary d-flex align-items-center">
<h3 class="card-title mb-0">Latest response</h3> <h3 class="card-title mb-0">Latest response</h3>
{{#if lastResponseJson}} {{#if lastResponseJson}}
<div class="btn-group btn-group-sm ms-auto" role="group" aria-label="JSON tree actions"> <div class="btn-group btn-group-sm ms-auto" role="group" aria-label="JSON tree actions">
@@ -7,7 +7,7 @@
<form id="rss-feed-form" method="post" action="{{formAction}}" {{{formAttrs}}}> <form id="rss-feed-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">RSS feed details</h3> <h3 class="card-title">RSS feed details</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -47,7 +47,7 @@
{{#if isEdit}}<form id="rss-feed-toggle-form" method="post" action="/data-sources/rss-feeds/{{rssFeed.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}} {{#if isEdit}}<form id="rss-feed-toggle-form" method="post" action="/data-sources/rss-feeds/{{rssFeed.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
<div class="card card-outline card-secondary admin-form-card mt-4"> <div class="card card-outline card-secondary admin-form-card mt-4">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Latest items</h3> <h3 class="card-title">Latest items</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -7,7 +7,7 @@
<form id="timetable-group-form" method="post" action="{{formAction}}" {{{formAttrs}}}> <form id="timetable-group-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Timetable group details</h3> <h3 class="card-title">Timetable group details</h3>
</div> </div>
<div class="card-body d-grid gap-4 pb-0"> <div class="card-body d-grid gap-4 pb-0">
@@ -42,7 +42,7 @@
</div> </div>
<div class="card card-outline card-secondary admin-form-card mt-4"> <div class="card card-outline card-secondary admin-form-card mt-4">
<div class="card-header d-flex align-items-center"> <div class="card-header bg-body-tertiary d-flex align-items-center">
<h3 class="card-title mb-0 flex-grow-1">Entries</h3> <h3 class="card-title mb-0 flex-grow-1">Entries</h3>
<button type="button" class="btn btn-primary btn-sm ms-auto" data-add-timetable-entry>Add entry</button> <button type="button" class="btn btn-primary btn-sm ms-auto" data-add-timetable-entry>Add entry</button>
</div> </div>
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="col-12 col-xl-7"> <div class="col-12 col-xl-7">
<form id="weather-location-form" method="post" action="{{formAction}}" {{{formAttrs}}}> <form id="weather-location-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-header"><h3 class="card-title">Weather location details</h3></div> <div class="card-header bg-body-tertiary"><h3 class="card-title">Weather location details</h3></div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"><label for="weather-name" class="form-label">Name</label><input id="weather-name" name="name" class="form-control" value="{{weatherLocation.name}}" maxlength="255" required /></div> <div class="mb-3"><label for="weather-name" class="form-label">Name</label><input id="weather-name" name="name" class="form-control" value="{{weatherLocation.name}}" maxlength="255" required /></div>
<div class="mb-3 position-relative"><label for="weather-label" class="form-label">Location</label><input id="weather-label" name="location_label" class="form-control" value="{{weatherLocation.location_label}}" maxlength="255" placeholder="Start typing a town, city, or postcode" autocomplete="off" required /><div id="weather-location-results" class="list-group position-absolute w-100 d-none" style="z-index: 10;"></div><div class="form-text">Choose a result to fill the coordinates and timezone automatically.</div></div> <div class="mb-3 position-relative"><label for="weather-label" class="form-label">Location</label><input id="weather-label" name="location_label" class="form-control" value="{{weatherLocation.location_label}}" maxlength="255" placeholder="Start typing a town, city, or postcode" autocomplete="off" required /><div id="weather-location-results" class="list-group position-absolute w-100 d-none" style="z-index: 10;"></div><div class="form-text">Choose a result to fill the coordinates and timezone automatically.</div></div>
+2 -2
View File
@@ -8,8 +8,8 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-12 col-lg-7 col-xl-6"> <div class="col-12 col-lg-7 col-xl-6">
<div class="card card-outline card-primary admin-form-card onboarding-pairing-card" id="onboarding-pair-card"> <div class="card card-outline card-primary admin-form-card onboarding-pairing-card" id="onboarding-pair-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Screen setup</h3> <h3 class="card-title">Screen setup</h3>
</div> </div>
<form id="onboarding-pair-form" method="post" action="/pairing" novalidate> <form id="onboarding-pair-form" method="post" action="/pairing" novalidate>
<div class="card-body"> <div class="card-body">
+1 -1
View File
@@ -46,7 +46,7 @@
<td><div class="fw-semibold text-capitalize">{{eventLabel}}</div><div class="text-muted small">{{category}}</div></td> <td><div class="fw-semibold text-capitalize">{{eventLabel}}</div><div class="text-muted small">{{category}}</div></td>
<td>{{actorLabel}}</td> <td>{{actorLabel}}</td>
<td>{{targetLabelDisplay}}</td> <td>{{targetLabelDisplay}}</td>
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{user_agent}}</div></td> <td><div>{{ip_address}}</div><div class="text-muted small text-break">{{userAgentLabel}}</div></td>
<td class="text-break"> <td class="text-break">
{{#if auditDetailView.hasChanges}} {{#if auditDetailView.hasChanges}}
<div class="audit-change-list"> <div class="audit-change-list">
+3 -3
View File
@@ -12,7 +12,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-xl-4 d-flex flex-column gap-4"> <div class="col-12 col-xl-4 d-flex flex-column gap-4">
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Role details</h3> <h3 class="card-title">Role details</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -33,7 +33,7 @@
</div> </div>
<div class="card card-outline card-secondary admin-form-card" data-table-search-container data-table-pagination-card> <div class="card card-outline card-secondary admin-form-card" data-table-search-container data-table-pagination-card>
<div class="card-header d-flex flex-wrap align-items-center justify-content-between"> <div class="card-header bg-body-tertiary d-flex flex-wrap align-items-center justify-content-between">
<h3 class="card-title">Users</h3> <h3 class="card-title">Users</h3>
<div class="input-group input-group-sm flex-shrink-1 ms-auto table-search-group" style="width: min(14rem, 100%);"> <div class="input-group input-group-sm flex-shrink-1 ms-auto table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span> <span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
@@ -82,7 +82,7 @@
<div class="col-12 col-xl-8 d-flex flex-column gap-4"> <div class="col-12 col-xl-8 d-flex flex-column gap-4">
<div class="card card-outline card-secondary admin-form-card overflow-hidden"> <div class="card card-outline card-secondary admin-form-card overflow-hidden">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Permissions</h3> <h3 class="card-title">Permissions</h3>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
+7 -4
View File
@@ -282,6 +282,9 @@
<div class="col-12 col-md-4"><label class="form-label" for="settings-smtp-password">SMTP password</label><input id="settings-smtp-password" name="smtp_password" type="password" class="form-control" placeholder="{{#if mediaSettings.smtpConfigured}}Leave unchanged{{/if}}"></div> <div class="col-12 col-md-4"><label class="form-label" for="settings-smtp-password">SMTP password</label><input id="settings-smtp-password" name="smtp_password" type="password" class="form-control" placeholder="{{#if mediaSettings.smtpConfigured}}Leave unchanged{{/if}}"></div>
<div class="col-12 col-md-6"><label class="form-label" for="settings-from-address">From address</label><input id="settings-from-address" name="from_address" type="email" class="form-control" value="{{mediaSettings.emailFromAddress}}"></div> <div class="col-12 col-md-6"><label class="form-label" for="settings-from-address">From address</label><input id="settings-from-address" name="from_address" type="email" class="form-control" value="{{mediaSettings.emailFromAddress}}"></div>
<div class="col-12 col-md-6"><label class="form-label" for="settings-from-name">From name</label><input id="settings-from-name" name="from_name" type="text" class="form-control" value="{{mediaSettings.emailFromName}}"></div> <div class="col-12 col-md-6"><label class="form-label" for="settings-from-name">From name</label><input id="settings-from-name" name="from_name" type="text" class="form-control" value="{{mediaSettings.emailFromName}}"></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-verification-expiry">Verification expiry</label><div class="input-group"><input id="settings-verification-expiry" name="verification_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.verificationExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-reset-expiry">Password reset expiry</label><div class="input-group"><input id="settings-reset-expiry" name="reset_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.resetExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-invitation-expiry">Invitation expiry</label><div class="input-group"><input id="settings-invitation-expiry" name="invitation_expiry_hours" type="number" class="form-control" min="1" max="720" value="{{mediaSettings.invitationExpiryHours}}"><span class="input-group-text">hours</span></div></div>
</div></div> </div></div>
<div class="card-footer d-flex align-items-center"> <div class="card-footer d-flex align-items-center">
{{#if (hasPermission currentUser 'system-settings.update')}} {{#if (hasPermission currentUser 'system-settings.update')}}
@@ -300,7 +303,7 @@
<div id="email-templates" class="card settings-section-card" data-settings-section hidden> <div id="email-templates" class="card settings-section-card" data-settings-section hidden>
<div class="card-header"><h3 class="h5 mb-1 fw-semibold"><i class="bi bi-envelope-paper me-2 text-primary" aria-hidden="true"></i>Email templates</h3><p class="text-muted small mb-0">Customize the messages sent for email verification and password resets.</p></div> <div class="card-header"><h3 class="h5 mb-1 fw-semibold"><i class="bi bi-envelope-paper me-2 text-primary" aria-hidden="true"></i>Email templates</h3><p class="text-muted small mb-0">Customize the messages sent for email verification and password resets.</p></div>
<div class="card-body"> <div class="card-body">
<div data-email-preview-user data-username="{{currentUser.username}}" data-display-name="{{currentUser.name}}" data-email="{{currentUser.email}}" hidden></div> <div data-email-preview-user data-username="{{currentUser.username}}" data-display-name="{{currentUser.name}}" data-email="{{currentUser.email}}" data-verification-expiry="{{mediaSettings.verificationExpiryMinutes}}" data-reset-expiry="{{mediaSettings.resetExpiryMinutes}}" data-invitation-expiry="{{mediaSettings.invitationExpiryHours}}" hidden></div>
<div class="email-template-editor" data-email-template-editor> <div class="email-template-editor" data-email-template-editor>
<div class="row g-4 align-items-start"> <div class="row g-4 align-items-start">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
@@ -314,7 +317,7 @@
<div class="col-12 col-md-8"><label class="form-label" for="settings-verification-button-text">Button text</label><input id="settings-verification-button-text" name="verification_button_text" type="text" class="form-control" value="{{mediaSettings.verificationButtonText}}" maxlength="120" data-email-template-button-text></div> <div class="col-12 col-md-8"><label class="form-label" for="settings-verification-button-text">Button text</label><input id="settings-verification-button-text" name="verification_button_text" type="text" class="form-control" value="{{mediaSettings.verificationButtonText}}" maxlength="120" data-email-template-button-text></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-verification-alignment">Alignment</label><select id="settings-verification-alignment" name="verification_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.verificationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.verificationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.verificationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div> <div class="col-12 col-md-4"><label class="form-label" for="settings-verification-alignment">Alignment</label><select id="settings-verification-alignment" name="verification_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.verificationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.verificationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.verificationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
</div> </div>
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the verification link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>.</div> <div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the verification link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="email-template-preview" data-email-template-preview aria-label="Email verification preview"></div> <div class="email-template-preview" data-email-template-preview aria-label="Email verification preview"></div>
@@ -335,7 +338,7 @@
<div class="col-12 col-md-8"><label class="form-label" for="settings-reset-button-text">Button text</label><input id="settings-reset-button-text" name="reset_button_text" type="text" class="form-control" value="{{mediaSettings.resetButtonText}}" maxlength="120" data-email-template-button-text></div> <div class="col-12 col-md-8"><label class="form-label" for="settings-reset-button-text">Button text</label><input id="settings-reset-button-text" name="reset_button_text" type="text" class="form-control" value="{{mediaSettings.resetButtonText}}" maxlength="120" data-email-template-button-text></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-reset-alignment">Alignment</label><select id="settings-reset-alignment" name="reset_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.resetButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.resetButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.resetButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div> <div class="col-12 col-md-4"><label class="form-label" for="settings-reset-alignment">Alignment</label><select id="settings-reset-alignment" name="reset_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.resetButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.resetButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.resetButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
</div> </div>
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the reset link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>.</div> <div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the reset link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="email-template-preview" data-email-template-preview aria-label="Password reset preview"></div> <div class="email-template-preview" data-email-template-preview aria-label="Password reset preview"></div>
@@ -356,7 +359,7 @@
<div class="col-12 col-md-8"><label class="form-label" for="settings-invitation-button-text">Button text</label><input id="settings-invitation-button-text" name="invitation_button_text" type="text" class="form-control" value="{{mediaSettings.invitationButtonText}}" maxlength="120" data-email-template-button-text></div> <div class="col-12 col-md-8"><label class="form-label" for="settings-invitation-button-text">Button text</label><input id="settings-invitation-button-text" name="invitation_button_text" type="text" class="form-control" value="{{mediaSettings.invitationButtonText}}" maxlength="120" data-email-template-button-text></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-invitation-alignment">Alignment</label><select id="settings-invitation-alignment" name="invitation_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.invitationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.invitationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.invitationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div> <div class="col-12 col-md-4"><label class="form-label" for="settings-invitation-alignment">Alignment</label><select id="settings-invitation-alignment" name="invitation_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.invitationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.invitationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.invitationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
</div> </div>
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the invitation link. Available values: <code>[[display_name]]</code>, <code>[[email]]</code>.</div> <div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the invitation link. Available values: <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
</div> </div>
<div class="col-12 col-lg-6"><div class="email-template-preview" data-email-template-preview aria-label="User invitation preview"></div></div> <div class="col-12 col-lg-6"><div class="email-template-preview" data-email-template-preview aria-label="User invitation preview"></div></div>
</div> </div>
+4 -4
View File
@@ -9,7 +9,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-xl-4"> <div class="col-12 col-xl-4">
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">{{#if isEdit}}Profile{{else}}User details{{/if}}</h3> <h3 class="card-title">{{#if isEdit}}Profile{{else}}User details{{/if}}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -55,7 +55,7 @@
</div> </div>
<div class="card card-outline card-warning admin-form-card mt-3"> <div class="card card-outline card-warning admin-form-card mt-3">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">{{#if isEdit}}Reset password{{else}}Password{{/if}}</h3> <h3 class="card-title">{{#if isEdit}}Reset password{{else}}Password{{/if}}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -79,7 +79,7 @@
<div class="col-12 col-xl-8"> <div class="col-12 col-xl-8">
<div class="card card-outline card-secondary admin-form-card"> <div class="card card-outline card-secondary admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Roles</h3> <h3 class="card-title">Roles</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -111,7 +111,7 @@
{{#if isEdit}} {{#if isEdit}}
<div class="card card-outline card-secondary admin-form-card mt-3"> <div class="card card-outline card-secondary admin-form-card mt-3">
<div class="card-header d-flex align-items-center justify-content-between"> <div class="card-header bg-body-tertiary d-flex align-items-center justify-content-between">
<h3 class="card-title">Active sessions</h3> <h3 class="card-title">Active sessions</h3>
{{#if sessions.length}} {{#if sessions.length}}
<form method="post" action="/settings/users/{{user.id}}/sessions/revoke-all" class="ms-auto flex-shrink-0" data-confirm-message="Sign out all sessions for this user?"> <form method="post" action="/settings/users/{{user.id}}/sessions/revoke-all" class="ms-auto flex-shrink-0" data-confirm-message="Sign out all sessions for this user?">
+2 -2
View File
@@ -9,7 +9,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-xl-4"> <div class="col-12 col-xl-4">
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Invitation details</h3> <h3 class="card-title">Invitation details</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -36,7 +36,7 @@
<div class="col-12 col-xl-8"> <div class="col-12 col-xl-8">
<div class="card card-outline card-secondary admin-form-card"> <div class="card card-outline card-secondary admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Roles</h3> <h3 class="card-title">Roles</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
+2 -2
View File
@@ -9,7 +9,7 @@
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-12 col-xl-7"> <div class="col-12 col-xl-7">
<div class="card card-outline card-primary admin-form-card h-100 mb-0"> <div class="card card-outline card-primary admin-form-card h-100 mb-0">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Content</h3> <h3 class="card-title">Content</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -122,7 +122,7 @@
</div> </div>
</div> </div>
<div class="card card-outline card-secondary admin-form-card mb-4"> <div class="card card-outline card-secondary admin-form-card mb-4">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Screen Groups</h3> <h3 class="card-title">Screen Groups</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
+1 -1
View File
@@ -7,7 +7,7 @@
<form id="canvas-size-form" method="post" action="{{formAction}}" {{{formAttrs}}}> <form id="canvas-size-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Canvas size details</h3> <h3 class="card-title">Canvas size details</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
+3 -3
View File
@@ -8,7 +8,7 @@
<div class="row g-4 mb-4 align-items-stretch"> <div class="row g-4 mb-4 align-items-stretch">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="card card-outline card-primary admin-form-card h-100"> <div class="card card-outline card-primary admin-form-card h-100">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Playlist details</h3> <h3 class="card-title">Playlist details</h3>
</div> </div>
<form id="playlist-edit-form" method="post" action="{{formAction}}" {{{formAttrs}}}> <form id="playlist-edit-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
@@ -38,7 +38,7 @@
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="card card-outline card-light h-100 mb-0"> <div class="card card-outline card-light h-100 mb-0">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h4 class="card-title">Options</h4> <h4 class="card-title">Options</h4>
</div> </div>
<div class="card-body py-3"> <div class="card-body py-3">
@@ -58,7 +58,7 @@
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-12"> <div class="col-12">
<div class="card card-outline card-secondary mb-0"> <div class="card card-outline card-secondary mb-0">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Playlist items</h3> <h3 class="card-title">Playlist items</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2"> <div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<button type="button" class="btn btn-primary btn-sm" id="playlist-open-slide-modal" data-bs-toggle="modal" data-bs-target="#playlist-add-slide-modal" {{#unless playlistCanvasId}}disabled{{/unless}}>Add slide</button> <button type="button" class="btn btn-primary btn-sm" id="playlist-open-slide-modal" data-bs-toggle="modal" data-bs-target="#playlist-add-slide-modal" {{#unless playlistCanvasId}}disabled{{/unless}}>Add slide</button>
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="row g-4"> <div class="row g-4">
<div class="col-12"> <div class="col-12">
<div class="card card-outline card-primary admin-form-card h-100"> <div class="card card-outline card-primary admin-form-card h-100">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Screen group details</h3> <h3 class="card-title">Screen group details</h3>
</div> </div>
<form id="screen-form" method="post" action="{{formAction}}" {{{formAttrs}}}> <form id="screen-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
+2 -2
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" /> <link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
<form method="post" action="{{action}}" enctype="multipart/form-data" id="slide-form" class="slide-form-stack" {{{formAttrs}}}> <form method="post" action="{{action}}" enctype="multipart/form-data" id="slide-form" class="slide-form-stack" {{{formAttrs}}}>
<div class="card card-outline card-primary admin-form-card slide-template-card"> <div class="card card-outline card-primary admin-form-card slide-template-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Slide details</h3> <h3 class="card-title">Slide details</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -54,7 +54,7 @@
</div> </div>
<aside class="card card-outline card-secondary admin-form-card slide-preview-card"> <aside class="card card-outline card-secondary admin-form-card slide-preview-card">
<div class="card-header d-flex align-items-center justify-content-between"> <div class="card-header bg-body-tertiary d-flex align-items-center justify-content-between">
<div class="slide-preview-header"> <div class="slide-preview-header">
<h3>Preview</h3> <h3>Preview</h3>
</div> </div>
+5 -5
View File
@@ -16,7 +16,7 @@
<div class="template-designer-layout"> <div class="template-designer-layout">
<div class="card card-outline card-secondary admin-form-card mb-3"> <div class="card card-outline card-secondary admin-form-card mb-3">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Designer</h3> <h3 class="card-title">Designer</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -45,7 +45,7 @@
</div> </div>
</div> </div>
<div class="card card-outline card-info admin-form-card region-info-card mb-3"> <div class="card card-outline card-info admin-form-card region-info-card mb-3">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<div> <div>
<h3 class="card-title">Region information</h3> <h3 class="card-title">Region information</h3>
</div> </div>
@@ -65,7 +65,7 @@
<div class="row g-4 mb-4 align-items-stretch"> <div class="row g-4 mb-4 align-items-stretch">
<div class="col-12 col-xl-4"> <div class="col-12 col-xl-4">
<div class="card card-outline card-primary admin-form-card h-100 template-details-card"> <div class="card card-outline card-primary admin-form-card h-100 template-details-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Template details</h3> <h3 class="card-title">Template details</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -93,7 +93,7 @@
</div> </div>
<div class="col-12 col-xl-8"> <div class="col-12 col-xl-8">
<div class="card card-outline card-primary admin-form-card h-100 template-options-card"> <div class="card card-outline card-primary admin-form-card h-100 template-options-card">
<div class="card-header"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Options</h3> <h3 class="card-title">Options</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -154,7 +154,7 @@
<template id="region-card-template"> <template id="region-card-template">
<div class="card card-outline card-secondary admin-form-card region-item"> <div class="card card-outline card-secondary admin-form-card region-item">
<div class="card-header template-field-head d-flex align-items-center flex-nowrap gap-2"><strong class="flex-grow-1 text-truncate" data-region-title>Region</strong><span class="chip ms-auto" data-region-chip>Text</span></div> <div class="card-header bg-body-tertiary template-field-head d-flex align-items-center flex-nowrap gap-2"><strong class="flex-grow-1 text-truncate" data-region-title>Region</strong><span class="chip ms-auto" data-region-chip>Text</span></div>
<div class="card-body p-3 d-grid gap-3 pb-0"> <div class="card-body p-3 d-grid gap-3 pb-0">
<div class="region-field-grid region-field-grid--identity"> <div class="region-field-grid region-field-grid--identity">
<label>Region name<input class="form-control" name="region_name[]" value="" maxlength="64" data-limit-text-length placeholder="type_1" required /></label> <label>Region name<input class="form-control" name="region_name[]" value="" maxlength="64" data-limit-text-length placeholder="type_1" required /></label>
+25
View File
@@ -0,0 +1,25 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, formatAccountEmailExpiry, renderAccountEmailTemplate } = require('../src/data/account-email-templates');
test('account email expiry values render with singular and plural units', () => {
assert.equal(formatAccountEmailExpiry(1, 'minutes'), '1 minute');
assert.equal(formatAccountEmailExpiry(30, 'minutes'), '30 minutes');
assert.equal(formatAccountEmailExpiry(24, 'hours'), '24 hours');
assert.equal(renderAccountEmailTemplate('Subject [[expiry_time]]', 'Expires in [[expiry_time]].', { expiry_time: '45 minutes' }).text, 'Expires in 45 minutes.');
});
test('default account email templates render both the action button and inline link', () => {
const rendered = renderAccountEmailTemplate('Subject', DEFAULT_ACCOUNT_EMAIL_TEMPLATES.verificationBody, {
url: 'https://example.test/verify',
display_name: 'Alex',
expiry_time: '30 minutes',
action_label: 'Verify email address'
});
assert.match(rendered.html, /<strong>Alex<\/strong>/);
assert.match(rendered.html, /This <u>link<\/u> expires in <em>30 minutes<\/em>\./);
assert.match(rendered.html, /Confirm this email address: <a href="https:\/\/example\.test\/verify">https:\/\/example\.test\/verify<\/a>/);
assert.doesNotMatch(rendered.html, /\[\[(?:action_button|url)\]\]/);
});
+3
View File
@@ -24,6 +24,7 @@ test('audit log uses its own read permission and shared pagination partial', asy
category: 'authentication', category: 'authentication',
event_type: 'login.success', event_type: 'login.success',
target_label: 'newer', target_label: 'newer',
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
details_json: JSON.stringify({ details_json: JSON.stringify({
changes: { changes: {
content: { content: {
@@ -86,6 +87,8 @@ test('audit log uses its own read permission and shared pagination partial', asy
assert.match(response.body, /data-local-datetime/); assert.match(response.body, /data-local-datetime/);
assert.match(response.body, /audit-event-type-options/); assert.match(response.body, /audit-event-type-options/);
assert.match(response.body, /login\.success/); assert.match(response.body, /login\.success/);
assert.match(response.body, />Chrome 150\.0\.0\.0 on Windows</);
assert.doesNotMatch(response.body, /Mozilla\/5\.0 \(Windows NT 10\.0; Win64; x64\)/);
assert.match(response.body, /content\.body\.color/); assert.match(response.body, /content\.body\.color/);
assert.match(response.body, />red</); assert.match(response.body, />red</);
assert.match(response.body, />blue</); assert.match(response.body, />blue</);
+72
View File
@@ -12,6 +12,14 @@ const {
const renderTemplateAddPage = require('../src/web/routes/signage/templates/add'); const renderTemplateAddPage = require('../src/web/routes/signage/templates/add');
const renderTemplateEditPage = require('../src/web/routes/signage/templates/edit'); const renderTemplateEditPage = require('../src/web/routes/signage/templates/edit');
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../src/web/routes/signage/templates/duplicate'); const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../src/web/routes/signage/templates/duplicate');
const { buildThumbnailPreviewPayload } = require('../src/web/lib/media/slide-thumbnail-preview');
function readCssDeclarations(css, selector) {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(escapedSelector + '\\s*\\{([^}]*)\\}'));
assert.ok(match, `CSS rule not found: ${selector}`);
return Object.fromEntries(match[1].split(';').map((declaration) => declaration.trim().split(':')).filter(([property, value]) => property && value).map(([property, ...value]) => [property.trim(), value.join(':').trim()]));
}
test('extractTemplateRegions normalizes JSON regions and filters invalid rows', () => { test('extractTemplateRegions normalizes JSON regions and filters invalid rows', () => {
const regions = extractTemplateRegions({ const regions = extractTemplateRegions({
@@ -175,6 +183,70 @@ test('template edit page confirms deletion', () => {
assert.doesNotMatch(html, /id="delete-template-form"/); assert.doesNotMatch(html, /id="delete-template-form"/);
}); });
test('template preview preserves the live canvas aspect and visual settings', () => {
const template = {
id: 12,
name: 'Portrait-ish display',
canvas_size_id: 3,
canvas_size_width: 1440,
canvas_size_height: 900,
background_image_path: '/media/uploads/background.png',
background_color: '#123456',
background_gradient: JSON.stringify({
type: 'linear',
angle: 135,
stops: [
{ color: '#123456', position: 0 },
{ color: '#abcdef', position: 100 }
]
}),
regions: [{
region_key: 'hero',
region_type: 'text',
label: 'Hero',
x: 144,
y: 90,
width: 720,
height: 450,
z_index: 2
}],
region_usage: []
};
const editorHtml = renderTemplateEditPage(template, {
canvasSizes: [{ id: 3, name: 'Custom', width: 1440, height: 900 }]
}, '', { permissionKeys: ['templates.update'] });
const livePreview = buildThumbnailPreviewPayload({
template,
content: { hero: { type: 'text', value: 'Preview content' } }
}, { baseUrl: 'https://signage.test' });
assert.match(editorHtml, /style="aspect-ratio: 1440 \/ 900;"/);
assert.deepEqual(
{ width: 1440, height: 900 },
{ width: livePreview.canvasWidth, height: livePreview.canvasHeight }
);
assert.equal(livePreview.backgroundColor, template.background_color);
assert.equal(livePreview.backgroundGradient, template.background_gradient);
assert.equal(livePreview.backgroundImagePath, template.background_image_path);
assert.match(livePreview.html, /left:10%;top:10%;width:50%;height:50%;z-index:2;/);
});
test('template preview and live output keep matching visual CSS contracts', () => {
const previewCss = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
const playerCss = fs.readFileSync(require.resolve('../src/player/public/css/player.css'), 'utf8');
const layoutProperties = ['position', 'overflow', 'box-sizing'];
const mediaProperties = ['width', 'height', 'object-fit', 'display'];
assert.deepEqual(
Object.fromEntries(layoutProperties.map((property) => [property, readCssDeclarations(previewCss, '.slide-preview-region')[property]])),
Object.fromEntries(layoutProperties.map((property) => [property, readCssDeclarations(playerCss, '.template-region')[property]]))
);
assert.deepEqual(
Object.fromEntries(mediaProperties.map((property) => [property, readCssDeclarations(previewCss, '.slide-preview-image')[property]])),
Object.fromEntries(mediaProperties.map((property) => [property, readCssDeclarations(playerCss, '.template-region.image img')[property]]))
);
});
test('template list template includes duplicate action', () => { test('template list template includes duplicate action', () => {
const template = fs.readFileSync(require.resolve('../src/web/views/signage/templates/list.hbs'), 'utf8'); const template = fs.readFileSync(require.resolve('../src/web/views/signage/templates/list.hbs'), 'utf8');