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:
2026-09-05 18:14:38 +01:00
parent 7fec2154e0
commit c4ae69d25f
40 changed files with 314 additions and 352 deletions
+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-*`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
/*!
* AdminLTE v4.3.1 (https://adminlte.io)
* AdminLTE v4.9.1 (https://adminlte.io)
* Copyright 2014-2026 Colorlib <https://colorlib.com>
* 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>
+2 -2
View File
@@ -8,8 +8,8 @@
<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">
<h3 class="card-title">Screen setup</h3>
<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>
<div class="card-body">
+1 -1
View File
@@ -46,7 +46,7 @@
<td><div class="fw-semibold text-capitalize">{{eventLabel}}</div><div class="text-muted small">{{category}}</div></td>
<td>{{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>