Release v2.20.2
This commit is contained in:
1 parent
220c3f401e
commit
61d553f74b
14 files changed
+395
-94
No files matched your search
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## 2.20.1 - 2026-10-25
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.20.1",
|
"version": "2.20.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.20.1",
|
"version": "2.20.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.20.1",
|
"version": "2.20.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.20.1",
|
"version": "2.20.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.7.13",
|
"@better-auth/passkey": "^1.7.13",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.20.1",
|
"version": "2.20.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+2
-4
@@ -24,7 +24,7 @@ const { rbacData } = require('#src/web/lib/auth/index');
|
|||||||
const { createPlayerActionService } = require('#src/web/lib/player-actions');
|
const { createPlayerActionService } = require('#src/web/lib/player-actions');
|
||||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||||
const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth/index');
|
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 { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
const { sendAccountEmail } = require('#src/data/mailer');
|
const { sendAccountEmail } = require('#src/data/mailer');
|
||||||
const { recordRequestAuditEvent } = require('#src/data/audit-log');
|
const { recordRequestAuditEvent } = require('#src/data/audit-log');
|
||||||
@@ -121,6 +121,7 @@ async function start() {
|
|||||||
const setAuthMessageCookie = sessionService.setAuthMessageCookie;
|
const setAuthMessageCookie = sessionService.setAuthMessageCookie;
|
||||||
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie;
|
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie;
|
||||||
const loadCurrentUser = async function (pool, req) {
|
const loadCurrentUser = async function (pool, req) {
|
||||||
|
ensureBetterAuthClientIp(req);
|
||||||
const authSession = await betterAuth.api.getSession({ headers: fromNodeHeaders(req.headers) });
|
const authSession = await betterAuth.api.getSession({ headers: fromNodeHeaders(req.headers) });
|
||||||
if (!authSession || !authSession.session || !authSession.session.token) return null;
|
if (!authSession || !authSession.session || !authSession.session.token) return null;
|
||||||
req.betterAuthSession = authSession;
|
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/'))) {
|
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.' });
|
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);
|
return betterAuthHandler(req, res, next);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -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'];
|
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) {
|
function getRoleTwoFactorAuthError(currentUser, requestPath) {
|
||||||
const actor = currentUser && (currentUser.authenticatedUser || currentUser);
|
const actor = currentUser && (currentUser.authenticatedUser || currentUser);
|
||||||
if (!actor || !actor.requiresTwoFactor) return null;
|
if (!actor || !actor.requiresTwoFactor) return null;
|
||||||
@@ -311,6 +317,7 @@ async function createBetterAuth(pool, options) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createBetterAuth,
|
createBetterAuth,
|
||||||
|
ensureBetterAuthClientIp,
|
||||||
createPasswordCallbacks,
|
createPasswordCallbacks,
|
||||||
createAccountLockSessionHook,
|
createAccountLockSessionHook,
|
||||||
createLoginRateLimitResetHook,
|
createLoginRateLimitResetHook,
|
||||||
|
|||||||
@@ -649,33 +649,77 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Audit log change comparisons */
|
/* Audit log change details */
|
||||||
|
|
||||||
.audit-change-list {
|
.audit-change-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
padding: 0.08rem 0.3rem;
|
gap: 0.4rem;
|
||||||
border-radius: 0.2rem;
|
min-width: 12rem;
|
||||||
gap: 0.2rem;
|
|
||||||
min-width: 18rem;
|
|
||||||
}
|
}
|
||||||
.audit-change-row {
|
.audit-change-paths {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
gap: 0.25rem;
|
||||||
align-items: baseline;
|
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;
|
overflow-wrap: anywhere;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.audit-change-from {
|
.audit-diff-removed {
|
||||||
overflow-wrap: anywhere;
|
background: var(--bs-danger-bg-subtle);
|
||||||
white-space: pre-wrap;
|
color: var(--bs-danger-text-emphasis);
|
||||||
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
.audit-change-from del,
|
.audit-diff-added {
|
||||||
.audit-change-to ins {
|
background: var(--bs-success-bg-subtle);
|
||||||
text-decoration-thickness: 2px;
|
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 */
|
/* Template preview states and permission tables */
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -150,7 +150,7 @@ function collectAuditChangeRows(previousValue, nextValue, path, rows) {
|
|||||||
|
|
||||||
function buildAuditDetailView(details) {
|
function buildAuditDetailView(details) {
|
||||||
if (!isRecord(details) || !isRecord(details.changes)) {
|
if (!isRecord(details) || !isRecord(details.changes)) {
|
||||||
return { hasChanges: false, changeRows: [] };
|
return { hasChanges: false, changeRows: [], summaryRows: [], remainingChangeCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = [];
|
const rows = [];
|
||||||
@@ -160,7 +160,12 @@ function buildAuditDetailView(details) {
|
|||||||
collectAuditChangeRows(change.from, change.to, key, rows);
|
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) {
|
function mapAuditRow(row, formatDashboardDate) {
|
||||||
@@ -175,6 +180,9 @@ function mapAuditRow(row, formatDashboardDate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const occurredAt = new Date(row.occurred_at);
|
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, {
|
return Object.assign({}, row, {
|
||||||
occurredAtValue: Number.isNaN(occurredAt.getTime()) ? '' : occurredAt.toISOString(),
|
occurredAtValue: Number.isNaN(occurredAt.getTime()) ? '' : occurredAt.toISOString(),
|
||||||
occurredAtLabel: formatDashboardDate(row.occurred_at),
|
occurredAtLabel: formatDashboardDate(row.occurred_at),
|
||||||
@@ -183,7 +191,7 @@ function mapAuditRow(row, formatDashboardDate) {
|
|||||||
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
|
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
|
||||||
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
|
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
|
||||||
detailsDisplay: details,
|
detailsDisplay: details,
|
||||||
auditDetailView: buildAuditDetailView(parsedDetails)
|
auditDetailView: auditDetailView
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,8 +256,11 @@ async function fetchAuditEventTypes(pool, filters) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAuditRows(pool, filters, page) {
|
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,
|
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
|
u.name AS actor_name, u.username AS actor_username
|
||||||
${AUDIT_FROM_SQL}${filters.whereSql}${buildAuditOrderBy(filters)}`;
|
${AUDIT_FROM_SQL}${filters.whereSql}${buildAuditOrderBy(filters)}`;
|
||||||
const params = filters.params.slice();
|
const params = filters.params.slice();
|
||||||
@@ -301,6 +312,7 @@ module.exports = function registerAuditLogRoutes(app, deps) {
|
|||||||
res.send(renderView('settings/audit-log/index', {
|
res.send(renderView('settings/audit-log/index', {
|
||||||
title: 'Audit log',
|
title: 'Audit log',
|
||||||
active: 'audit-log',
|
active: 'audit-log',
|
||||||
|
scripts: ['js/lib/modal.js', 'js/settings/audit-log.js'],
|
||||||
currentUser: req.currentUser,
|
currentUser: req.currentUser,
|
||||||
events: events,
|
events: events,
|
||||||
categories: SORTED_AUDIT_CATEGORY_KEYS,
|
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) {
|
app.get('/settings/audit-log/export', requireAuditExportAccess(deps.setAuthMessageCookie), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const filters = buildAuditFilters(req);
|
const filters = buildAuditFilters(req);
|
||||||
|
|||||||
@@ -49,15 +49,10 @@
|
|||||||
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{userAgentLabel}}</div></td>
|
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{userAgentLabel}}</div></td>
|
||||||
<td class="text-break">
|
<td class="text-break">
|
||||||
{{#if auditDetailView.hasChanges}}
|
{{#if auditDetailView.hasChanges}}
|
||||||
<div class="audit-change-list">
|
<div class="audit-change-summary">
|
||||||
{{#each auditDetailView.changeRows}}
|
<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}}">
|
||||||
<div class="audit-change-row">
|
<i class="bi bi-list-ul me-1" aria-hidden="true"></i>View details
|
||||||
<code class="audit-change-path">{{path}}</code>
|
</button>
|
||||||
<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">→</span>{{else}}<span></span>{{/if}}
|
|
||||||
<span class="audit-change-to"><span class="visually-hidden">To: </span>{{to}}</span>
|
|
||||||
</div>
|
|
||||||
{{/each}}
|
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<small>{{detailsDisplay}}</small>
|
<small>{{detailsDisplay}}</small>
|
||||||
@@ -72,4 +67,25 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{{> table-pagination pagination=pagination basePath="/settings/audit-log" alwaysShow=true}}
|
{{> 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>
|
</div>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
<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.min.css" />
|
||||||
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.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}}" />
|
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
|
||||||
{{#if stylesheets.length}}
|
{{#if stylesheets.length}}
|
||||||
{{#each stylesheets}}
|
{{#each stylesheets}}
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ const assert = require('node:assert/strict');
|
|||||||
require('../src/common');
|
require('../src/common');
|
||||||
|
|
||||||
const registerAuditLogRoutes = require('../src/web/routes/settings/audit-log');
|
const registerAuditLogRoutes = require('../src/web/routes/settings/audit-log');
|
||||||
|
const { buildDiffSegments } = require('../src/web/public/js/settings/audit-log');
|
||||||
|
|
||||||
|
test('audit diff highlights only the differing middle span', () => {
|
||||||
|
assert.deepEqual(buildDiffSegments('prefix old suffix', 'prefix new suffix'), {
|
||||||
|
before: [
|
||||||
|
{ text: 'prefix ', changed: false },
|
||||||
|
{ text: 'old', changed: true },
|
||||||
|
{ text: ' suffix', changed: false }
|
||||||
|
],
|
||||||
|
after: [
|
||||||
|
{ text: 'prefix ', changed: false },
|
||||||
|
{ text: 'new', changed: true },
|
||||||
|
{ text: ' suffix', changed: false }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert.deepEqual(buildDiffSegments('', 'added'), {
|
||||||
|
before: [],
|
||||||
|
after: [{ text: 'added', changed: true }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('audit log uses its own read permission and shared pagination partial', async () => {
|
test('audit log uses its own read permission and shared pagination partial', async () => {
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
@@ -19,6 +39,16 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
async query(sql, params) {
|
async query(sql, params) {
|
||||||
const query = String(sql);
|
const query = String(sql);
|
||||||
queries.push([query, params || []]);
|
queries.push([query, params || []]);
|
||||||
|
if (query.includes('SELECT e.details_json FROM o_audit_events e WHERE e.id = ?')) {
|
||||||
|
return [[{
|
||||||
|
details_json: JSON.stringify({
|
||||||
|
changes: {
|
||||||
|
'content.heading.value': { from: 'Old heading', to: 'New heading' },
|
||||||
|
'content.qr.qr_svg': { from: '[Generated asset: 13 bytes; SHA-256 old]', to: '[Generated asset: 13 bytes; SHA-256 new]' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}]];
|
||||||
|
}
|
||||||
if (query.includes('SELECT COUNT(*) AS count')) {
|
if (query.includes('SELECT COUNT(*) AS count')) {
|
||||||
return [[{ count: 2 }]];
|
return [[{ count: 2 }]];
|
||||||
}
|
}
|
||||||
@@ -34,42 +64,8 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
event_type: 'login.success',
|
event_type: 'login.success',
|
||||||
target_label: 'newer',
|
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',
|
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({
|
details_json: '{}',
|
||||||
changes: {
|
has_changes: 1
|
||||||
content: {
|
|
||||||
from: { title: 'Same', body: { color: 'red', keep: 'same' } },
|
|
||||||
to: { title: 'Same', body: { color: 'blue', keep: 'same' } }
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
from: ['dashboard.read', 'screens.read'],
|
|
||||||
to: ['screens.read', 'playlists.read']
|
|
||||||
},
|
|
||||||
backgroundColor: {
|
|
||||||
from: null,
|
|
||||||
to: '#111111'
|
|
||||||
},
|
|
||||||
logoPath: {
|
|
||||||
from: '',
|
|
||||||
to: '/media/logo.png'
|
|
||||||
},
|
|
||||||
'content.heading.value': {
|
|
||||||
from: 'Old heading',
|
|
||||||
to: 'New heading'
|
|
||||||
},
|
|
||||||
'content.added.value': {
|
|
||||||
from: null,
|
|
||||||
to: 'Added text'
|
|
||||||
},
|
|
||||||
'content.removed.value': {
|
|
||||||
from: 'Removed text',
|
|
||||||
to: null
|
|
||||||
},
|
|
||||||
'content.qr.qr_svg': {
|
|
||||||
from: null,
|
|
||||||
to: '[Generated asset: 13 bytes; SHA-256 fingerprint]'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -77,7 +73,8 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
category: 'authentication',
|
category: 'authentication',
|
||||||
event_type: 'login.failed',
|
event_type: 'login.failed',
|
||||||
target_label: 'older',
|
target_label: 'older',
|
||||||
details_json: null
|
details_json: null,
|
||||||
|
has_changes: 0
|
||||||
}
|
}
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
@@ -114,31 +111,32 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
assert.match(response.body, /login\.success/);
|
assert.match(response.body, /login\.success/);
|
||||||
assert.match(response.body, />Chrome 150\.0\.0\.0 on Windows</);
|
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.doesNotMatch(response.body, /Mozilla\/5\.0 \(Windows NT 10\.0; Win64; x64\)/);
|
||||||
assert.match(response.body, /content\.body\.color/);
|
assert.doesNotMatch(response.body, /content\.heading\.value|Old heading|New heading/);
|
||||||
assert.match(response.body, />red</);
|
assert.match(response.body, /data-audit-detail-url="\/settings\/audit-log\/1\/details"/);
|
||||||
assert.match(response.body, />blue</);
|
assert.match(response.body, /id="audit-change-detail-modal"/);
|
||||||
assert.doesNotMatch(response.body, /content\.body\.keep/);
|
assert.match(response.body, /\/assets\/js\/settings\/audit-log\.js/);
|
||||||
assert.match(response.body, /permissions removed/);
|
|
||||||
assert.match(response.body, /permissions added/);
|
|
||||||
assert.match(response.body, /dashboard\.read/);
|
|
||||||
assert.match(response.body, /playlists\.read/);
|
|
||||||
assert.match(response.body, /backgroundColor added/);
|
|
||||||
assert.match(response.body, /logoPath added/);
|
|
||||||
assert.match(response.body, /content\.heading\.value/);
|
|
||||||
assert.match(response.body, />New heading</);
|
|
||||||
assert.match(response.body, /content\.added\.value added/);
|
|
||||||
assert.match(response.body, /content\.removed\.value removed/);
|
|
||||||
assert.match(response.body, /Generated asset: 13 bytes; SHA-256 fingerprint/);
|
|
||||||
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
|
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
|
||||||
assert.match(response.body, />Export</);
|
assert.match(response.body, />Export</);
|
||||||
assert.ok(handlers['/settings/audit-log/export']);
|
assert.ok(handlers['/settings/audit-log/export']);
|
||||||
const pageQuery = queries.find(([sql]) => sql.includes('LIMIT ? OFFSET ?'));
|
const pageQuery = queries.find(([sql]) => sql.includes('LIMIT ? OFFSET ?'));
|
||||||
assert.ok(pageQuery);
|
assert.ok(pageQuery);
|
||||||
assert.match(pageQuery[0], /ORDER BY e\.occurred_at DESC, e\.id DESC LIMIT \? OFFSET \?/);
|
assert.match(pageQuery[0], /ORDER BY e\.occurred_at DESC, e\.id DESC LIMIT \? OFFSET \?/);
|
||||||
|
assert.match(pageQuery[0], /JSON_CONTAINS_PATH\(e\.details_json, 'one', '\$\.changes'\) AS has_changes/);
|
||||||
assert.deepEqual(pageQuery[1].slice(-2), [25, 0]);
|
assert.deepEqual(pageQuery[1].slice(-2), [25, 0]);
|
||||||
assert.ok(queries.some(([sql]) => sql.includes('SELECT COUNT(*) AS count')));
|
assert.ok(queries.some(([sql]) => sql.includes('SELECT COUNT(*) AS count')));
|
||||||
assert.ok(queries.some(([sql]) => sql.includes('SELECT DISTINCT e.event_type') && /WHERE e\.event_type IS NOT NULL/.test(sql)));
|
assert.ok(queries.some(([sql]) => sql.includes('SELECT DISTINCT e.event_type') && /WHERE e\.event_type IS NOT NULL/.test(sql)));
|
||||||
|
|
||||||
|
let popupPayload;
|
||||||
|
await handlers['/settings/audit-log/:id/details'][1]({ params: { id: '1' } }, {
|
||||||
|
json(value) { popupPayload = value; }
|
||||||
|
}, function (error) {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
assert.deepEqual(popupPayload.changeRows.map(row => [row.path, row.from, row.to]), [
|
||||||
|
['content.heading.value', 'Old heading', 'New heading'],
|
||||||
|
['content.qr.qr_svg', '[Generated asset: 13 bytes; SHA-256 old]', '[Generated asset: 13 bytes; SHA-256 new]']
|
||||||
|
]);
|
||||||
|
|
||||||
const exportResponse = {
|
const exportResponse = {
|
||||||
headers: {},
|
headers: {},
|
||||||
setHeader(name, value) { this.headers[name] = value; },
|
setHeader(name, value) { this.headers[name] = value; },
|
||||||
@@ -153,6 +151,12 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
const exportQuery = queries.filter(([sql]) => sql.includes('FROM o_audit_events') && !sql.includes('SELECT COUNT(*)') && !sql.includes('SELECT DISTINCT')).at(-1);
|
const exportQuery = queries.filter(([sql]) => sql.includes('FROM o_audit_events') && !sql.includes('SELECT COUNT(*)') && !sql.includes('SELECT DISTINCT')).at(-1);
|
||||||
assert.ok(exportQuery);
|
assert.ok(exportQuery);
|
||||||
assert.doesNotMatch(exportQuery[0], /LIMIT \?/);
|
assert.doesNotMatch(exportQuery[0], /LIMIT \?/);
|
||||||
|
assert.match(exportQuery[0], /e\.details_json/);
|
||||||
|
assert.doesNotMatch(exportQuery[0], /JSON_REMOVE/);
|
||||||
|
const listQuery = queries.find(([sql]) => sql.includes('LIMIT ? OFFSET ?'));
|
||||||
|
assert.ok(listQuery);
|
||||||
|
assert.match(listQuery[0], /JSON_CONTAINS_PATH\(e\.details_json, 'one', '\$\.changes'\) AS has_changes/);
|
||||||
|
assert.match(listQuery[0], /JSON_REMOVE\(e\.details_json, '\$\.changes'\) AS details_json/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('audit log applies SQL filters, escaped search, and selected sort before pagination', async () => {
|
test('audit log applies SQL filters, escaped search, and selected sort before pagination', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { hashPassword, encodePasswordRecord, verifyEncodedPassword, hashScryptPassword, verifyCredentialPassword } = require('../src/auth');
|
const { hashPassword, encodePasswordRecord, verifyEncodedPassword, hashScryptPassword, verifyCredentialPassword } = require('../src/auth');
|
||||||
const { createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError } = require('../src/web/lib/auth/better-auth');
|
const { createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError, ensureBetterAuthClientIp } = require('../src/web/lib/auth/better-auth');
|
||||||
|
|
||||||
|
test('Better Auth client IP falls back to the socket address for direct requests', () => {
|
||||||
|
for (const address of ['192.0.2.1', '::1', '::ffff:192.0.2.1']) {
|
||||||
|
const req = { headers: {}, socket: { remoteAddress: address } };
|
||||||
|
ensureBetterAuthClientIp(req);
|
||||||
|
assert.equal(req.headers['x-forwarded-for'], address);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Better Auth client IP preserves forwarded addresses and does not invent a missing address', () => {
|
||||||
|
const req = { headers: { 'x-forwarded-for': '192.0.2.1, 192.0.2.2' }, socket: { remoteAddress: '192.0.2.3' } };
|
||||||
|
ensureBetterAuthClientIp(req);
|
||||||
|
assert.equal(req.headers['x-forwarded-for'], '192.0.2.1, 192.0.2.2');
|
||||||
|
for (const directReq of [{ headers: {} }, { headers: {}, socket: {} }]) {
|
||||||
|
ensureBetterAuthClientIp(directReq);
|
||||||
|
assert.equal(directReq.headers['x-forwarded-for'], undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('passkey RP ID uses localhost for local HTTP-IP configuration and the canonical hostname otherwise', () => {
|
test('passkey RP ID uses localhost for local HTTP-IP configuration and the canonical hostname otherwise', () => {
|
||||||
assert.equal(resolvePasskeyRpID('http://192.168.0.80:8080'), 'localhost');
|
assert.equal(resolvePasskeyRpID('http://192.168.0.80:8080'), 'localhost');
|
||||||
|
|||||||
Reference in new issue
Block a user