@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.
- package/LICENSE +21 -21
- package/README.md +13 -13
- package/icons.json +170 -170
- package/package.json +71 -71
- package/src/api/client.ts +139 -139
- package/src/components/ConfirmIdentityModal.tsx +82 -82
- package/src/components/ConfirmModal.tsx +83 -83
- package/src/components/CopyButton.tsx +71 -71
- package/src/components/CredentialSection.tsx +270 -270
- package/src/components/ErrorBoundary.tsx +47 -47
- package/src/components/ModalShell.tsx +160 -160
- package/src/components/ResetTwoFactorModal.tsx +74 -74
- package/src/components/SidePanel.tsx +96 -96
- package/src/components/SingleSignOnModal.tsx +161 -161
- package/src/components/SsoConfirmation.tsx +96 -96
- package/src/components/VaultTypePicker.tsx +131 -131
- package/src/components/WhatsNew.tsx +84 -84
- package/src/components/formParts.tsx +59 -59
- package/src/components/ui/Alert.tsx +45 -45
- package/src/components/ui/DataTable.tsx +225 -225
- package/src/components/ui/Field.tsx +63 -63
- package/src/components/ui/PageHeader.tsx +36 -36
- package/src/components/ui/Pagination.tsx +37 -37
- package/src/components/ui/QuickStartPanel.tsx +57 -57
- package/src/components/ui/StateViews.tsx +135 -135
- package/src/components/ui/Switch.tsx +65 -65
- package/src/components/ui/Toast.tsx +104 -104
- package/src/components/ui/index.ts +9 -9
- package/src/config.ts +46 -46
- package/src/hooks/useDarkMode.ts +38 -38
- package/src/hooks/useDebouncedValue.ts +22 -22
- package/src/index.ts +42 -42
- package/src/lib/changelog.ts +93 -93
- package/src/lib/dateFormat.ts +90 -90
- package/src/navigation.ts +6 -6
- package/src/pages/ApiTokens.tsx +361 -361
- package/src/pages/AuditLog.tsx +388 -388
- package/src/pages/Backups.tsx +170 -170
- package/src/pages/ChangePassword.tsx +84 -84
- package/src/pages/Login.tsx +275 -275
- package/src/pages/Settings.tsx +264 -264
- package/src/pages/Setup.tsx +438 -438
- package/src/pages/SsoConfirm.tsx +26 -26
- package/src/pages/SsoTestResult.tsx +104 -104
- package/src/pages/Users.tsx +426 -426
- package/src/pages/Vault.tsx +1036 -1036
- package/src/shell/AdminShell.tsx +256 -256
- package/src/shell/guards.tsx +88 -88
- package/styles/base.css +521 -521
- package/styles/icons.css +4 -4
- package/tailwind-preset.js +79 -79
package/src/pages/AuditLog.tsx
CHANGED
|
@@ -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
|
+
}
|