@better-forge/ui 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +13 -13
  3. package/icons.json +170 -170
  4. package/package.json +71 -71
  5. package/src/api/client.ts +139 -139
  6. package/src/components/ConfirmIdentityModal.tsx +82 -82
  7. package/src/components/ConfirmModal.tsx +83 -83
  8. package/src/components/CopyButton.tsx +71 -71
  9. package/src/components/CredentialSection.tsx +270 -270
  10. package/src/components/ErrorBoundary.tsx +47 -47
  11. package/src/components/ModalShell.tsx +160 -160
  12. package/src/components/ResetTwoFactorModal.tsx +74 -74
  13. package/src/components/SidePanel.tsx +96 -96
  14. package/src/components/SingleSignOnModal.tsx +161 -161
  15. package/src/components/SsoConfirmation.tsx +96 -96
  16. package/src/components/VaultTypePicker.tsx +131 -131
  17. package/src/components/WhatsNew.tsx +84 -84
  18. package/src/components/formParts.tsx +59 -59
  19. package/src/components/ui/Alert.tsx +45 -45
  20. package/src/components/ui/DataTable.tsx +225 -225
  21. package/src/components/ui/Field.tsx +63 -63
  22. package/src/components/ui/PageHeader.tsx +36 -36
  23. package/src/components/ui/Pagination.tsx +37 -37
  24. package/src/components/ui/QuickStartPanel.tsx +57 -57
  25. package/src/components/ui/StateViews.tsx +135 -135
  26. package/src/components/ui/Switch.tsx +65 -65
  27. package/src/components/ui/Toast.tsx +104 -104
  28. package/src/components/ui/index.ts +9 -9
  29. package/src/config.ts +46 -46
  30. package/src/hooks/useDarkMode.ts +38 -38
  31. package/src/hooks/useDebouncedValue.ts +22 -22
  32. package/src/index.ts +42 -42
  33. package/src/lib/changelog.ts +93 -93
  34. package/src/lib/dateFormat.ts +90 -90
  35. package/src/navigation.ts +6 -6
  36. package/src/pages/ApiTokens.tsx +361 -361
  37. package/src/pages/AuditLog.tsx +388 -388
  38. package/src/pages/Backups.tsx +170 -170
  39. package/src/pages/ChangePassword.tsx +84 -84
  40. package/src/pages/Login.tsx +275 -275
  41. package/src/pages/Settings.tsx +264 -264
  42. package/src/pages/Setup.tsx +438 -438
  43. package/src/pages/SsoConfirm.tsx +26 -26
  44. package/src/pages/SsoTestResult.tsx +104 -104
  45. package/src/pages/Users.tsx +426 -426
  46. package/src/pages/Vault.tsx +1036 -1036
  47. package/src/shell/AdminShell.tsx +256 -256
  48. package/src/shell/guards.tsx +88 -88
  49. package/styles/base.css +521 -521
  50. package/styles/icons.css +4 -4
  51. package/tailwind-preset.js +79 -79
