feat: add Audit Log admin page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 23:08:53 +01:00
parent cffda9a5a7
commit af3219a7df
2 changed files with 325 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
.header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
font-family: var(--font-body);
}
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.filterInput {
width: 200px;
}
.filterSelect {
width: 160px;
}
.tableWrap {
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-body);
font-size: 12px;
}
.th {
text-align: left;
padding: 10px 12px;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
background: var(--bg-raised);
border-bottom: 1px solid var(--border-subtle);
position: sticky;
top: 0;
z-index: 1;
}
.row {
cursor: pointer;
transition: background 0.1s;
}
.row:hover {
background: var(--bg-hover);
}
.td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
vertical-align: middle;
}
.userCell {
font-weight: 500;
}
.target {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
padding: 32px;
text-align: center;
color: var(--text-faint);
}
.detailRow {
background: var(--bg-raised);
}
.detailCell {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.detailGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.detailField {
display: flex;
flex-direction: column;
gap: 4px;
}
.detailLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
font-family: var(--font-body);
}
.detailValue {
font-size: 12px;
color: var(--text-secondary);
}
.detailJson {
display: flex;
flex-direction: column;
gap: 6px;
}
.pagination {
display: flex;
justify-content: center;
margin-top: 16px;
}

View File

@@ -0,0 +1,186 @@
import { useState, useMemo } from 'react'
import { AdminLayout } from '../Admin'
import { Badge } from '../../../design-system/primitives/Badge/Badge'
import { DateRangePicker } from '../../../design-system/primitives/DateRangePicker/DateRangePicker'
import { Input } from '../../../design-system/primitives/Input/Input'
import { Select } from '../../../design-system/primitives/Select/Select'
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
import { CodeBlock } from '../../../design-system/primitives/CodeBlock/CodeBlock'
import { Pagination } from '../../../design-system/primitives/Pagination/Pagination'
import type { DateRange } from '../../../design-system/utils/timePresets'
import { AUDIT_EVENTS, type AuditEvent } from './auditMocks'
import styles from './AuditLog.module.css'
const CATEGORIES = [
{ value: '', label: 'All categories' },
{ value: 'INFRA', label: 'INFRA' },
{ value: 'AUTH', label: 'AUTH' },
{ value: 'USER_MGMT', label: 'USER_MGMT' },
{ value: 'CONFIG', label: 'CONFIG' },
]
const PAGE_SIZE = 10
function formatTimestamp(iso: string): string {
return new Date(iso).toLocaleString('en-GB', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
})
}
const now = Date.now()
const INITIAL_RANGE: DateRange = {
from: new Date(now - 7 * 24 * 3600_000).toISOString().slice(0, 16),
to: new Date(now).toISOString().slice(0, 16),
}
export function AuditLog() {
const [dateRange, setDateRange] = useState<DateRange>(INITIAL_RANGE)
const [userFilter, setUserFilter] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [searchFilter, setSearchFilter] = useState('')
const [page, setPage] = useState(1)
const [expandedId, setExpandedId] = useState<number | null>(null)
const filtered = useMemo(() => {
const from = new Date(dateRange.from).getTime()
const to = new Date(dateRange.to).getTime()
return AUDIT_EVENTS.filter((e) => {
const ts = new Date(e.timestamp).getTime()
if (ts < from || ts > to) return false
if (userFilter && !e.username.toLowerCase().includes(userFilter.toLowerCase())) return false
if (categoryFilter && e.category !== categoryFilter) return false
if (searchFilter) {
const q = searchFilter.toLowerCase()
if (!e.action.toLowerCase().includes(q) && !e.target.toLowerCase().includes(q)) return false
}
return true
})
}, [dateRange, userFilter, categoryFilter, searchFilter])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const pageEvents = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return (
<AdminLayout title="Audit Log">
<div className={styles.header}>
<h2 className={styles.title}>Audit Log</h2>
<Badge label={`${filtered.length} events`} color="primary" />
</div>
<div className={styles.filters}>
<DateRangePicker
value={dateRange}
onChange={(r) => { setDateRange(r); setPage(1) }}
/>
<Input
placeholder="Filter by user..."
value={userFilter}
onChange={(e) => { setUserFilter(e.target.value); setPage(1) }}
onClear={() => { setUserFilter(''); setPage(1) }}
className={styles.filterInput}
/>
<Select
options={CATEGORIES}
value={categoryFilter}
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1) }}
className={styles.filterSelect}
/>
<Input
placeholder="Search action or target..."
value={searchFilter}
onChange={(e) => { setSearchFilter(e.target.value); setPage(1) }}
onClear={() => { setSearchFilter(''); setPage(1) }}
className={styles.filterInput}
/>
</div>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th} style={{ width: 170 }}>Timestamp</th>
<th className={styles.th}>User</th>
<th className={styles.th} style={{ width: 100 }}>Category</th>
<th className={styles.th}>Action</th>
<th className={styles.th}>Target</th>
<th className={styles.th} style={{ width: 80 }}>Result</th>
</tr>
</thead>
<tbody>
{pageEvents.map((event) => (
<EventRow
key={event.id}
event={event}
expanded={expandedId === event.id}
onToggle={() => setExpandedId(expandedId === event.id ? null : event.id)}
/>
))}
{pageEvents.length === 0 && (
<tr>
<td colSpan={6} className={styles.empty}>No events match the current filters.</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</AdminLayout>
)
}
function EventRow({ event, expanded, onToggle }: { event: AuditEvent; expanded: boolean; onToggle: () => void }) {
return (
<>
<tr className={styles.row} onClick={onToggle}>
<td className={styles.td}>
<MonoText size="xs">{formatTimestamp(event.timestamp)}</MonoText>
</td>
<td className={`${styles.td} ${styles.userCell}`}>{event.username}</td>
<td className={styles.td}>
<Badge label={event.category} color="auto" />
</td>
<td className={styles.td}>{event.action}</td>
<td className={styles.td}>
<span className={styles.target}>{event.target}</span>
</td>
<td className={styles.td}>
<Badge
label={event.result}
color={event.result === 'SUCCESS' ? 'success' : 'error'}
/>
</td>
</tr>
{expanded && (
<tr className={styles.detailRow}>
<td colSpan={6} className={styles.detailCell}>
<div className={styles.detailGrid}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>IP Address</span>
<MonoText size="xs">{event.ipAddress}</MonoText>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>User Agent</span>
<span className={styles.detailValue}>{event.userAgent}</span>
</div>
</div>
<div className={styles.detailJson}>
<span className={styles.detailLabel}>Detail</span>
<CodeBlock content={JSON.stringify(event.detail, null, 2)} language="json" />
</div>
</td>
</tr>
)}
</>
)
}