Release v2.20.2
This commit is contained in:
1 parent
220c3f401e
commit
61d553f74b
14 files changed
+395
-94
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
|
||||
@@ -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">→</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>
|
||||
@@ -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}}
|
||||
|
||||
Reference in new issue
Block a user