diff --git a/CHANGELOG.md b/CHANGELOG.md index 821ea32..0533a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## 2.20.2 - 2026-10-25 + +### Changed + +- Audit-log entries summarize changed items and open detailed before/after values in synchronized side-by-side panes with differences highlighted. + ## 2.20.1 - 2026-10-25 ### Changed diff --git a/build/package.player.json b/build/package.player.json index 2c90e64..f57af25 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.20.1", + "version": "2.20.2", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index d1249f9..ea6fe98 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.20.1", + "version": "2.20.2", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index cb4321c..ac5f805 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.20.1", + "version": "2.20.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.20.1", + "version": "2.20.2", "dependencies": { "@better-auth/passkey": "^1.7.13", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 7460496..d4ddb37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.20.1", + "version": "2.20.2", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/web.js b/src/web.js index 95e4ae5..1531432 100644 --- a/src/web.js +++ b/src/web.js @@ -24,7 +24,7 @@ const { rbacData } = require('#src/web/lib/auth/index'); const { createPlayerActionService } = require('#src/web/lib/player-actions'); const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth/index'); -const { createBetterAuth, getRoleTwoFactorAuthError } = require('#src/web/lib/auth/better-auth'); +const { createBetterAuth, ensureBetterAuthClientIp, getRoleTwoFactorAuthError } = require('#src/web/lib/auth/better-auth'); const { fetchAppSettings } = require('#src/data/app-settings'); const { sendAccountEmail } = require('#src/data/mailer'); const { recordRequestAuditEvent } = require('#src/data/audit-log'); @@ -121,6 +121,7 @@ async function start() { const setAuthMessageCookie = sessionService.setAuthMessageCookie; const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie; const loadCurrentUser = async function (pool, req) { + ensureBetterAuthClientIp(req); const authSession = await betterAuth.api.getSession({ headers: fromNodeHeaders(req.headers) }); if (!authSession || !authSession.session || !authSession.session.token) return null; req.betterAuthSession = authSession; @@ -138,9 +139,6 @@ async function start() { if (currentUser && currentUser.isViewingAs && (authPath.startsWith('/api/auth/two-factor/') || authPath.startsWith('/api/auth/passkey/'))) { return res.status(403).json({ message: 'Exit View as before changing authentication settings.' }); } - if (!req.headers['x-forwarded-for'] && req.socket && req.socket.remoteAddress) { - req.headers['x-forwarded-for'] = req.socket.remoteAddress; - } return betterAuthHandler(req, res, next); } catch (error) { next(error); diff --git a/src/web/lib/auth/better-auth.js b/src/web/lib/auth/better-auth.js index 0b282d2..2aa8fe7 100644 --- a/src/web/lib/auth/better-auth.js +++ b/src/web/lib/auth/better-auth.js @@ -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, diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index d2ac213..33405a9 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -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 */ diff --git a/src/web/public/js/settings/audit-log.js b/src/web/public/js/settings/audit-log.js new file mode 100644 index 0000000..015933d --- /dev/null +++ b/src/web/public/js/settings/audit-log.js @@ -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(); + } + } +}()); \ No newline at end of file diff --git a/src/web/routes/settings/audit-log.js b/src/web/routes/settings/audit-log.js index ac640c8..77d5e07 100644 --- a/src/web/routes/settings/audit-log.js +++ b/src/web/routes/settings/audit-log.js @@ -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); diff --git a/src/web/views/settings/audit-log/index.hbs b/src/web/views/settings/audit-log/index.hbs index ccd1323..7b013f1 100644 --- a/src/web/views/settings/audit-log/index.hbs +++ b/src/web/views/settings/audit-log/index.hbs @@ -49,15 +49,10 @@
{{path}}
- From: {{from}}
- {{#if (eq direction "changed")}}{{else}}{{/if}}
- To: {{to}}
-