// ========== AUDIT LOG VIEWER ========== // DC-050: surface authenticated user identity (userEmail / userRole from // auditLogger.details), add outcome filter, pass confirm=CLEAR body for // destructive DELETE. (function() { // Inject modal HTML injectModal('audit-modal', `

📜 Audit Log

Loading audit log...
`); const modal = document.getElementById('audit-modal'); const openBtn = document.getElementById('audit-log-btn'); const cancelBtn = document.getElementById('audit-cancel'); const refreshBtn = document.getElementById('audit-refresh-btn'); const clearBtn = document.getElementById('audit-clear-btn'); const filterSelect = document.getElementById('audit-filter'); const outcomeSelect = document.getElementById('audit-outcome-filter'); const sinceInput = document.getElementById('audit-since'); const untilInput = document.getElementById('audit-until'); const container = document.getElementById('audit-log-container'); const loadMoreBtn = document.getElementById('audit-load-more'); let currentOffset = 0; let inflight = null; // AbortController for the in-flight request let filterNonce = 0; // increments on every fresh (non-append) load; lets // an in-flight append detect the filter has changed // and skip its DOM splice. const PAGE_SIZE = 50; // datetime-local fields carry no timezone offset — convert to ISO 8601 // with the local offset so the server can compare correctly. function toIso(localDtValue) { if (!localDtValue) return null; // Browsers expose datetime-local as naive local time. new Date() on // that string parses it as LOCAL, so toISOString() yields the UTC // equivalent the server expects. const d = new Date(localDtValue); if (isNaN(d.getTime())) return null; return d.toISOString(); } async function loadAudit(append) { try { if (!append) { // Cancel any pending request and bump the filter nonce so any // appending fetch (still in flight) knows to discard its response. if (inflight) inflight.abort(); inflight = new AbortController(); currentOffset = 0; filterNonce++; container.innerHTML = '
Loading...
'; } else { if (inflight) inflight.abort(); inflight = new AbortController(); } const myNonce = filterNonce; const params = new URLSearchParams(); params.set('limit', String(PAGE_SIZE)); params.set('offset', String(currentOffset)); const action = filterSelect.value; const outcome = outcomeSelect.value; const since = toIso(sinceInput.value); const until = toIso(untilInput.value); if (action) params.set('action', action); if (outcome) params.set('outcome', outcome); if (since) params.set('since', since); if (until) params.set('until', until); const res = await fetch('/api/v1/audit-logs?' + params.toString(), { signal: inflight.signal, }); // Surface 401/403/500 explicitly — the dashboard used to render any // non-success response as "no audit log entries yet," which is // misleading for an expired session. if (!res.ok) { container.innerHTML = `
Failed: HTTP ${res.status}
`; loadMoreBtn.style.display = 'none'; return; } const data = await res.json(); if (!data.success) { container.innerHTML = `
Failed: ${escapeHtml(data.error || 'unknown')}
`; loadMoreBtn.style.display = 'none'; return; } // If a non-append load happened after this fetch was issued, the // operator changed filters; discard the now-stale response. if (!append && myNonce !== filterNonce) return; const entries = Array.isArray(data.entries) ? data.entries : []; if (entries.length === 0 && !append) { const reason = data.filters && (data.filters.action || data.filters.outcome || data.filters.since || data.filters.until) ? 'No entries match your filters.' : 'No audit log entries yet. Actions will be logged automatically.'; container.innerHTML = `
📜${escapeHtml(reason)}
`; loadMoreBtn.style.display = 'none'; return; } let html = ''; if (!append) { html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; } for (const e of entries) { const ok = e.outcome === 'success'; const actor = actorLabel(e); html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; if (e.details && Object.keys(e.details).length > 0) { html += ``; } } if (!append) { html += '
WhenActorIPActionResourceResult
${timeAgo(e.timestamp)}${actor}${escapeHtml(e.ip || '-')}${escapeHtml(e.action || '-')}${escapeHtml(e.resource || '-')}${ok ? '✓' : '✗'} ${escapeHtml(e.outcome || '')}
'; container.innerHTML = html; } else { const table = container.querySelector('table'); if (table) table.insertAdjacentHTML('beforeend', html); } currentOffset += entries.length; // hasMore is reported by the server (post-filter total), so the // Load More button stays accurate when filters change mid-scroll. loadMoreBtn.style.display = data.hasMore ? '' : 'none'; // Toggle detail rows on click container.querySelectorAll('.audit-row').forEach(row => { if (row.dataset.wired) return; row.dataset.wired = 'true'; row.addEventListener('click', () => { const detail = row.nextElementSibling; if (detail && detail.classList.contains('audit-detail')) { detail.style.display = detail.style.display === 'none' ? '' : 'none'; } }); }); } catch (e) { // AbortError is expected when we deliberately cancel an in-flight // request (e.g. the operator changed filters mid-fetch) — don't // flash a "Failed: The user aborted a request" message over the // loading spinner. The new fetch has already kicked off. if (e && e.name === 'AbortError') return; container.innerHTML = `
Failed: ${escapeHtml(e.message)}
`; } } // Render the human-readable actor: prefer userEmail, fall back to // userId, fall back to bare IP. If no user attribution, mark as // "system" so the operator knows the entry came from an unauthenticated // or service path (e.g. cron-driven backups). function actorLabel(entry) { const d = entry.details || {}; const email = d.userEmail; const id = d.userId; const role = d.userRole; const provider = d.viaProvider; if (email) { const tag = role ? ` [${escapeHtml(role)}${provider ? '/' + escapeHtml(provider) : ''}]` : ''; return `${escapeHtml(email)}${tag}`; } if (id) return `${escapeHtml(id)}`; if (!entry.ip) return 'system'; return 'anon'; } openBtn?.addEventListener('click', () => { modal?.classList.add('show'); loadAudit(false); }); wireModal(modal, cancelBtn); refreshBtn?.addEventListener('click', () => loadAudit(false)); filterSelect?.addEventListener('change', () => loadAudit(false)); outcomeSelect?.addEventListener('change', () => loadAudit(false)); // Re-fetch on date change only when both fields have a value or both are // empty — typing one character shouldn't trigger a fetch for every keystroke. let dateDebounce; [sinceInput, untilInput].forEach((el) => { el?.addEventListener('change', () => { clearTimeout(dateDebounce); dateDebounce = setTimeout(() => loadAudit(false), 250); }); }); loadMoreBtn?.addEventListener('click', () => loadAudit(true)); clearBtn?.addEventListener('click', async () => { if (!confirm('Clear the entire audit log? This cannot be undone.')) return; try { const res = await secureFetch('/api/v1/audit-logs', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'CLEAR' }), }); const data = await res.json(); if (data.success) loadAudit(false); else showNotification('Error: ' + (data.error || 'Clear failed'), 'error'); } catch (e) { showNotification('Error: ' + e.message, 'error'); } }); })();