From 7c949274c5b3f78f269a69e1f1db8ce8d5db93e1 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:11:16 +0100 Subject: [PATCH] feat: add Audit Log admin page with filtering, pagination, and detail expansion Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/admin/AuditLogPage.module.css | 260 +++++++++++++++++++++ ui/src/pages/admin/AuditLogPage.tsx | 225 ++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 ui/src/pages/admin/AuditLogPage.module.css create mode 100644 ui/src/pages/admin/AuditLogPage.tsx diff --git a/ui/src/pages/admin/AuditLogPage.module.css b/ui/src/pages/admin/AuditLogPage.module.css new file mode 100644 index 00000000..2d24e5c8 --- /dev/null +++ b/ui/src/pages/admin/AuditLogPage.module.css @@ -0,0 +1,260 @@ +.page { + max-width: 1100px; + margin: 0 auto; + padding: 32px 16px; +} + +.pageTitle { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.totalCount { + font-size: 13px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.accessDenied { + text-align: center; + padding: 64px 16px; + color: var(--text-muted); + font-size: 14px; +} + +/* ─── Filters ─── */ +.filters { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 20px; + padding: 16px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} + +.filterGroup { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 120px; +} + +.filterGroup:nth-child(3), +.filterGroup:nth-child(5) { + flex: 1; + min-width: 150px; +} + +.filterLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.filterInput { + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 7px 10px; + color: var(--text-primary); + font-size: 12px; + outline: none; + transition: border-color 0.2s; +} + +.filterInput:focus { + border-color: var(--amber-dim); +} + +.filterInput::placeholder { + color: var(--text-muted); +} + +.filterSelect { + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 7px 10px; + color: var(--text-primary); + font-size: 12px; + outline: none; + cursor: pointer; +} + +/* ─── Table ─── */ +.tableWrapper { + overflow-x: auto; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th { + text-align: left; + padding: 10px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + border-bottom: 1px solid var(--border-subtle); + white-space: nowrap; +} + +.table td { + padding: 8px 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-subtle); +} + +.eventRow { + cursor: pointer; + transition: background 0.1s; +} + +.eventRow:hover { + background: var(--bg-hover); +} + +.eventRowExpanded { + background: var(--bg-hover); +} + +.mono { + font-family: var(--font-mono); + font-size: 11px; + white-space: nowrap; +} + +.categoryBadge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + background: var(--bg-raised); + border: 1px solid var(--border); + color: var(--text-secondary); +} + +.resultBadge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.resultSuccess { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; +} + +.resultFailure { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +/* ─── Detail Row ─── */ +.detailRow td { + padding: 0 12px 12px; + background: var(--bg-hover); +} + +.detailJson { + margin: 0; + padding: 12px; + background: var(--bg-base); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +/* ─── Pagination ─── */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 16px; +} + +.pageBtn { + padding: 6px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-raised); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} + +.pageBtn:hover:not(:disabled) { + border-color: var(--amber-dim); + color: var(--text-primary); +} + +.pageBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pageInfo { + font-size: 12px; + color: var(--text-muted); +} + +.loading { + text-align: center; + padding: 32px; + color: var(--text-muted); + font-size: 14px; +} + +.emptyState { + text-align: center; + padding: 48px 16px; + color: var(--text-muted); + font-size: 13px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} + +@media (max-width: 768px) { + .filters { + flex-direction: column; + } + + .filterGroup { + min-width: unset; + } +} diff --git a/ui/src/pages/admin/AuditLogPage.tsx b/ui/src/pages/admin/AuditLogPage.tsx new file mode 100644 index 00000000..cf39e5fa --- /dev/null +++ b/ui/src/pages/admin/AuditLogPage.tsx @@ -0,0 +1,225 @@ +import { useState } from 'react'; +import { useAuthStore } from '../../auth/auth-store'; +import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit'; +import styles from './AuditLogPage.module.css'; + +function defaultFrom(): string { + const d = new Date(); + d.setDate(d.getDate() - 7); + return d.toISOString().slice(0, 10); +} + +function defaultTo(): string { + return new Date().toISOString().slice(0, 10); +} + +export function AuditLogPage() { + const roles = useAuthStore((s) => s.roles); + + if (!roles.includes('ADMIN')) { + return ( +
+
+ Access Denied — this page requires the ADMIN role. +
+
+ ); + } + + return ; +} + +function AuditLogContent() { + const [from, setFrom] = useState(defaultFrom); + const [to, setTo] = useState(defaultTo); + const [username, setUsername] = useState(''); + const [category, setCategory] = useState(''); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(0); + const [expandedRow, setExpandedRow] = useState(null); + const pageSize = 25; + + const params: AuditLogParams = { + from: from || undefined, + to: to || undefined, + username: username || undefined, + category: category || undefined, + search: search || undefined, + page, + size: pageSize, + }; + + const audit = useAuditLog(params); + const data = audit.data; + const totalPages = data ? Math.ceil(data.total / pageSize) : 0; + const showingFrom = data && data.total > 0 ? page * pageSize + 1 : 0; + const showingTo = data ? Math.min((page + 1) * pageSize, data.total) : 0; + + return ( +
+
+

Audit Log

+ {data && ( + {data.total.toLocaleString()} events + )} +
+ +
+
+ + { setFrom(e.target.value); setPage(0); }} + /> +
+
+ + { setTo(e.target.value); setPage(0); }} + /> +
+
+ + { setUsername(e.target.value); setPage(0); }} + /> +
+
+ + +
+
+ + { setSearch(e.target.value); setPage(0); }} + /> +
+
+ + {audit.isLoading ? ( +
Loading...
+ ) : !data || data.events.length === 0 ? ( +
No audit events found for the selected filters.
+ ) : ( + <> +
+ + + + + + + + + + + + + {data.events.map((event) => ( + <> + + setExpandedRow((prev) => (prev === event.id ? null : event.id)) + } + > + + + + + + + + {expandedRow === event.id && ( + + + + )} + + ))} + +
TimestampUserCategoryActionTargetResult
+ {formatTimestamp(event.timestamp)} + {event.username} + {event.category} + {event.action}{event.target} + + {event.result} + +
+
+                            {JSON.stringify(event.detail, null, 2)}
+                          
+
+
+ +
+ + + Showing {showingFrom}-{showingTo} of {data.total.toLocaleString()} + + +
+ + )} +
+ ); +} + +function formatTimestamp(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return iso; + } +}