Release v2.20.2
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m23s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s

This commit is contained in:
lzstealth committed 2026-10-25 19:08:10 +00:00
1 parent 220c3f401e
commit 61d553f74b
14 files changed
+395 -94

No files matched your search

+7
View File
@@ -6,6 +6,12 @@ const { formatAccountEmailExpiry, normalizeOtpEmailBody, renderAccountEmailTempl
const LOGIN_RATE_LIMIT_PATHS = ['/sign-in/username', '/sign-in/email', '/two-factor/verify-totp', '/two-factor/verify-backup-code'];
function ensureBetterAuthClientIp(req) {
if (!req.headers['x-forwarded-for'] && req.socket && req.socket.remoteAddress) {
req.headers['x-forwarded-for'] = req.socket.remoteAddress;
}
}
function getRoleTwoFactorAuthError(currentUser, requestPath) {
const actor = currentUser && (currentUser.authenticatedUser || currentUser);
if (!actor || !actor.requiresTwoFactor) return null;
@@ -311,6 +317,7 @@ async function createBetterAuth(pool, options) {
module.exports = {
createBetterAuth,
ensureBetterAuthClientIp,
createPasswordCallbacks,
createAccountLockSessionHook,
createLoginRateLimitResetHook,
+62 -18
View File
@@ -649,33 +649,77 @@
gap: 0.5rem;
}
/* Audit log change comparisons */
/* Audit log change details */
.audit-change-list {
.audit-change-summary {
display: grid;
padding: 0.08rem 0.3rem;
border-radius: 0.2rem;
gap: 0.2rem;
min-width: 18rem;
gap: 0.4rem;
min-width: 12rem;
}
.audit-change-row {
display: grid;
grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
gap: 0.35rem;
.audit-change-paths {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: baseline;
font-size: 0.78rem;
}
.audit-change-to {
.audit-change-path {
font-size: 0.72rem;
overflow-wrap: anywhere;
}
.audit-diff-changes {
display: grid;
gap: 1rem;
}
.audit-diff-change {
min-width: 0;
padding-bottom: 1rem;
border-bottom: 1px solid var(--bs-border-color);
}
.audit-diff-path {
overflow-wrap: anywhere;
}
.audit-diff-pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.audit-diff-column {
min-width: 0;
}
.audit-diff-label {
margin-bottom: 0.25rem;
color: var(--bs-secondary-color);
font-size: 0.8rem;
font-weight: 600;
}
.audit-diff-pane {
max-height: 24rem;
min-height: 8rem;
margin: 0;
overflow: auto;
padding: 0.75rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.25rem;
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
font-size: 0.8rem;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.audit-change-from {
overflow-wrap: anywhere;
white-space: pre-wrap;
.audit-diff-removed {
background: var(--bs-danger-bg-subtle);
color: var(--bs-danger-text-emphasis);
text-decoration: line-through;
}
.audit-change-from del,
.audit-change-to ins {
text-decoration-thickness: 2px;
.audit-diff-added {
background: var(--bs-success-bg-subtle);
color: var(--bs-success-text-emphasis);
text-decoration: none;
}
@media (max-width: 767.98px) {
.audit-diff-pair {
grid-template-columns: minmax(0, 1fr);
}
}
/* Template preview states and permission tables */
+171
View File
@@ -0,0 +1,171 @@
(function () {
function buildDiffSegments(beforeValue, afterValue) {
var before = Array.from(String(beforeValue == null ? '' : beforeValue));
var after = Array.from(String(afterValue == null ? '' : afterValue));
var prefixLength = 0;
var suffixLength = 0;
while (prefixLength < before.length && prefixLength < after.length && before[prefixLength] === after[prefixLength]) {
prefixLength += 1;
}
while (suffixLength < before.length - prefixLength && suffixLength < after.length - prefixLength &&
before[before.length - suffixLength - 1] === after[after.length - suffixLength - 1]) {
suffixLength += 1;
}
function toSegments(characters) {
var segments = [];
var changedEnd = characters.length - suffixLength;
if (prefixLength) segments.push({ text: characters.slice(0, prefixLength).join(''), changed: false });
if (changedEnd > prefixLength) segments.push({ text: characters.slice(prefixLength, changedEnd).join(''), changed: true });
if (suffixLength) segments.push({ text: characters.slice(changedEnd).join(''), changed: false });
return segments;
}
return { before: toSegments(before), after: toSegments(after) };
}
function appendSegments(element, segments, changeClass) {
segments.forEach(function (segment) {
if (!segment.changed) {
element.appendChild(document.createTextNode(segment.text));
return;
}
var mark = document.createElement(changeClass === 'audit-diff-removed' ? 'del' : 'ins');
mark.className = changeClass;
mark.textContent = segment.text;
element.appendChild(mark);
});
}
function synchronizeScroll(beforePane, afterPane) {
var isSyncing = false;
function sync(source, target) {
if (isSyncing) return;
isSyncing = true;
var sourceVerticalRange = source.scrollHeight - source.clientHeight;
var targetVerticalRange = target.scrollHeight - target.clientHeight;
var sourceHorizontalRange = source.scrollWidth - source.clientWidth;
var targetHorizontalRange = target.scrollWidth - target.clientWidth;
target.scrollTop = sourceVerticalRange > 0 ? source.scrollTop / sourceVerticalRange * targetVerticalRange : 0;
target.scrollLeft = sourceHorizontalRange > 0 ? source.scrollLeft / sourceHorizontalRange * targetHorizontalRange : 0;
window.requestAnimationFrame(function () { isSyncing = false; });
}
beforePane.addEventListener('scroll', function () { sync(beforePane, afterPane); }, { passive: true });
afterPane.addEventListener('scroll', function () { sync(afterPane, beforePane); }, { passive: true });
}
function createDiffPane(label, value, segments, changeClass) {
var paneWrapper = document.createElement('section');
paneWrapper.className = 'audit-diff-column';
var heading = document.createElement('div');
heading.className = 'audit-diff-label';
heading.textContent = label;
var pane = document.createElement('pre');
pane.className = 'audit-diff-pane';
pane.tabIndex = 0;
pane.setAttribute('aria-label', label + ' value');
if (segments) {
appendSegments(pane, segments, changeClass);
} else {
pane.textContent = value == null ? '' : String(value);
}
paneWrapper.appendChild(heading);
paneWrapper.appendChild(pane);
return { wrapper: paneWrapper, pane: pane };
}
function createChangeBlock(change) {
var block = document.createElement('section');
block.className = 'audit-diff-change';
var path = document.createElement('h4');
path.className = 'audit-diff-path h6';
path.textContent = String(change.path || 'Changed value');
var pair = document.createElement('div');
pair.className = 'audit-diff-pair';
var segments = buildDiffSegments(change.from, change.to);
var before = createDiffPane('Before', change.from, segments.before, 'audit-diff-removed');
var after = createDiffPane('After', change.to, segments.after, 'audit-diff-added');
pair.appendChild(before.wrapper);
pair.appendChild(after.wrapper);
synchronizeScroll(before.pane, after.pane);
block.appendChild(path);
block.appendChild(pair);
return block;
}
function renderRows(container, changeRows) {
container.replaceChildren();
changeRows.forEach(function (change) {
container.appendChild(createChangeBlock(change));
});
}
function initializeAuditDetails() {
var modal = document.getElementById('audit-change-detail-modal');
if (!modal) return;
var status = modal.querySelector('[data-audit-detail-status]');
var content = modal.querySelector('[data-audit-detail-content]');
var rows = content;
var subtitle = modal.querySelector('[data-audit-detail-subtitle]');
var requestNumber = 0;
document.addEventListener('click', function (event) {
var button = event.target.closest('[data-audit-detail-url]');
if (!button) return;
var currentRequest = ++requestNumber;
var detailUrl = button.getAttribute('data-audit-detail-url');
rows.replaceChildren();
content.hidden = true;
status.textContent = 'Loading change details...';
status.hidden = false;
subtitle.textContent = button.getAttribute('data-audit-detail-title') || '';
if (!window.pulseModal || !window.pulseModal.show(modal)) {
status.textContent = 'Change details are unavailable.';
return;
}
button.disabled = true;
fetch(detailUrl, {
credentials: 'same-origin',
headers: { Accept: 'application/json' }
}).then(function (response) {
if (!response.ok) throw new Error('Audit details request failed.');
return response.json();
}).then(function (result) {
if (currentRequest !== requestNumber) return;
var changeRows = result && Array.isArray(result.changeRows) ? result.changeRows : [];
renderRows(rows, changeRows);
if (changeRows.length) {
content.hidden = false;
status.hidden = true;
} else {
status.textContent = 'No change details are available.';
}
}).catch(function () {
if (currentRequest === requestNumber) {
status.textContent = 'Unable to load change details.';
}
}).finally(function () {
button.disabled = false;
});
});
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { buildDiffSegments: buildDiffSegments };
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeAuditDetails);
} else {
initializeAuditDetails();
}
}
}());
+41 -4
View File
@@ -150,7 +150,7 @@ function collectAuditChangeRows(previousValue, nextValue, path, rows) {
function buildAuditDetailView(details) {
if (!isRecord(details) || !isRecord(details.changes)) {
return { hasChanges: false, changeRows: [] };
return { hasChanges: false, changeRows: [], summaryRows: [], remainingChangeCount: 0 };
}
const rows = [];
@@ -160,7 +160,12 @@ function buildAuditDetailView(details) {
collectAuditChangeRows(change.from, change.to, key, rows);
}
});
return { hasChanges: rows.length > 0, changeRows: rows };
return {
hasChanges: rows.length > 0,
changeRows: rows,
summaryRows: rows.slice(0, 3),
remainingChangeCount: Math.max(0, rows.length - 3)
};
}
function mapAuditRow(row, formatDashboardDate) {
@@ -175,6 +180,9 @@ function mapAuditRow(row, formatDashboardDate) {
}
}
const occurredAt = new Date(row.occurred_at);
const auditDetailView = Object.prototype.hasOwnProperty.call(row, 'has_changes')
? { hasChanges: Number(row.has_changes) === 1, summaryRows: [], remainingChangeCount: 0 }
: buildAuditDetailView(parsedDetails);
return Object.assign({}, row, {
occurredAtValue: Number.isNaN(occurredAt.getTime()) ? '' : occurredAt.toISOString(),
occurredAtLabel: formatDashboardDate(row.occurred_at),
@@ -183,7 +191,7 @@ function mapAuditRow(row, formatDashboardDate) {
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,
auditDetailView: buildAuditDetailView(parsedDetails)
auditDetailView: auditDetailView
});
}
@@ -248,8 +256,11 @@ async function fetchAuditEventTypes(pool, filters) {
}
async function fetchAuditRows(pool, filters, page) {
const detailsColumns = page
? "JSON_CONTAINS_PATH(e.details_json, 'one', '$.changes') AS has_changes, JSON_REMOVE(e.details_json, '$.changes') AS details_json,"
: 'e.details_json,';
let sql = `SELECT e.id, e.occurred_at, e.category, e.event_type, e.target_type, e.target_id,
e.target_label, e.ip_address, e.user_agent, e.details_json,
e.target_label, e.ip_address, e.user_agent, ${detailsColumns}
u.name AS actor_name, u.username AS actor_username
${AUDIT_FROM_SQL}${filters.whereSql}${buildAuditOrderBy(filters)}`;
const params = filters.params.slice();
@@ -301,6 +312,7 @@ module.exports = function registerAuditLogRoutes(app, deps) {
res.send(renderView('settings/audit-log/index', {
title: 'Audit log',
active: 'audit-log',
scripts: ['js/lib/modal.js', 'js/settings/audit-log.js'],
currentUser: req.currentUser,
events: events,
categories: SORTED_AUDIT_CATEGORY_KEYS,
@@ -318,6 +330,31 @@ module.exports = function registerAuditLogRoutes(app, deps) {
}
});
app.get('/settings/audit-log/:id/details', requireAuditLogAccess(deps.setAuthMessageCookie), async function (req, res, next) {
try {
const auditEventId = Number(req.params.id);
if (!Number.isSafeInteger(auditEventId) || auditEventId < 1) {
return res.status(400).json({ error: 'Audit event id is invalid.' });
}
const [rows] = await pool.query('SELECT e.details_json FROM o_audit_events e WHERE e.id = ? LIMIT 1', [auditEventId]);
const row = rows && rows[0];
if (!row) {
return res.status(404).json({ error: 'Audit event not found.' });
}
let details = row.details_json;
if (typeof details === 'string') {
try {
details = JSON.parse(details);
} catch (_error) {
details = null;
}
}
res.json({ changeRows: buildAuditDetailView(details).changeRows });
} catch (error) {
next(error);
}
});
app.get('/settings/audit-log/export', requireAuditExportAccess(deps.setAuthMessageCookie), async function (req, res, next) {
try {
const filters = buildAuditFilters(req);
+25 -9
View File
@@ -49,15 +49,10 @@
<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">
{{#each auditDetailView.changeRows}}
<div class="audit-change-row">
<code class="audit-change-path">{{path}}</code>
<span class="audit-change-from"><span class="visually-hidden">From: </span>{{from}}</span>
{{#if (eq direction "changed")}}<span class="audit-change-arrow" aria-hidden="true">&rarr;</span>{{else}}<span></span>{{/if}}
<span class="audit-change-to"><span class="visually-hidden">To: </span>{{to}}</span>
</div>
{{/each}}
<div class="audit-change-summary">
<button type="button" class="btn btn-sm btn-outline-secondary align-self-start" data-audit-detail-url="/settings/audit-log/{{id}}/details" data-audit-detail-title="{{eventLabel}} - {{targetLabelDisplay}}">
<i class="bi bi-list-ul me-1" aria-hidden="true"></i>View details
</button>
</div>
{{else}}
<small>{{detailsDisplay}}</small>
@@ -72,4 +67,25 @@
</table>
</div>
{{> table-pagination pagination=pagination basePath="/settings/audit-log" alwaysShow=true}}
</div>
<div class="modal fade" id="audit-change-detail-modal" tabindex="-1" aria-labelledby="audit-change-detail-title" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h3 class="modal-title h5" id="audit-change-detail-title">Change details</h3>
<p class="text-muted small mb-0" data-audit-detail-subtitle></p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-muted" data-audit-detail-status role="status" aria-live="polite"></div>
<div class="audit-diff-changes" data-audit-detail-content hidden></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}&ui=20261025.2" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}&ui=20261025.3" />
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
{{#if stylesheets.length}}
{{#each stylesheets}}