release: v2.11.2
This commit is contained in:
@@ -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,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,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": {
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') : '';
|
||||
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 };
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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-*`
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) } });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)\]\]/);
|
||||
});
|
||||
@@ -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</);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user