diff --git a/src/pages/Admin/AuditLog/AuditLog.module.css b/src/pages/Admin/AuditLog/AuditLog.module.css new file mode 100644 index 0000000..5d34aec --- /dev/null +++ b/src/pages/Admin/AuditLog/AuditLog.module.css @@ -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; +} diff --git a/src/pages/Admin/AuditLog/AuditLog.tsx b/src/pages/Admin/AuditLog/AuditLog.tsx new file mode 100644 index 0000000..2ec8bc8 --- /dev/null +++ b/src/pages/Admin/AuditLog/AuditLog.tsx @@ -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(INITIAL_RANGE) + const [userFilter, setUserFilter] = useState('') + const [categoryFilter, setCategoryFilter] = useState('') + const [searchFilter, setSearchFilter] = useState('') + const [page, setPage] = useState(1) + const [expandedId, setExpandedId] = useState(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 ( + +
+

Audit Log

+ +
+ +
+ { setDateRange(r); setPage(1) }} + /> + { setUserFilter(e.target.value); setPage(1) }} + onClear={() => { setUserFilter(''); setPage(1) }} + className={styles.filterInput} + /> + { setSearchFilter(e.target.value); setPage(1) }} + onClear={() => { setSearchFilter(''); setPage(1) }} + className={styles.filterInput} + /> +
+ +
+ + + + + + + + + + + + + {pageEvents.map((event) => ( + setExpandedId(expandedId === event.id ? null : event.id)} + /> + ))} + {pageEvents.length === 0 && ( + + + + )} + +
TimestampUserCategoryActionTargetResult
No events match the current filters.
+
+ + {totalPages > 1 && ( +
+ +
+ )} +
+ ) +} + +function EventRow({ event, expanded, onToggle }: { event: AuditEvent; expanded: boolean; onToggle: () => void }) { + return ( + <> + + + {formatTimestamp(event.timestamp)} + + {event.username} + + + + {event.action} + + {event.target} + + + + + + {expanded && ( + + +
+
+ IP Address + {event.ipAddress} +
+
+ User Agent + {event.userAgent} +
+
+
+ Detail + +
+ + + )} + + ) +}