@@ -1,388 +1,388 @@
1
- import { useId, useMemo, useState } from 'react'
2
- import { formatDateTime } from '../lib/dateFormat'
3
- import { useQuery } from '@tanstack/react-query'
4
- import { api } from '../api/client'
5
- import { EmptyState, EmptyStateLink, ErrorState, LoadingState, PageContainer, PageHeader, Pagination, ResizeHandle } from '../components/ui'
6
- import { useDebouncedValue } from '../hooks/useDebouncedValue'
7
- import type { AuditAction, AuditLogEntry } from '@better-forge/contracts'
8
-
9
- /** How an application names the things its audit log is about: labels and icons by type, and the sections of the filter. */
10
- export interface AuditEntities {
11
- labels: Readonly<Record<string, string>>
12
- icons: Readonly<Record<string, string>>
13
- /** Filter sections, most used first. */
14
- groups: ReadonlyArray<{ label: string; types: readonly string[] }>
15
- }
16
-
17
- interface AuditPage {
18
- entries: AuditLogEntry[]
19
- total: number
20
- page: number
21
- limit: number
22
- pages: number
23
- }
24
-
25
- const ACTION_COLORS: Record<AuditAction, { bg: string; color: string; label: string }> = {
26
- create: { bg: 'var(--audit-create-bg)', color: 'var(--audit-create-text)', label: 'Create' },
27
- update: { bg: 'var(--audit-update-bg)', color: 'var(--audit-update-text)', label: 'Update' },
28
- delete: { bg: 'var(--audit-delete-bg)', color: 'var(--audit-delete-text)', label: 'Delete' },
29
- allow: { bg: 'var(--audit-create-bg)', color: 'var(--audit-create-text)', label: 'Allowed' },
30
- deny: { bg: 'var(--audit-delete-bg)', color: 'var(--audit-delete-text)', label: 'Denied' },
31
- }
32
-
33
- function formatTs(ms: number) {
34
- return formatDateTime(ms, true)
35
- }
36
-
37
- function DiffView({ diff }: { diff: Record<string, unknown> }) {
38
- const entries = Object.entries(diff)
39
- if (entries.length === 0) return <span style={{ color: 'var(--m3-outline)' }}>—</span>
40
-
41
- // Detect if values are {from, to} pairs (update diff) or a flat snapshot
42
- const isUpdateDiff = entries.every(([, v]) =>
43
- v !== null && typeof v === 'object' && 'from' in (v as object) && 'to' in (v as object),
44
- )
45
-
46
- if (isUpdateDiff) {
47
- return (
48
- <table style={{ borderCollapse: 'collapse', fontSize: '11px', width: '100%' }}>
49
- <thead>
50
- <tr>
51
- <th style={{ textAlign: 'left', padding: '2px 8px 2px 0', color: 'var(--m3-outline)', fontWeight: 600, whiteSpace: 'nowrap' }}>Field</th>
52
- <th style={{ textAlign: 'left', padding: '2px 8px', color: 'var(--m3-outline)', fontWeight: 600 }}>Before</th>
53
- <th style={{ textAlign: 'left', padding: '2px 0', color: 'var(--m3-outline)', fontWeight: 600 }}>After</th>
54
- </tr>
55
- </thead>
56
- <tbody>
57
- {entries.map(([key, val]) => {
58
- const { from, to } = val as { from: unknown; to: unknown }
59
- return (
60
- <tr key={key}>
61
- <td style={{ padding: '2px 8px 2px 0', color: 'var(--m3-secondary)', whiteSpace: 'nowrap', verticalAlign: 'top' }}>{key}</td>
62
- <td style={{ padding: '2px 8px', verticalAlign: 'top' }}>
63
- <span style={{ color: 'var(--audit-delete-text)', background: 'var(--audit-delete-bg)', borderRadius: 4, padding: '0 4px', display: 'inline-block', wordBreak: 'break-all' }}>
64
- {from === null ? 'null' : String(from)}
65
- </span>
66
- </td>
67
- <td style={{ padding: '2px 0', verticalAlign: 'top' }}>
68
- <span style={{ color: 'var(--audit-create-text)', background: 'var(--audit-create-bg)', borderRadius: 4, padding: '0 4px', display: 'inline-block', wordBreak: 'break-all' }}>
69
- {to === null ? 'null' : String(to)}
70
- </span>
71
- </td>
72
- </tr>
73
- )
74
- })}
75
- </tbody>
76
- </table>
77
- )
78
- }
79
-
80
- // Flat snapshot (create/delete)
81
- return (
82
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
83
- {entries.map(([key, val]) => (
84
- <span key={key} style={{ fontSize: '11px' }}>
85
- <span style={{ color: 'var(--m3-outline)' }}>{key}: </span>
86
- <span style={{ color: 'var(--m3-on-surface)' }}>{val === null ? 'null' : String(val)}</span>
87
- </span>
88
- ))}
89
- </div>
90
- )
91
- }
92
-
93
- const HEADERS = ['Timestamp', 'User', 'Action', 'Entity'] as const
94
- const DEFAULT_WIDTHS = [168, 220, 100, 200]
95
- const DETAILS_MIN = 160
96
- const FILTER_LABEL = 'font-mono text-[10px] uppercase tracking-widest'
97
-
98
- export default function AuditLogPage({ entities }: { entities: AuditEntities }) {
99
- const [page, setPage] = useState(1)
100
- const [emailInput, setEmailInput] = useState('')
101
- const [userEmail, flushEmail] = useDebouncedValue(emailInput, 300)
102
- const [entityType, setEntityType] = useState('')
103
- const [action, setAction] = useState('')
104
- const [fromDate, setFromDate] = useState('')
105
- const [toDate, setToDate] = useState('')
106
- const [expanded, setExpanded] = useState<number | null>(null)
107
- const [widths, setWidths] = useState(DEFAULT_WIDTHS)
108
- const idPrefix = useId()
109
- const gridColumns = `${widths.map((w) => `${w}px`).join(' ')} minmax(${DETAILS_MIN}px, 1fr)`
110
- const tableMinWidth = widths.reduce((a, b) => a + b, 0) + DETAILS_MIN + 40
111
-
112
- function resizeColumn(index: number, width: number) {
113
- setWidths((prev) => prev.map((w, i) => (i === index ? width : w)))
114
- }
115
-
116
- // Reset to page 1 when filters change
117
- const filters = useMemo(() => ({
118
- page,
119
- limit: 50,
120
- ...(userEmail ? { userEmail } : {}),
121
- ...(entityType ? { entityType } : {}),
122
- ...(action ? { action } : {}),
123
- ...(fromDate ? { from: String(new Date(fromDate).getTime()) } : {}),
124
- ...(toDate ? { to: String(new Date(toDate + 'T23:59:59').getTime()) } : {}),
125
- }), [page, userEmail, entityType, action, fromDate, toDate])
126
-
127
- const qs = new URLSearchParams(
128
- Object.fromEntries(Object.entries(filters).map(([k, v]) => [k, String(v)])),
129
- ).toString()
130
-
131
- const { data, isFetching, isLoading, isError, refetch } = useQuery<AuditPage>({
132
- queryKey: ['audit', qs],
133
- queryFn: () => api.get(`/admin/audit?${qs}`),
134
- refetchOnMount: 'always',
135
- })
136
-
137
- function clearFilters() {
138
- setEmailInput(''); flushEmail(''); setEntityType(''); setAction(''); setFromDate(''); setToDate(''); setPage(1)
139
- }
140
-
141
- const entries = data?.entries ?? []
142
- const filtered = !!(emailInput || entityType || action || fromDate || toDate)
143
- const totalPages = data?.pages ?? 1
144
-
145
- return (
146
- <PageContainer>
147
- <PageHeader title="Audit Log" subtitle={data ? `${data.total} entries` : undefined} />
148
-
149
- {/* Filters */}
150
- <div
151
- className="rounded-2xl p-4 flex flex-wrap gap-3 items-end"
152
- style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
153
- >
154
- <div className="flex flex-col gap-1 min-w-[180px] flex-1">
155
- <label htmlFor={`${idPrefix}-user`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>User</label>
156
- <input
157
- id={`${idPrefix}-user`}
158
- value={emailInput}
159
- onChange={(e) => { setEmailInput(e.target.value); setPage(1) }}
160
- onKeyDown={(e) => { if (e.key === 'Enter') flushEmail() }}
161
- placeholder="Filter by email…"
162
- className="input-sig"
163
- style={{ height: '36px', fontSize: '13px' }}
164
- />
165
- </div>
166
-
167
- <div className="flex flex-col gap-1">
168
- <label htmlFor={`${idPrefix}-entity`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>Entity</label>
169
- <select
170
- id={`${idPrefix}-entity`}
171
- value={entityType}
172
- onChange={(e) => { setEntityType(e.target.value); setPage(1) }}
173
- className="input-sig"
174
- style={{ height: '36px', fontSize: '13px', width: '180px' }}
175
- >
176
- <option value="">All entities</option>
177
- {entities.groups.map((group) => (
178
- <optgroup key={group.label} label={group.label}>
179
- {group.types.map((t) => <option key={t} value={t}>{entities.labels[t]}</option>)}
180
- </optgroup>
181
- ))}
182
- </select>
183
- </div>
184
-
185
- <div className="flex flex-col gap-1">
186
- <label htmlFor={`${idPrefix}-action`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>Action</label>
187
- <select
188
- id={`${idPrefix}-action`}
189
- value={action}
190
- onChange={(e) => { setAction(e.target.value); setPage(1) }}
191
- className="input-sig"
192
- style={{ height: '36px', fontSize: '13px', width: '130px' }}
193
- >
194
- <option value="">All actions</option>
195
- <option value="create">Create</option>
196
- <option value="update">Update</option>
197
- <option value="delete">Delete</option>
198
- <option value="allow">Allowed</option>
199
- <option value="deny">Denied</option>
200
- </select>
201
- </div>
202
-
203
- <div className="flex flex-col gap-1">
204
- <label htmlFor={`${idPrefix}-from`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>From</label>
205
- <input
206
- id={`${idPrefix}-from`}
207
- type="date"
208
- value={fromDate}
209
- onChange={(e) => { setFromDate(e.target.value); setPage(1) }}
210
- className="input-sig"
211
- style={{ height: '36px', fontSize: '13px', width: '150px' }}
212
- />
213
- </div>
214
-
215
- <div className="flex flex-col gap-1">
216
- <label htmlFor={`${idPrefix}-to`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>To</label>
217
- <input
218
- id={`${idPrefix}-to`}
219
- type="date"
220
- value={toDate}
221
- onChange={(e) => { setToDate(e.target.value); setPage(1) }}
222
- className="input-sig"
223
- style={{ height: '36px', fontSize: '13px', width: '150px' }}
224
- />
225
- </div>
226
-
227
- {(emailInput || entityType || action || fromDate || toDate) && (
228
- <button type="button" onClick={clearFilters} className="btn btn-ghost btn-sm self-end" style={{ height: '36px' }}>
229
- Clear
230
- </button>
231
- )}
232
- </div>
233
-
234
- {/* Table */}
235
- {isLoading ? (
236
- <LoadingState label="Loading audit log…" />
237
- ) : isError ? (
238
- <ErrorState message="Could not load the audit log." onRetry={() => void refetch()} />
239
- ) : (
240
- <div
241
- className="rounded-2xl overflow-x-auto"
242
- style={{ border: '1px solid var(--m3-outline-variant)', opacity: isFetching ? 0.7 : 1, transition: 'opacity 0.15s' }}
243
- >
244
- <div style={{ minWidth: tableMinWidth }}>
245
- {/* Header */}
246
- <div
247
- className="grid font-mono text-xs uppercase tracking-wider px-5 py-2.5"
248
- style={{
249
- gridTemplateColumns: gridColumns,
250
- color: 'var(--m3-outline)',
251
- borderBottom: '1px solid var(--m3-outline-variant)',
252
- background: 'var(--m3-surface-container)',
253
- }}
254
- >
255
- {HEADERS.map((label, i) => (
256
- <span key={label} className="relative -my-2.5 py-2.5 pr-3">
257
- {label}
258
- <ResizeHandle
259
- label={label}
260
- width={widths[i]}
261
- measure={() => widths[i] ?? 0}
262
- onResize={(w) => resizeColumn(i, w)}
263
- onReset={() => resizeColumn(i, DEFAULT_WIDTHS[i] ?? 0)}
264
- />
265
- </span>
266
- ))}
267
- <span>Details</span>
268
- </div>
269
-
270
- {entries.map((entry) => {
271
- const ac = ACTION_COLORS[entry.action]
272
- const entityLabel = entities.labels[entry.entityType] ?? entry.entityType
273
- const entityIcon = entities.icons[entry.entityType] ?? 'article'
274
- const isExpanded = expanded === entry.id
275
- const hasDiff = entry.diff && Object.keys(entry.diff).length > 0
276
- const diffId = `${idPrefix}-diff-${entry.id}`
277
- const fieldCount = hasDiff ? Object.keys(entry.diff!).length : 0
278
-
279
- return (
280
- <div
281
- key={entry.id}
282
- style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}
283
- >
284
- {/* Main row */}
285
- <div
286
- className="grid items-center px-5 py-3"
287
- style={{ gridTemplateColumns: gridColumns }}
288
- >
289
- {/* Timestamp */}
290
- <span className="font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>
291
- {formatTs(entry.timestamp)}
292
- </span>
293
-
294
- {/* User */}
295
- <span className="text-sm truncate" style={{ color: 'var(--m3-on-surface)' }} title={entry.userEmail}>
296
- {entry.userEmail}
297
- </span>
298
-
299
- {/* Action badge */}
300
- <span
301
- className="text-xs font-bold px-2 py-0.5 rounded-full w-fit"
302
- style={{ background: ac.bg, color: ac.color }}
303
- >
304
- {ac.label}
305
- </span>
306
-
307
- {/* Entity */}
308
- <div className="flex items-center gap-1.5 min-w-0">
309
- <span className="material-symbols-outlined flex-shrink-0" aria-hidden="true" style={{ fontSize: '14px', color: 'var(--m3-secondary)' }}>
310
- {entityIcon}
311
- </span>
312
- <div className="min-w-0">
313
- <div className="font-mono text-[10px] uppercase tracking-wider" style={{ color: 'var(--m3-outline)' }}>
314
- {entityLabel}
315
- </div>
316
- <div className="text-xs truncate" style={{ color: 'var(--m3-on-surface)' }} title={entry.entityName}>
317
- {entry.entityName}
318
- </div>
319
- </div>
320
- </div>
321
-
322
- {/* Details / expand */}
323
- {hasDiff ? (
324
- <button
325
- type="button"
326
- onClick={() => setExpanded(isExpanded ? null : entry.id)}
327
- aria-expanded={isExpanded}
328
- aria-controls={isExpanded ? diffId : undefined}
329
- className="focus-ring flex items-center justify-between gap-2 min-w-0 -mx-2 px-2 py-1.5 rounded-lg text-left transition-colors hover:bg-[var(--m3-surface-container-low)]"
330
- >
331
- <span className="text-xs" style={{ color: 'var(--m3-secondary)' }}>
332
- {isExpanded ? 'Hide details' : entry.action === 'deny' ? 'Show reason' : entry.action === 'allow' ? 'Show details' : `${fieldCount} field${fieldCount !== 1 ? 's' : ''} changed`}
333
- </span>
334
- <span
335
- className="material-symbols-outlined"
336
- aria-hidden="true"
337
- style={{
338
- fontSize: '16px', color: 'var(--m3-secondary)',
339
- transform: isExpanded ? 'rotate(180deg)' : 'none',
340
- transition: 'transform 0.2s',
341
- }}
342
- >
343
- expand_more
344
- </span>
345
- </button>
346
- ) : (
347
- <span className="text-xs" style={{ color: 'var(--m3-outline)' }}>—</span>
348
- )}
349
- </div>
350
-
351
- {/* Expanded diff */}
352
- {isExpanded && hasDiff && (
353
- <div
354
- id={diffId}
355
- className="px-5 py-3"
356
- style={{ borderTop: '1px solid var(--m3-outline-variant)', background: 'var(--m3-surface-container-lowest)' }}
357
- >
358
- <DiffView diff={entry.diff!} />
359
- </div>
360
- )}
361
- </div>
362
- )
363
- })}
364
-
365
- {entries.length === 0 && (
366
- <EmptyState
367
- variant="row"
368
- className="px-5 py-7"
369
- icon="history"
370
- title="No audit entries found."
371
- description={filtered
372
- ? <>Try widening the date range, or <EmptyStateLink onClick={clearFilters}>clear all filters</EmptyStateLink>.</>
373
- : 'Changes made in the admin panel and every sign-in, allowed or denied, are recorded here.'}
374
- />
375
- )}
376
- </div>
377
- </div>
378
- )}
379
-
380
- <Pagination
381
- page={page}
382
- pageCount={totalPages}
383
- onPageChange={setPage}
384
- summary={`Page ${page} of ${totalPages} · ${data?.total ?? 0} entries`}
385
- />
386
- </PageContainer>
387
- )
388
- }
1
+ import { useId, useMemo, useState } from 'react'
2
+ import { formatDateTime } from '../lib/dateFormat'
3
+ import { useQuery } from '@tanstack/react-query'
4
+ import { api } from '../api/client'
5
+ import { EmptyState, EmptyStateLink, ErrorState, LoadingState, PageContainer, PageHeader, Pagination, ResizeHandle } from '../components/ui'
6
+ import { useDebouncedValue } from '../hooks/useDebouncedValue'
7
+ import type { AuditAction, AuditLogEntry } from '@better-forge/contracts'
8
+
9
+ /** How an application names the things its audit log is about: labels and icons by type, and the sections of the filter. */
10
+ export interface AuditEntities {
11
+ labels: Readonly<Record<string, string>>
12
+ icons: Readonly<Record<string, string>>
13
+ /** Filter sections, most used first. */
14
+ groups: ReadonlyArray<{ label: string; types: readonly string[] }>
15
+ }
16
+
17
+ interface AuditPage {
18
+ entries: AuditLogEntry[]
19
+ total: number
20
+ page: number
21
+ limit: number
22
+ pages: number
23
+ }
24
+
25
+ const ACTION_COLORS: Record<AuditAction, { bg: string; color: string; label: string }> = {
26
+ create: { bg: 'var(--audit-create-bg)', color: 'var(--audit-create-text)', label: 'Create' },
27
+ update: { bg: 'var(--audit-update-bg)', color: 'var(--audit-update-text)', label: 'Update' },
28
+ delete: { bg: 'var(--audit-delete-bg)', color: 'var(--audit-delete-text)', label: 'Delete' },
29
+ allow: { bg: 'var(--audit-create-bg)', color: 'var(--audit-create-text)', label: 'Allowed' },
30
+ deny: { bg: 'var(--audit-delete-bg)', color: 'var(--audit-delete-text)', label: 'Denied' },
31
+ }
32
+
33
+ function formatTs(ms: number) {
34
+ return formatDateTime(ms, true)
35
+ }
36
+
37
+ function DiffView({ diff }: { diff: Record<string, unknown> }) {
38
+ const entries = Object.entries(diff)
39
+ if (entries.length === 0) return <span style={{ color: 'var(--m3-outline)' }}>—</span>
40
+
41
+ // Detect if values are {from, to} pairs (update diff) or a flat snapshot
42
+ const isUpdateDiff = entries.every(([, v]) =>
43
+ v !== null && typeof v === 'object' && 'from' in (v as object) && 'to' in (v as object),
44
+ )
45
+
46
+ if (isUpdateDiff) {
47
+ return (
48
+ <table style={{ borderCollapse: 'collapse', fontSize: '11px', width: '100%' }}>
49
+ <thead>
50
+ <tr>
51
+ <th style={{ textAlign: 'left', padding: '2px 8px 2px 0', color: 'var(--m3-outline)', fontWeight: 600, whiteSpace: 'nowrap' }}>Field</th>
52
+ <th style={{ textAlign: 'left', padding: '2px 8px', color: 'var(--m3-outline)', fontWeight: 600 }}>Before</th>
53
+ <th style={{ textAlign: 'left', padding: '2px 0', color: 'var(--m3-outline)', fontWeight: 600 }}>After</th>
54
+ </tr>
55
+ </thead>
56
+ <tbody>
57
+ {entries.map(([key, val]) => {
58
+ const { from, to } = val as { from: unknown; to: unknown }
59
+ return (
60
+ <tr key={key}>
61
+ <td style={{ padding: '2px 8px 2px 0', color: 'var(--m3-secondary)', whiteSpace: 'nowrap', verticalAlign: 'top' }}>{key}</td>
62
+ <td style={{ padding: '2px 8px', verticalAlign: 'top' }}>
63
+ <span style={{ color: 'var(--audit-delete-text)', background: 'var(--audit-delete-bg)', borderRadius: 4, padding: '0 4px', display: 'inline-block', wordBreak: 'break-all' }}>
64
+ {from === null ? 'null' : String(from)}
65
+ </span>
66
+ </td>
67
+ <td style={{ padding: '2px 0', verticalAlign: 'top' }}>
68
+ <span style={{ color: 'var(--audit-create-text)', background: 'var(--audit-create-bg)', borderRadius: 4, padding: '0 4px', display: 'inline-block', wordBreak: 'break-all' }}>
69
+ {to === null ? 'null' : String(to)}
70
+ </span>
71
+ </td>
72
+ </tr>
73
+ )
74
+ })}
75
+ </tbody>
76
+ </table>
77
+ )
78
+ }
79
+
80
+ // Flat snapshot (create/delete)
81
+ return (
82
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
83
+ {entries.map(([key, val]) => (
84
+ <span key={key} style={{ fontSize: '11px' }}>
85
+ <span style={{ color: 'var(--m3-outline)' }}>{key}: </span>
86
+ <span style={{ color: 'var(--m3-on-surface)' }}>{val === null ? 'null' : String(val)}</span>
87
+ </span>
88
+ ))}
89
+ </div>
90
+ )
91
+ }
92
+
93
+ const HEADERS = ['Timestamp', 'User', 'Action', 'Entity'] as const
94
+ const DEFAULT_WIDTHS = [168, 220, 100, 200]
95
+ const DETAILS_MIN = 160
96
+ const FILTER_LABEL = 'font-mono text-[10px] uppercase tracking-widest'
97
+
98
+ export default function AuditLogPage({ entities }: { entities: AuditEntities }) {
99
+ const [page, setPage] = useState(1)
100
+ const [emailInput, setEmailInput] = useState('')
101
+ const [userEmail, flushEmail] = useDebouncedValue(emailInput, 300)
102
+ const [entityType, setEntityType] = useState('')
103
+ const [action, setAction] = useState('')
104
+ const [fromDate, setFromDate] = useState('')
105
+ const [toDate, setToDate] = useState('')
106
+ const [expanded, setExpanded] = useState<number | null>(null)
107
+ const [widths, setWidths] = useState(DEFAULT_WIDTHS)
108
+ const idPrefix = useId()
109
+ const gridColumns = `${widths.map((w) => `${w}px`).join(' ')} minmax(${DETAILS_MIN}px, 1fr)`
110
+ const tableMinWidth = widths.reduce((a, b) => a + b, 0) + DETAILS_MIN + 40
111
+
112
+ function resizeColumn(index: number, width: number) {
113
+ setWidths((prev) => prev.map((w, i) => (i === index ? width : w)))
114
+ }
115
+
116
+ // Reset to page 1 when filters change
117
+ const filters = useMemo(() => ({
118
+ page,
119
+ limit: 50,
120
+ ...(userEmail ? { userEmail } : {}),
121
+ ...(entityType ? { entityType } : {}),
122
+ ...(action ? { action } : {}),
123
+ ...(fromDate ? { from: String(new Date(fromDate).getTime()) } : {}),
124
+ ...(toDate ? { to: String(new Date(toDate + 'T23:59:59').getTime()) } : {}),
125
+ }), [page, userEmail, entityType, action, fromDate, toDate])
126
+
127
+ const qs = new URLSearchParams(
128
+ Object.fromEntries(Object.entries(filters).map(([k, v]) => [k, String(v)])),
129
+ ).toString()
130
+
131
+ const { data, isFetching, isLoading, isError, refetch } = useQuery<AuditPage>({
132
+ queryKey: ['audit', qs],
133
+ queryFn: () => api.get(`/admin/audit?${qs}`),
134
+ refetchOnMount: 'always',
135
+ })
136
+
137
+ function clearFilters() {
138
+ setEmailInput(''); flushEmail(''); setEntityType(''); setAction(''); setFromDate(''); setToDate(''); setPage(1)
139
+ }
140
+
141
+ const entries = data?.entries ?? []
142
+ const filtered = !!(emailInput || entityType || action || fromDate || toDate)
143
+ const totalPages = data?.pages ?? 1
144
+
145
+ return (
146
+ <PageContainer>
147
+ <PageHeader title="Audit Log" subtitle={data ? `${data.total} entries` : undefined} />
148
+
149
+ {/* Filters */}
150
+ <div
151
+ className="rounded-2xl p-4 flex flex-wrap gap-3 items-end"
152
+ style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
153
+ >
154
+ <div className="flex flex-col gap-1 min-w-[180px] flex-1">
155
+ <label htmlFor={`${idPrefix}-user`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>User</label>
156
+ <input
157
+ id={`${idPrefix}-user`}
158
+ value={emailInput}
159
+ onChange={(e) => { setEmailInput(e.target.value); setPage(1) }}
160
+ onKeyDown={(e) => { if (e.key === 'Enter') flushEmail() }}
161
+ placeholder="Filter by email…"
162
+ className="input-sig"
163
+ style={{ height: '36px', fontSize: '13px' }}
164
+ />
165
+ </div>
166
+
167
+ <div className="flex flex-col gap-1">
168
+ <label htmlFor={`${idPrefix}-entity`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>Entity</label>
169
+ <select
170
+ id={`${idPrefix}-entity`}
171
+ value={entityType}
172
+ onChange={(e) => { setEntityType(e.target.value); setPage(1) }}
173
+ className="input-sig"
174
+ style={{ height: '36px', fontSize: '13px', width: '180px' }}
175
+ >
176
+ <option value="">All entities</option>
177
+ {entities.groups.map((group) => (
178
+ <optgroup key={group.label} label={group.label}>
179
+ {group.types.map((t) => <option key={t} value={t}>{entities.labels[t]}</option>)}
180
+ </optgroup>
181
+ ))}
182
+ </select>
183
+ </div>
184
+
185
+ <div className="flex flex-col gap-1">
186
+ <label htmlFor={`${idPrefix}-action`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>Action</label>
187
+ <select
188
+ id={`${idPrefix}-action`}
189
+ value={action}
190
+ onChange={(e) => { setAction(e.target.value); setPage(1) }}
191
+ className="input-sig"
192
+ style={{ height: '36px', fontSize: '13px', width: '130px' }}
193
+ >
194
+ <option value="">All actions</option>
195
+ <option value="create">Create</option>
196
+ <option value="update">Update</option>
197
+ <option value="delete">Delete</option>
198
+ <option value="allow">Allowed</option>
199
+ <option value="deny">Denied</option>
200
+ </select>
201
+ </div>
202
+
203
+ <div className="flex flex-col gap-1">
204
+ <label htmlFor={`${idPrefix}-from`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>From</label>
205
+ <input
206
+ id={`${idPrefix}-from`}
207
+ type="date"
208
+ value={fromDate}
209
+ onChange={(e) => { setFromDate(e.target.value); setPage(1) }}
210
+ className="input-sig"
211
+ style={{ height: '36px', fontSize: '13px', width: '150px' }}
212
+ />
213
+ </div>
214
+
215
+ <div className="flex flex-col gap-1">
216
+ <label htmlFor={`${idPrefix}-to`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>To</label>
217
+ <input
218
+ id={`${idPrefix}-to`}
219
+ type="date"
220
+ value={toDate}
221
+ onChange={(e) => { setToDate(e.target.value); setPage(1) }}
222
+ className="input-sig"
223
+ style={{ height: '36px', fontSize: '13px', width: '150px' }}
224
+ />
225
+ </div>
226
+
227
+ {(emailInput || entityType || action || fromDate || toDate) && (
228
+ <button type="button" onClick={clearFilters} className="btn btn-ghost btn-sm self-end" style={{ height: '36px' }}>
229
+ Clear
230
+ </button>
231
+ )}
232
+ </div>
233
+
234
+ {/* Table */}
235
+ {isLoading ? (
236
+ <LoadingState label="Loading audit log…" />
237
+ ) : isError ? (
238
+ <ErrorState message="Could not load the audit log." onRetry={() => void refetch()} />
239
+ ) : (
240
+ <div
241
+ className="rounded-2xl overflow-x-auto"
242
+ style={{ border: '1px solid var(--m3-outline-variant)', opacity: isFetching ? 0.7 : 1, transition: 'opacity 0.15s' }}
243
+ >
244
+ <div style={{ minWidth: tableMinWidth }}>
245
+ {/* Header */}
246
+ <div
247
+ className="grid font-mono text-xs uppercase tracking-wider px-5 py-2.5"
248
+ style={{
249
+ gridTemplateColumns: gridColumns,
250
+ color: 'var(--m3-outline)',
251
+ borderBottom: '1px solid var(--m3-outline-variant)',
252
+ background: 'var(--m3-surface-container)',
253
+ }}
254
+ >
255
+ {HEADERS.map((label, i) => (
256
+ <span key={label} className="relative -my-2.5 py-2.5 pr-3">
257
+ {label}
258
+ <ResizeHandle
259
+ label={label}
260
+ width={widths[i]}
261
+ measure={() => widths[i] ?? 0}
262
+ onResize={(w) => resizeColumn(i, w)}
263
+ onReset={() => resizeColumn(i, DEFAULT_WIDTHS[i] ?? 0)}
264
+ />
265
+ </span>
266
+ ))}
267
+ <span>Details</span>
268
+ </div>
269
+
270
+ {entries.map((entry) => {
271
+ const ac = ACTION_COLORS[entry.action]
272
+ const entityLabel = entities.labels[entry.entityType] ?? entry.entityType
273
+ const entityIcon = entities.icons[entry.entityType] ?? 'article'
274
+ const isExpanded = expanded === entry.id
275
+ const hasDiff = entry.diff && Object.keys(entry.diff).length > 0
276
+ const diffId = `${idPrefix}-diff-${entry.id}`
277
+ const fieldCount = hasDiff ? Object.keys(entry.diff!).length : 0
278
+
279
+ return (
280
+ <div
281
+ key={entry.id}
282
+ style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}
283
+ >
284
+ {/* Main row */}
285
+ <div
286
+ className="grid items-center px-5 py-3"
287
+ style={{ gridTemplateColumns: gridColumns }}
288
+ >
289
+ {/* Timestamp */}
290
+ <span className="font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>
291
+ {formatTs(entry.timestamp)}
292
+ </span>
293
+
294
+ {/* User */}
295
+ <span className="text-sm truncate" style={{ color: 'var(--m3-on-surface)' }} title={entry.userEmail}>
296
+ {entry.userEmail}
297
+ </span>
298
+
299
+ {/* Action badge */}
300
+ <span
301
+ className="text-xs font-bold px-2 py-0.5 rounded-full w-fit"
302
+ style={{ background: ac.bg, color: ac.color }}
303
+ >
304
+ {ac.label}
305
+ </span>
306
+
307
+ {/* Entity */}
308
+ <div className="flex items-center gap-1.5 min-w-0">
309
+ <span className="material-symbols-outlined flex-shrink-0" aria-hidden="true" style={{ fontSize: '14px', color: 'var(--m3-secondary)' }}>
310
+ {entityIcon}
311
+ </span>
312
+ <div className="min-w-0">
313
+ <div className="font-mono text-[10px] uppercase tracking-wider" style={{ color: 'var(--m3-outline)' }}>
314
+ {entityLabel}
315
+ </div>
316
+ <div className="text-xs truncate" style={{ color: 'var(--m3-on-surface)' }} title={entry.entityName}>
317
+ {entry.entityName}
318
+ </div>
319
+ </div>
320
+ </div>
321
+
322
+ {/* Details / expand */}
323
+ {hasDiff ? (
324
+ <button
325
+ type="button"
326
+ onClick={() => setExpanded(isExpanded ? null : entry.id)}
327
+ aria-expanded={isExpanded}
328
+ aria-controls={isExpanded ? diffId : undefined}
329
+ className="focus-ring flex items-center justify-between gap-2 min-w-0 -mx-2 px-2 py-1.5 rounded-lg text-left transition-colors hover:bg-[var(--m3-surface-container-low)]"
330
+ >
331
+ <span className="text-xs" style={{ color: 'var(--m3-secondary)' }}>
332
+ {isExpanded ? 'Hide details' : entry.action === 'deny' ? 'Show reason' : entry.action === 'allow' ? 'Show details' : `${fieldCount} field${fieldCount !== 1 ? 's' : ''} changed`}
333
+ </span>
334
+ <span
335
+ className="material-symbols-outlined"
336
+ aria-hidden="true"
337
+ style={{
338
+ fontSize: '16px', color: 'var(--m3-secondary)',
339
+ transform: isExpanded ? 'rotate(180deg)' : 'none',
340
+ transition: 'transform 0.2s',
341
+ }}
342
+ >
343
+ expand_more
344
+ </span>
345
+ </button>
346
+ ) : (
347
+ <span className="text-xs" style={{ color: 'var(--m3-outline)' }}>—</span>
348
+ )}
349
+ </div>
350
+
351
+ {/* Expanded diff */}
352
+ {isExpanded && hasDiff && (
353
+ <div
354
+ id={diffId}
355
+ className="px-5 py-3"
356
+ style={{ borderTop: '1px solid var(--m3-outline-variant)', background: 'var(--m3-surface-container-lowest)' }}
357
+ >
358
+ <DiffView diff={entry.diff!} />
359
+ </div>
360
+ )}
361
+ </div>
362
+ )
363
+ })}
364
+
365
+ {entries.length === 0 && (
366
+ <EmptyState
367
+ variant="row"
368
+ className="px-5 py-7"
369
+ icon="history"
370
+ title="No audit entries found."
371
+ description={filtered
372
+ ? <>Try widening the date range, or <EmptyStateLink onClick={clearFilters}>clear all filters</EmptyStateLink>.</>
373
+ : 'Changes made in the admin panel and every sign-in, allowed or denied, are recorded here.'}
374
+ />
375
+ )}
376
+ </div>
377
+ </div>
378
+ )}
379
+
380
+ <Pagination
381
+ page={page}
382
+ pageCount={totalPages}
383
+ onPageChange={setPage}
384
+ summary={`Page ${page} of ${totalPages} · ${data?.total ?? 0} entries`}
385
+ />
386
+ </PageContainer>
387
+ )
388
+ }