release: v2.11.2
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 2m0s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
lzstealth committed 2026-09-05 18:14:38 +01:00
1 parent 7fec2154e0
commit c4ae69d25f
40 files changed
+313 -351

No files matched your search

+9
View File
@@ -2,6 +2,15 @@
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
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.11.1",
"version": "2.11.2",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.11.1",
"version": "2.11.2",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.11.1",
"version": "2.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.11.1",
"version": "2.11.2",
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.11.1",
"version": "2.11.2",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
+12 -4
View File
@@ -1,10 +1,17 @@
const DEFAULT_ACCOUNT_EMAIL_TEMPLATES = {
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',
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) {
const values = variables || {};
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 actionBlock = actionButton ? '<div style="margin:24px 0;text-align:' + actionAlignment + ';">' + actionButton + '</div>' : '';
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 hasUrlPlaceholder = String(body || '').indexOf('[[url]]') !== -1;
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
? escapedBody.split(actionUrl).join(actionBlock + 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 };
}
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.from_address', type: 'string', defaultValue: '' },
{ 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_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_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_text', type: 'string', defaultValue: 'Verify email address' },
{ 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.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_text', type: 'string', defaultValue: 'Accept invitation' },
{ 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');
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) {
if (details === undefined || details === null) {
return null;
@@ -136,6 +157,7 @@ module.exports = {
AUDIT_CATEGORY_LABELS,
SCREEN_CONTROL_COMMAND_KEYS,
SCREEN_CONTROL_COMMAND_LABELS,
formatUserAgentLabel,
getRequestMetadata,
buildAuditChanges,
recordAuditEvent,
+1 -1
View File
@@ -1,5 +1,5 @@
@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
* get fourteen additional colours (orange, amber, olive, teal, sky, indigo,
* violet, fuchsia, pink, navy, steel, slate, graphite, midnight) as `--bs-*`
@@ -1 +1 @@
{"version":3,"sources":["../../src/scss/adminlte-colors.scss","../../src/scss/colors/_colors.scss","dist/css/adminlte-colors.css","../../node_modules/bootstrap/scss/mixins/_color-mode.scss","../../src/scss/colors/_primary.scss"],"names":[],"mappings":"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 truncated
{"version":3,"sources":["../../src/scss/adminlte-colors.scss","../../src/scss/colors/_colors.scss","dist/css/adminlte-colors.css","../../node_modules/bootstrap/scss/mixins/_color-mode.scss","../../src/scss/colors/_primary.scss"],"names":[],"mappings":"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 truncated
+2 -2
View File
@@ -1,7 +1,7 @@
@charset "UTF-8";/*!
* AdminLTE v4.8.5
* AdminLTE v4.9.1
* Author: Colorlib
* Website: AdminLTE.io <https://adminlte.io>
* License: Open source - MIT <https://opensource.org/licenses/MIT>
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:rgb(2.0392156863%, 17.2549019608%, 39.6862745098%);--bs-secondary-text-emphasis:rgb(16.9411764706%, 18.3529411765%, 19.6078431373%);--bs-success-text-emphasis:rgb(3.9215686275%, 21.1764705882%, 13.1764705882%);--bs-info-text-emphasis:rgb(2.0392156863%, 31.6862745098%, 37.6470588235%);--bs-warning-text-emphasis:rgb(40%, 30.2745098039%, 1.0980392157%);--bs-danger-text-emphasis:rgb(34.5098039216%, 8.3137254902%, 10.8235294118%);--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:rgb(81.0196078431%, 88.6274509804%, 99.8431372549%);--bs-secondary-bg-subtle:rgb(88.4705882353%, 89.1764705882%, 89.8039215686%);--bs-success-bg-subtle:rgb(81.9607843137%, 90.5882352941%, 86.5882352941%);--bs-info-bg-subtle:rgb(81.0196078431%, 95.8431372549%, 98.8235294118%);--bs-warning-bg-subtle:rgb(100%, 95.137254902%, 80.5490196078%);--bs-danger-bg-subtle:rgb(97.2549019608%, 84.1568627451%, 85.4117647059%);--bs-light-bg-subtle:rgb(98.6274509804%, 98.8235294118%, 99.0196078431%);--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:rgb(62.0392156863%, 77.2549019608%, 99.6862745098%);--bs-secondary-border-subtle:rgb(76.9411764706%, 78.3529411765%, 79.6078431373%);--bs-success-border-subtle:rgb(63.9215686275%, 81.1764705882%, 73.1764705882%);--bs-info-border-subtle:rgb(62.0392156863%, 91.6862745098%, 97.6470588235%);--bs-warning-border-subtle:rgb(100%, 90.2745098039%, 61.0980392157%);--bs-danger-border-subtle:rgb(94.5098039216%, 68.3137254902%, 70.8235294118%);--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:"Source Sans 3",system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:rgb(4.0784313725%, 34.5098039216%, 79.3725490196%);--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:rgb(100%, 95.137254902%, 80.5490196078%);--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiaLine truncated
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:rgb(2.0392156863%, 17.2549019608%, 39.6862745098%);--bs-secondary-text-emphasis:rgb(16.9411764706%, 18.3529411765%, 19.6078431373%);--bs-success-text-emphasis:rgb(3.9215686275%, 21.1764705882%, 13.1764705882%);--bs-info-text-emphasis:rgb(2.0392156863%, 31.6862745098%, 37.6470588235%);--bs-warning-text-emphasis:rgb(40%, 30.2745098039%, 1.0980392157%);--bs-danger-text-emphasis:rgb(34.5098039216%, 8.3137254902%, 10.8235294118%);--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:rgb(81.0196078431%, 88.6274509804%, 99.8431372549%);--bs-secondary-bg-subtle:rgb(88.4705882353%, 89.1764705882%, 89.8039215686%);--bs-success-bg-subtle:rgb(81.9607843137%, 90.5882352941%, 86.5882352941%);--bs-info-bg-subtle:rgb(81.0196078431%, 95.8431372549%, 98.8235294118%);--bs-warning-bg-subtle:rgb(100%, 95.137254902%, 80.5490196078%);--bs-danger-bg-subtle:rgb(97.2549019608%, 84.1568627451%, 85.4117647059%);--bs-light-bg-subtle:rgb(98.6274509804%, 98.8235294118%, 99.0196078431%);--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:rgb(62.0392156863%, 77.2549019608%, 99.6862745098%);--bs-secondary-border-subtle:rgb(76.9411764706%, 78.3529411765%, 79.6078431373%);--bs-success-border-subtle:rgb(63.9215686275%, 81.1764705882%, 73.1764705882%);--bs-info-border-subtle:rgb(62.0392156863%, 91.6862745098%, 97.6470588235%);--bs-warning-border-subtle:rgb(100%, 90.2745098039%, 61.0980392157%);--bs-danger-border-subtle:rgb(94.5098039216%, 68.3137254902%, 70.8235294118%);--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:"Source Sans 3",system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:rgb(4.0784313725%, 34.5098039216%, 79.3725490196%);--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:rgb(100%, 95.137254902%, 80.5490196078%);--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiaLine truncated
/*# sourceMappingURL=adminlte.min.css.map */
@@ -1 +1 @@
{"version":3,"sources":["../../src/scss/adminlte.scss","../../node_modules/bootstrap/scss/_root.scss","dist/css/adminlte.css","../../node_modules/bootstrap/scss/vendor/_rfs.scss","../../node_modules/bootstrap/scss/mixins/_color-mode.scss","../../node_modules/bootstrap/scss/_reboot.scss","../../node_modules/bootstrap/scss/mixins/_border-radius.scss","../../node_modules/bootstrap/scss/_type.scss","../../node_modules/bootstrap/scss/mixins/_lists.scss","../../node_modules/bootstrap/scss/_images.scss","../../node_modules/bootstrap/scss/mixins/_image.scss","../../node_modules/bootstrap/scss/mixins/_box-shadow.scss","../../node_modules/bootstrap/scss/_containers.scss","../../node_modules/bootstrap/scss/mixins/_container.scss","../../node_modules/bootstrap/scss/mixins/_breakpoints.scss","../../node_modules/bootstrap/scss/_grid.scss","../../node_modules/bootstrap/scss/mixins/_grid.scss","../../node_modules/bootstrap/scss/_tables.scss","../../node_modules/bootstrap/scss/mixins/_table-variants.scss","../../node_modules/bootstrap/scss/forms/_labels.scss","../../node_modules/bootstrap/scss/forms/_form-text.scss","../../node_modules/bootstrap/scss/forms/_form-control.scss","../../node_modules/bootstrap/scss/mixins/_transition.scss","../../node_modules/bootstrap/scss/mixins/_gradients.scss","../../node_modules/bootstrap/scss/forms/_form-select.scss","../../node_modules/bootstrap/scss/forms/_form-check.scss","../../node_modules/bootstrap/scss/forms/_form-range.scss","../../node_modules/bootstrap/scss/forms/_floating-labels.scss","../../node_modules/bootstrap/scss/forms/_input-group.scss","../../node_modules/bootstrap/scss/mixins/_forms.scss","../../node_modules/bootstrap/scss/_buttons.scss","../../node_modules/bootstrap/scss/mixins/_buttons.scss","../../node_modules/bootstrap/scss/_transitions.scss","../../node_modules/bootstrap/scss/_dropdown.scss","../../node_modules/bootstrap/scss/mixins/_caret.scss","../../node_modules/bootstrap/scss/_button-group.scss","../../node_modules/bootstrap/scss/_nav.scss","../../node_modules/bootstrap/scss/_navbar.scss","../../node_modules/bootstrap/scss/_card.scss","../../node_modules/bootstrap/scss/_accordion.scss","../../node_modules/bootstrap/scss/_breadcrumb.scss","../../node_modules/bootstrap/scss/_pagination.scss","../../node_modules/bootstrap/scss/mixins/_pagination.scss","../../node_modules/bootstrap/scss/_badge.scss","../../node_modules/bootstrap/scss/_alert.scss","../../node_modules/bootstrap/scss/_progress.scss","../../node_modules/bootstrap/scss/_list-group.scss","../../node_modules/bootstrap/scss/_close.scss","../../node_modules/bootstrap/scss/_toasts.scss","../../node_modules/bootstrap/scss/_modal.scss","../../node_modules/bootstrap/scss/mixins/_backdrop.scss","../../node_modules/bootstrap/scss/_tooltip.scss","../../node_modules/bootstrap/scss/mixins/_reset-text.scss","../../node_modules/bootstrap/scss/_popover.scss","../../node_modules/bootstrap/scss/_carousel.scss","../../node_modules/bootstrap/scss/mixins/_clearfix.scss","../../node_modules/bootstrap/scss/_spinners.scss","../../node_modules/bootstrap/scss/_offcanvas.scss","../../node_modules/bootstrap/scss/_placeholders.scss","../../node_modules/bootstrap/scss/helpers/_color-bg.scss","../../node_modules/bootstrap/scss/helpers/_colored-links.scss","../../node_modules/bootstrap/scss/helpers/_focus-ring.scss","../../node_modules/bootstrap/scss/helpers/_icon-link.scss","../../node_modules/bootstrap/scss/helpers/_ratio.scss","../../node_modules/bootstrap/scss/helpers/_position.scss","../../node_modules/bootstrap/scss/helpers/_stacks.scss","../../node_modules/bootstrap/scss/helpers/_visually-hidden.scss","../../node_modules/bootstrap/scss/mixins/_visually-hidden.scss","../../node_modules/bootstrap/scss/helpers/_stretched-link.scss","../../node_modules/bootstrap/scss/helpers/_text-truncation.scss","../../node_modules/bootstrap/scss/mixins/_text-truncate.scss","../../node_modules/bootstrap/scss/helpers/_vr.scss","../../node_modules/bootstrap/scss/mixins/_utilities.scss","../../node_modules/bootstrap/scss/utilities/_api.scss","../../src/scss/mixins/_animations.scss","../../src/scss/_root.scss","../../src/scss/_app-wrapper.scss","../../src/scss/_app-content.scss","../../src/scss/_app-header.scss","../../src/scss/mixins/_search-field.scss","../../src/scss/_app-sidebar.scss","../../src/scss/mixins/_scrollbar.scss","../../src/scss/_app-main.scss","../../src/scss/_app-footer.scss","../../src/scss/_dropdown.scss","../../src/scss/_callouts.scss","../../src/scss/_compact-mode.scss","../../src/scss/_progress-bars.scss","../../src/scss/_cards.scss","../../src/scss/_table.scss","../../src/scss/_small-box.scss","../../src/scss/_info-box.scss","../../src/scss/_timeline.scss","../../src/scss/_direct-chat.scss","../../src/scss/_toasts.scss","../../src/scss/_ribbon.scss","../../src/scss/_widgets.scss","../../src/scss/pages/_login_and_register.scss","../../src/scss/pages/_lockscreen.scss","../../src/scss/_miscellaneous.scss","../../src/scss/_accessiLine truncated
{"version":3,"sources":["../../src/scss/adminlte.scss","../../node_modules/bootstrap/scss/_root.scss","dist/css/adminlte.css","../../node_modules/bootstrap/scss/vendor/_rfs.scss","../../node_modules/bootstrap/scss/mixins/_color-mode.scss","../../node_modules/bootstrap/scss/_reboot.scss","../../node_modules/bootstrap/scss/mixins/_border-radius.scss","../../node_modules/bootstrap/scss/_type.scss","../../node_modules/bootstrap/scss/mixins/_lists.scss","../../node_modules/bootstrap/scss/_images.scss","../../node_modules/bootstrap/scss/mixins/_image.scss","../../node_modules/bootstrap/scss/mixins/_box-shadow.scss","../../node_modules/bootstrap/scss/_containers.scss","../../node_modules/bootstrap/scss/mixins/_container.scss","../../node_modules/bootstrap/scss/mixins/_breakpoints.scss","../../node_modules/bootstrap/scss/_grid.scss","../../node_modules/bootstrap/scss/mixins/_grid.scss","../../node_modules/bootstrap/scss/_tables.scss","../../node_modules/bootstrap/scss/mixins/_table-variants.scss","../../node_modules/bootstrap/scss/forms/_labels.scss","../../node_modules/bootstrap/scss/forms/_form-text.scss","../../node_modules/bootstrap/scss/forms/_form-control.scss","../../node_modules/bootstrap/scss/mixins/_transition.scss","../../node_modules/bootstrap/scss/mixins/_gradients.scss","../../node_modules/bootstrap/scss/forms/_form-select.scss","../../node_modules/bootstrap/scss/forms/_form-check.scss","../../node_modules/bootstrap/scss/forms/_form-range.scss","../../node_modules/bootstrap/scss/forms/_floating-labels.scss","../../node_modules/bootstrap/scss/forms/_input-group.scss","../../node_modules/bootstrap/scss/mixins/_forms.scss","../../node_modules/bootstrap/scss/_buttons.scss","../../node_modules/bootstrap/scss/mixins/_buttons.scss","../../node_modules/bootstrap/scss/_transitions.scss","../../node_modules/bootstrap/scss/_dropdown.scss","../../node_modules/bootstrap/scss/mixins/_caret.scss","../../node_modules/bootstrap/scss/_button-group.scss","../../node_modules/bootstrap/scss/_nav.scss","../../node_modules/bootstrap/scss/_navbar.scss","../../node_modules/bootstrap/scss/_card.scss","../../node_modules/bootstrap/scss/_accordion.scss","../../node_modules/bootstrap/scss/_breadcrumb.scss","../../node_modules/bootstrap/scss/_pagination.scss","../../node_modules/bootstrap/scss/mixins/_pagination.scss","../../node_modules/bootstrap/scss/_badge.scss","../../node_modules/bootstrap/scss/_alert.scss","../../node_modules/bootstrap/scss/_progress.scss","../../node_modules/bootstrap/scss/_list-group.scss","../../node_modules/bootstrap/scss/_close.scss","../../node_modules/bootstrap/scss/_toasts.scss","../../node_modules/bootstrap/scss/_modal.scss","../../node_modules/bootstrap/scss/mixins/_backdrop.scss","../../node_modules/bootstrap/scss/_tooltip.scss","../../node_modules/bootstrap/scss/mixins/_reset-text.scss","../../node_modules/bootstrap/scss/_popover.scss","../../node_modules/bootstrap/scss/_carousel.scss","../../node_modules/bootstrap/scss/mixins/_clearfix.scss","../../node_modules/bootstrap/scss/_spinners.scss","../../node_modules/bootstrap/scss/_offcanvas.scss","../../node_modules/bootstrap/scss/_placeholders.scss","../../node_modules/bootstrap/scss/helpers/_color-bg.scss","../../node_modules/bootstrap/scss/helpers/_colored-links.scss","../../node_modules/bootstrap/scss/helpers/_focus-ring.scss","../../node_modules/bootstrap/scss/helpers/_icon-link.scss","../../node_modules/bootstrap/scss/helpers/_ratio.scss","../../node_modules/bootstrap/scss/helpers/_position.scss","../../node_modules/bootstrap/scss/helpers/_stacks.scss","../../node_modules/bootstrap/scss/helpers/_visually-hidden.scss","../../node_modules/bootstrap/scss/mixins/_visually-hidden.scss","../../node_modules/bootstrap/scss/helpers/_stretched-link.scss","../../node_modules/bootstrap/scss/helpers/_text-truncation.scss","../../node_modules/bootstrap/scss/mixins/_text-truncate.scss","../../node_modules/bootstrap/scss/helpers/_vr.scss","../../node_modules/bootstrap/scss/mixins/_utilities.scss","../../node_modules/bootstrap/scss/utilities/_api.scss","../../src/scss/mixins/_animations.scss","../../src/scss/_root.scss","../../src/scss/_app-wrapper.scss","../../src/scss/_app-content.scss","../../src/scss/_app-header.scss","../../src/scss/mixins/_search-field.scss","../../src/scss/_app-sidebar.scss","../../src/scss/mixins/_scrollbar.scss","../../src/scss/_app-main.scss","../../src/scss/_app-footer.scss","../../src/scss/_dropdown.scss","../../src/scss/_callouts.scss","../../src/scss/_compact-mode.scss","../../src/scss/_progress-bars.scss","../../src/scss/_cards.scss","../../src/scss/_table.scss","../../src/scss/_small-box.scss","../../src/scss/_info-box.scss","../../src/scss/_timeline.scss","../../src/scss/_direct-chat.scss","../../src/scss/_toasts.scss","../../src/scss/_ribbon.scss","../../src/scss/_widgets.scss","../../src/scss/pages/_login_and_register.scss","../../src/scss/pages/_lockscreen.scss","../../src/scss/_miscellaneous.scss","../../src/scss/_accessiLine truncated
+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>
* Licensed under MIT (https://github.com/ColorlibHQ/AdminLTE/blob/master/LICENSE)
*/
+64 -268
View File
@@ -89,15 +89,6 @@
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 {
margin: 0.75rem 0 0;
line-height: 1.2;
@@ -280,12 +271,6 @@
text-align: center;
}
.email-template-preview__subject {
color: #111827;
font-weight: 600;
margin-bottom: 1rem;
}
.email-template-preview__message p {
margin-bottom: 0.75rem;
white-space: pre-wrap;
@@ -338,10 +323,6 @@
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 {
position: relative;
padding-right: 6.25rem;
@@ -364,6 +345,9 @@
.template-preview-card .btn-group {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.audit-change-list {
display: grid;
padding: 0.08rem 0.3rem;
@@ -374,32 +358,22 @@
.audit-change-row {
display: grid;
grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
background: var(--bs-danger-bg-subtle);
gap: 0.35rem;
align-items: baseline;
font-size: 0.78rem;
}
background: var(--bs-success-bg-subtle);
.audit-change-to {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.audit-change-from {
color: var(--bs-danger-text-emphasis);
}
.audit-change-to {
color: var(--bs-success-text-emphasis);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.audit-change-from del,
.audit-change-to ins {
text-decoration-thickness: 2px;
}
.audit-change-arrow {
color: var(--bs-secondary-color);
}
gap: 0.5rem;
}
.template-preview-card .btn-group .btn {
width: 100%;
}
@@ -457,20 +431,7 @@
position: relative;
}
.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-icon-picker__toggle,
.announcement-type-picker__toggle {
display: flex;
align-items: center;
@@ -485,29 +446,13 @@
text-align: left;
}
.announcement-icon-picker__toggle:focus-visible {
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
}
.announcement-icon-picker__toggle:focus-visible,
.announcement-type-picker__toggle:focus-visible {
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
}
.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-icon-picker__toggle-icon,
.announcement-type-picker__toggle-icon {
display: inline-flex;
align-items: center;
@@ -521,14 +466,7 @@
flex: 0 0 auto;
}
.announcement-icon-picker__toggle-content {
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1 1 auto;
min-width: 0;
}
.announcement-icon-picker__toggle-content,
.announcement-type-picker__toggle-content {
display: flex;
flex-direction: column;
@@ -537,60 +475,27 @@
min-width: 0;
}
.announcement-icon-picker__toggle-label {
font-weight: 600;
line-height: 1.1;
}
.announcement-icon-picker__toggle-label,
.announcement-type-picker__toggle-label {
font-weight: 600;
line-height: 1.1;
}
.announcement-icon-picker__toggle-hint {
color: var(--bs-secondary-color);
font-size: 0.78rem;
line-height: 1.1;
}
.announcement-icon-picker__toggle-hint,
.announcement-type-picker__toggle-hint {
color: var(--bs-secondary-color);
font-size: 0.78rem;
line-height: 1.1;
}
.announcement-icon-picker__caret {
flex: 0 0 auto;
color: var(--bs-secondary-color);
font-size: 0.8rem;
}
.announcement-icon-picker__caret,
.announcement-type-picker__caret {
flex: 0 0 auto;
color: var(--bs-secondary-color);
font-size: 0.8rem;
}
.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-icon-picker__menu,
.announcement-type-picker__menu {
position: absolute;
z-index: 20;
@@ -606,11 +511,15 @@
box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.18);
}
.announcement-type-picker__menu.is-open-above {
top: auto;
bottom: calc(100% + 0.5rem);
.announcement-icon-picker__menu {
display: flex;
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 {
top: auto;
bottom: calc(100% + 0.5rem);
@@ -634,13 +543,6 @@
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 {
display: flex;
align-items: center;
@@ -666,12 +568,6 @@
align-content: start;
}
.announcement-icon-picker__search-grid {
max-height: none;
overflow: hidden;
padding-right: 0;
}
.announcement-icon-picker__search-empty {
color: var(--bs-secondary-color);
font-size: 0.875rem;
@@ -684,6 +580,13 @@
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 {
display: inline-flex;
align-items: center;
@@ -694,51 +597,65 @@
border-radius: 0.75rem;
background: var(--bs-body-bg);
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;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: 0.5rem;
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-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:focus-visible {
border-color: rgba(var(--bs-primary-rgb), 0.7);
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:focus-visible {
border-color: rgba(var(--bs-primary-rgb), 0.7);
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 {
border-color: var(--bs-primary);
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 {
font-size: 1.05rem;
line-height: 1;
@@ -810,15 +727,12 @@
background: var(--bs-body-bg);
color: var(--bs-body-color);
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:focus-visible {
border-color: rgba(var(--bs-primary-rgb), 0.7);
background: rgba(var(--bs-primary-rgb), 0.04);
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 {
@@ -868,10 +782,9 @@
.announcement-screen-picker {
max-height: 16rem;
}
}
@media (max-width: 576px) {
.announcement-type-picker__menu {
.announcement-type-picker__menu,
.announcement-icon-picker__menu {
width: calc(100vw - 2rem);
right: 0;
left: auto;
@@ -880,26 +793,12 @@
.announcement-type-picker__grid {
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 {
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 {
flex-wrap: nowrap;
}
@@ -910,10 +809,6 @@
max-width: 12rem;
}
.background-tasks-task-status {
flex: 0 0 auto;
}
.background-tasks-page {
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 {
padding: 0.75rem 1rem;
}
@@ -1098,13 +985,6 @@
padding: 0.5rem 1rem;
}
@media (max-width: 575.98px) {
.onboarding-client-list-link {
flex-basis: 100%;
text-align: center;
}
}
.onboarding-pin-inputs {
display: flex;
width: fit-content;
@@ -1380,10 +1260,6 @@
opacity: 0.6;
}
.dashboard-launcher-download {
width: 9rem;
}
.screen-command-card .card-header {
padding-top: 1rem;
padding-bottom: 1rem;
@@ -1675,6 +1551,7 @@
overflow-wrap: anywhere;
font-size: 0.875rem;
line-height: 1.25;
line-clamp: 2;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@@ -1764,14 +1641,6 @@
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 {
display: inline-flex;
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-danger,
.playlist-actions-group > .btn-warning,
.playlist-actions-group > .btn-danger,
.screen-actions-group > .btn-warning,
.screen-actions-group > .btn-danger {
.template-actions-group > .btn-danger {
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;
}
@@ -2717,14 +2574,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
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 {
display: flex;
align-items: flex-start;
@@ -2735,10 +2584,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
margin-bottom: 0.15rem;
}
.region-info-card .card-subtitle {
margin-bottom: 0;
}
.region-selector-block .form-label {
font-weight: 600;
}
@@ -2916,17 +2761,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
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 {
display: grid;
gap: 0.75rem;
@@ -2939,25 +2773,14 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
}
.announcement-color-picker__option {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 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:focus-visible {
border-color: rgba(var(--bs-primary-rgb), 0.7);
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-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 {
display: inline-flex;
align-items: center;
@@ -3167,9 +2978,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
justify-self: stretch;
}
.template-designer-actions {
grid-template-columns: 1fr;
}
}
.frame-popup-card-body {
@@ -3194,16 +3002,6 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
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 {
display: grid;
gap: 1rem;
@@ -3233,15 +3031,13 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
background: var(--bs-body-bg);
color: var(--bs-body-color);
padding: 0;
display: grid;
gap: 0.3rem;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.playlist-slide-picker-card:hover,
.playlist-slide-picker-card:focus-visible {
.playlist-slide-picker-card:hover {
border-color: var(--bs-primary);
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.15);
transform: translateY(-1px);
+10 -5
View File
@@ -389,7 +389,10 @@
var signedInUser = {
username: previewUser ? previewUser.getAttribute('data-username') : '',
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) {
var subject = editor.querySelector('[data-email-template-subject]');
@@ -398,6 +401,9 @@
var buttonText = editor.querySelector('[data-email-template-button-text]');
var preview = editor.querySelector('[data-email-template-preview]');
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() {
preview.innerHTML = '';
@@ -411,7 +417,8 @@
var sampleValues = {
username: signedInUser.username || 'username',
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');
button.className = 'email-template-preview__button';
@@ -420,8 +427,6 @@
buttonWrap.className = 'email-template-preview__button-wrap';
buttonWrap.style.textAlign = buttonAlignment;
buttonWrap.appendChild(button);
var isVerificationTemplate = Boolean(editor.querySelector('[name="verification_subject"]'));
var isInvitationTemplate = Boolean(editor.querySelector('[name="invitation_subject"]'));
var previewActionPlaceholder = '[[url]]';
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';
@@ -430,7 +435,7 @@
var previewActionUrl = isVerificationTemplate ? previewVerificationUrl : (isInvitationTemplate ? previewOrigin + '/accept-invite?token=preview-token' : previewResetUrl);
var hasExplicitButton = body.value.indexOf('[[action_button]]') !== -1;
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 hasUrlToken = !hasExplicitButton && sourceParagraph.indexOf(previewActionPlaceholder) !== -1;
if (hasButtonToken) {
+9 -6
View File
@@ -1,7 +1,8 @@
// Admin account route registration and profile helpers.
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) {
const pool = deps.pool;
@@ -59,9 +60,10 @@ module.exports = function registerAccountRoutes(app, deps) {
const settings = await fetchAppSettings(pool);
if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') return res.status(400).send('Email delivery is not configured.');
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);
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 });
res.redirect('/account?message=' + encodeURIComponent('Check your new email address for a verification link.'));
} catch (error) {
@@ -87,7 +89,7 @@ module.exports = function registerAccountRoutes(app, deps) {
id: Number(session.id),
isCurrent: Boolean(tokenHash && session.session_hash === tokenHash),
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),
lastUsedAtLabel: formatDashboardDate(session.last_used_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]);
} else {
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);
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) } });
+9 -7
View File
@@ -1,8 +1,8 @@
// Admin user route registration and user-role management.
const { fetchAppSettings } = require('#src/data/app-settings');
const { renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const { buildAuditChanges } = require('#src/data/audit-log');
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const { buildAuditChanges, formatUserAgentLabel } = require('#src/data/audit-log');
module.exports = function registerUsersRoutes(app, deps) {
const pool = deps.pool;
@@ -187,8 +187,9 @@
const invitation = rows[0];
const token = createOneTimeToken();
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'] })));
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]);
const expiryHours = Number(settings['email.invitation_expiry_hours']);
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 });
res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation resent.'));
} 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.');
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('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);
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) {
await pool.query('DELETE FROM a_user_invitations WHERE token_hash = ?', [hashSessionToken(token)]);
throw mailError;
@@ -307,7 +309,7 @@
return {
id: Number(session.id),
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),
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
expiresAtLabel: formatDashboardDate(session.expires_at)
+4 -3
View File
@@ -2,7 +2,7 @@
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
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) {
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') {
const token = createOneTimeToken();
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);
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 });
} catch (_mailError) {
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 { 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 { 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(),
occurredAtLabel: formatDashboardDate(row.occurred_at),
actorLabel: row.actor_name || row.actor_username || 'System',
userAgentLabel: formatUserAgentLabel(row.user_agent),
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
detailsDisplay: details,
+11 -2
View File
@@ -175,6 +175,9 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
smtpConfigured: Boolean(settings['email.smtp_password']),
emailFromAddress: String(settings['email.from_address'] || ''),
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'] || ''),
verificationEmailBody: String(settings['email.verification_body'] || ''),
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 fromAddress = String(req.body && req.body.from_address || '').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.');
error.statusCode = 400;
error.expose = true;
@@ -442,7 +448,10 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
'email.smtp_username': smtpUsername,
'email.smtp_password': smtpPassword || previousSettings['email.smtp_password'],
'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);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
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="col-12 col-xl-6">
<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>
</div>
<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}}
<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>
</div>
<div class="card-body">
@@ -74,7 +74,7 @@
<div class="col-12 col-xl-6">
<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>
</div>
<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>
</div>
<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>
</div>
<div class="card-body">
@@ -11,7 +11,7 @@
<div class="col-12">
<form id="api-source-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<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>
</div>
<div class="card-body">
@@ -89,7 +89,7 @@
</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-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>
<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">
@@ -176,7 +176,7 @@
</div>
<div class="col-12">
<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>
{{#if lastResponseJson}}
<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}}}>
<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>
</div>
<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}}
<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>
</div>
<div class="card-body">
@@ -7,7 +7,7 @@
<form id="timetable-group-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<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>
</div>
<div class="card-body d-grid gap-4 pb-0">
@@ -42,7 +42,7 @@
</div>
<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>
<button type="button" class="btn btn-primary btn-sm ms-auto" data-add-timetable-entry>Add entry</button>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="col-12 col-xl-7">
<form id="weather-location-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<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="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>
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="row justify-content-center">
<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-header">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Screen setup</h3>
</div>
<form id="onboarding-pair-form" method="post" action="/pairing" novalidate>
+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>{{actorLabel}}</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">
{{#if auditDetailView.hasChanges}}
<div class="audit-change-list">
+3 -3
View File
@@ -12,7 +12,7 @@
<div class="row g-3">
<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-header">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Role details</h3>
</div>
<div class="card-body">
@@ -33,7 +33,7 @@
</div>
<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>
<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>
@@ -82,7 +82,7 @@
<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-header">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Permissions</h3>
</div>
<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-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-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 class="card-footer d-flex align-items-center">
{{#if (hasPermission currentUser 'system-settings.update')}}
@@ -300,7 +303,7 @@
<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-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="row g-4 align-items-start">
<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-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 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 class="col-12 col-lg-6">
<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-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 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 class="col-12 col-lg-6">
<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-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 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 class="col-12 col-lg-6"><div class="email-template-preview" data-email-template-preview aria-label="User invitation preview"></div></div>
</div>
+4 -4
View File
@@ -9,7 +9,7 @@
<div class="row g-3">
<div class="col-12 col-xl-4">
<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>
</div>
<div class="card-body">
@@ -55,7 +55,7 @@
</div>
<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>
</div>
<div class="card-body">
@@ -79,7 +79,7 @@
<div class="col-12 col-xl-8">
<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>
</div>
<div class="card-body">
@@ -111,7 +111,7 @@
{{#if isEdit}}
<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>
{{#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?">
+2 -2
View File
@@ -9,7 +9,7 @@
<div class="row g-3">
<div class="col-12 col-xl-4">
<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>
</div>
<div class="card-body">
@@ -36,7 +36,7 @@
<div class="col-12 col-xl-8">
<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>
</div>
<div class="card-body">
+2 -2
View File
@@ -9,7 +9,7 @@
<div class="row g-4 mb-4">
<div class="col-12 col-xl-7">
<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>
</div>
<div class="card-body">
@@ -122,7 +122,7 @@
</div>
</div>
<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>
</div>
<div class="card-body">
+1 -1
View File
@@ -7,7 +7,7 @@
<form id="canvas-size-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<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>
</div>
<div class="card-body">
+3 -3
View File
@@ -8,7 +8,7 @@
<div class="row g-4 mb-4 align-items-stretch">
<div class="col-12 col-lg-6">
<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>
</div>
<form id="playlist-edit-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
@@ -38,7 +38,7 @@
</div>
<div class="col-12 col-lg-6">
<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>
</div>
<div class="card-body py-3">
@@ -58,7 +58,7 @@
<div class="row g-4 mb-4">
<div class="col-12">
<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>
<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>
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="row g-4">
<div class="col-12">
<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>
</div>
<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" />
<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-header">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Slide details</h3>
</div>
<div class="card-body">
@@ -54,7 +54,7 @@
</div>
<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">
<h3>Preview</h3>
</div>
+5 -5
View File
@@ -16,7 +16,7 @@
<div class="template-designer-layout">
<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>
</div>
<div class="card-body">
@@ -45,7 +45,7 @@
</div>
</div>
<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>
<h3 class="card-title">Region information</h3>
</div>
@@ -65,7 +65,7 @@
<div class="row g-4 mb-4 align-items-stretch">
<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-header">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Template details</h3>
</div>
<div class="card-body">
@@ -93,7 +93,7 @@
</div>
<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-header">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Options</h3>
</div>
<div class="card-body">
@@ -154,7 +154,7 @@
<template id="region-card-template">
<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="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>
+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',
event_type: 'login.success',
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({
changes: {
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, /audit-event-type-options/);
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, />red</);
assert.match(response.body, />blue</);
+72
View File
@@ -12,6 +12,14 @@ const {
const renderTemplateAddPage = require('../src/web/routes/signage/templates/add');
const renderTemplateEditPage = require('../src/web/routes/signage/templates/edit');
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', () => {
const regions = extractTemplateRegions({
@@ -175,6 +183,70 @@ test('template edit page confirms deletion', () => {
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', () => {
const template = fs.readFileSync(require.resolve('../src/web/views/signage/templates/list.hbs'), 'utf8');