betterstart-cli 0.0.13 → 0.0.14

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 (76) hide show
  1. package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +51 -11
  2. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +7 -1
  3. package/dist/assets/adapters/next/templates/init/components/layouts/sidebar-branding.tsx +1 -1
  4. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +107 -0
  5. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +58 -45
  6. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/lifecycle-hooks-tab.tsx +3 -3
  7. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/snippets-tab.tsx +1 -1
  8. package/dist/assets/adapters/next/templates/init/components/shared/entity-filters-bar.tsx +1 -1
  9. package/dist/assets/adapters/next/templates/init/components/shared/media/edit-media-dialog-content.tsx +2 -2
  10. package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +1 -1
  11. package/dist/assets/adapters/next/templates/init/components/shared/sort-indicator.tsx +7 -3
  12. package/dist/assets/adapters/next/templates/init/data/navigation.ts +2 -0
  13. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-table-button.ts +85 -0
  14. package/dist/assets/adapters/next/templates/init/lib/actions/audit/get-audit-logs.ts +41 -16
  15. package/dist/assets/adapters/next/templates/init/lib/actions/audit/index.ts +2 -0
  16. package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +5 -0
  17. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +2 -1
  18. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +2 -1
  19. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-drawer.tsx +51 -43
  20. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-row.tsx +2 -4
  21. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +465 -179
  22. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-skeleton.tsx +3 -15
  23. package/dist/assets/adapters/next/templates/init/pages/dashboard-page.tsx +1 -1
  24. package/dist/assets/adapters/next/templates/init/pages/forgot-password-page-skeleton.tsx +1 -1
  25. package/dist/assets/adapters/next/templates/init/pages/login-page-skeleton.tsx +1 -1
  26. package/dist/assets/adapters/next/templates/init/pages/reset-password-page-skeleton.tsx +1 -1
  27. package/dist/assets/adapters/next/templates/init/pages/users/users-page-content.tsx +83 -2
  28. package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +126 -78
  29. package/dist/assets/adapters/next/templates/init/utils/editor/tiptap.ts +7 -8
  30. package/dist/assets/adapters/next/templates/init/utils/form/list-field.ts +17 -0
  31. package/dist/assets/adapters/next/templates/init/utils/user/user.ts +70 -0
  32. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-bubble-menu.tsx +1 -1
  33. package/dist/assets/shared-assets/react-admin/custom/content-editor/selection-bubble-menu.tsx +1 -1
  34. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode.tsx +0 -2
  35. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-button.tsx +17 -65
  36. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-control-popover.tsx +3 -8
  37. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-popover.tsx +1 -2
  38. package/dist/assets/shared-assets/react-admin/custom/date-range-picker.tsx +1 -1
  39. package/dist/assets/shared-assets/react-admin/custom/dynamic-list-field.tsx +20 -8
  40. package/dist/assets/shared-assets/react-admin/custom/gallery-field.tsx +2 -2
  41. package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +2 -2
  42. package/dist/assets/shared-assets/react-admin/custom/nested-object-list-field.tsx +211 -0
  43. package/dist/assets/shared-assets/react-admin/custom/placeholder-card.tsx +1 -1
  44. package/dist/assets/shared-assets/react-admin/custom/placeholder.tsx +1 -1
  45. package/dist/assets/shared-assets/react-admin/custom/sortable-gallery-item.tsx +1 -1
  46. package/dist/assets/shared-assets/react-admin/schema.json +136 -0
  47. package/dist/assets/shared-assets/react-admin/ui/accordion.tsx +1 -1
  48. package/dist/assets/shared-assets/react-admin/ui/alert.tsx +1 -1
  49. package/dist/assets/shared-assets/react-admin/ui/card.tsx +2 -2
  50. package/dist/assets/shared-assets/react-admin/ui/chart.tsx +1 -1
  51. package/dist/assets/shared-assets/react-admin/ui/checkbox.tsx +1 -1
  52. package/dist/assets/shared-assets/react-admin/ui/combobox.tsx +2 -2
  53. package/dist/assets/shared-assets/react-admin/ui/command.tsx +1 -1
  54. package/dist/assets/shared-assets/react-admin/ui/context-menu.tsx +6 -6
  55. package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +2 -2
  56. package/dist/assets/shared-assets/react-admin/ui/dropdown-menu.tsx +6 -6
  57. package/dist/assets/shared-assets/react-admin/ui/empty.tsx +2 -2
  58. package/dist/assets/shared-assets/react-admin/ui/field.tsx +1 -1
  59. package/dist/assets/shared-assets/react-admin/ui/form.tsx +1 -1
  60. package/dist/assets/shared-assets/react-admin/ui/hover-card.tsx +1 -1
  61. package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +1 -1
  62. package/dist/assets/shared-assets/react-admin/ui/item.tsx +2 -2
  63. package/dist/assets/shared-assets/react-admin/ui/menubar.tsx +7 -7
  64. package/dist/assets/shared-assets/react-admin/ui/navigation-menu.tsx +4 -4
  65. package/dist/assets/shared-assets/react-admin/ui/popover.tsx +1 -1
  66. package/dist/assets/shared-assets/react-admin/ui/resizable.tsx +1 -1
  67. package/dist/assets/shared-assets/react-admin/ui/select.tsx +2 -2
  68. package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +3 -3
  69. package/dist/assets/shared-assets/react-admin/ui/skeleton.tsx +1 -1
  70. package/dist/assets/shared-assets/react-admin/ui/tabs.tsx +2 -2
  71. package/dist/assets/shared-assets/react-admin/ui/textarea.tsx +1 -1
  72. package/dist/assets/shared-assets/react-admin/ui/tooltip.tsx +1 -1
  73. package/dist/cli.js +2688 -1644
  74. package/dist/cli.js.map +1 -1
  75. package/package.json +10 -2
  76. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-filters.tsx +0 -152
@@ -1,36 +1,60 @@
1
1
  'use client'
2
2
 
3
- import type { AuditLogEntry, AuditLogQuery } from '@admin/actions/audit'
3
+ import type { AuditLogEntry, AuditLogQuery, AuditLogSortField } from '@admin/actions/audit'
4
+ import { AUDIT_LOG_ACTIONS, AUDIT_LOG_CATEGORIES } from '@admin/actions/audit'
5
+ import {
6
+ DataGrid,
7
+ DataGridBody,
8
+ DataGridCell,
9
+ DataGridHead,
10
+ DataGridHeader,
11
+ DataGridRow
12
+ } from '@admin/components/shared/data-table/data-grid'
13
+ import { DataTablePagination } from '@admin/components/shared/data-table/data-table-pagination'
14
+ import { EntityFiltersBar } from '@admin/components/shared/entity-filters-bar'
4
15
  import { PageHeader } from '@admin/components/shared/page-header'
16
+ import { SortIndicator } from '@admin/components/shared/sort-indicator'
5
17
  import { Badge } from '@admin/components/ui/badge'
6
18
  import { Button } from '@admin/components/ui/button'
7
- import {
8
- Table,
9
- TableBody,
10
- TableCell,
11
- TableHead,
12
- TableHeader,
13
- TableRow
14
- } from '@admin/components/ui/table'
19
+ import { Card, CardContent } from '@admin/components/ui/card'
15
20
  import { useAuditLogs, useCleanupAuditLogs } from '@admin/hooks/use-audit-log'
16
- import { LoaderCircle, Trash2 } from 'lucide-react'
21
+ import { useTableUtils } from '@admin/hooks/use-table-utils'
22
+ import { cn } from '@admin/utils/shared/cn'
23
+ import { type ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
24
+ import { GripVertical, LoaderCircle, Trash2 } from 'lucide-react'
17
25
  import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'
18
26
  import * as React from 'react'
19
27
  import { toast } from 'sonner'
20
28
  import { AuditLogDetailDrawer } from './audit-log-detail-drawer'
21
- import { AuditLogFilters } from './audit-log-filters'
22
29
 
23
- const DEFAULT_PAGE_SIZE = 25
30
+ const DEFAULT_PAGE_SIZE = 20
31
+ const AUDIT_LOG_SORT_FIELDS = [
32
+ 'createdAt',
33
+ 'event',
34
+ 'target',
35
+ 'actor',
36
+ 'sourceType'
37
+ ] as const satisfies AuditLogSortField[]
38
+ type AuditLogSortDirection = NonNullable<AuditLogQuery['dir']>
24
39
 
25
40
  const auditLogParsers = {
26
41
  q: parseAsString.withDefault(''),
27
42
  category: parseAsString.withDefault(''),
28
43
  action: parseAsString.withDefault(''),
29
- targetType: parseAsString.withDefault(''),
30
- actor: parseAsString.withDefault(''),
31
44
  createdAtFrom: parseAsString.withDefault(''),
32
45
  createdAtTo: parseAsString.withDefault(''),
33
- page: parseAsInteger.withDefault(1)
46
+ page: parseAsInteger.withDefault(0),
47
+ size: parseAsInteger.withDefault(DEFAULT_PAGE_SIZE),
48
+ sort: parseAsString.withDefault(''),
49
+ dir: parseAsString.withDefault('')
50
+ }
51
+
52
+ function isAuditLogSortField(value: string): value is AuditLogSortField {
53
+ return (AUDIT_LOG_SORT_FIELDS as readonly string[]).includes(value)
54
+ }
55
+
56
+ function isAuditLogSortDirection(value: string): value is AuditLogSortDirection {
57
+ return value === 'asc' || value === 'desc'
34
58
  }
35
59
 
36
60
  function formatDateTime(value: string): string {
@@ -50,56 +74,274 @@ function formatEventLabel(auditLog: AuditLogEntry): string {
50
74
  return `${auditLog.category}.${auditLog.action}`
51
75
  }
52
76
 
77
+ function formatFilterLabel(value: string): string {
78
+ return value
79
+ .split('_')
80
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
81
+ .join(' ')
82
+ }
83
+
84
+ const categorySelectOptions = AUDIT_LOG_CATEGORIES.map((value) => ({
85
+ label: formatFilterLabel(value),
86
+ value
87
+ }))
88
+ const actionSelectOptions = AUDIT_LOG_ACTIONS.map((value) => ({
89
+ label: formatFilterLabel(value),
90
+ value
91
+ }))
92
+
93
+ function SortableAuditLogHeader({
94
+ column,
95
+ label
96
+ }: {
97
+ column: {
98
+ getIsSorted: () => false | 'asc' | 'desc'
99
+ toggleSorting: (desc?: boolean) => void
100
+ }
101
+ label: string
102
+ }) {
103
+ const sortDirection = column.getIsSorted()
104
+ const sortLabel =
105
+ sortDirection === 'asc'
106
+ ? 'sorted ascending'
107
+ : sortDirection === 'desc'
108
+ ? 'sorted descending'
109
+ : 'not sorted'
110
+
111
+ return (
112
+ <Button
113
+ variant="ghost"
114
+ onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
115
+ className="w-full min-w-0 justify-start overflow-hidden px-0! border-none rounded-none bg-transparent! scale-100! group"
116
+ aria-label={`Sort by ${label}, ${sortLabel}`}
117
+ aria-sort={
118
+ sortDirection === 'asc' ? 'ascending' : sortDirection === 'desc' ? 'descending' : 'none'
119
+ }
120
+ >
121
+ <span className="truncate">{label}</span>
122
+ <SortIndicator direction={sortDirection} />
123
+ </Button>
124
+ )
125
+ }
126
+
127
+ function createAuditLogColumns(): ColumnDef<AuditLogEntry>[] {
128
+ return [
129
+ {
130
+ accessorKey: 'createdAt',
131
+ id: 'createdAt',
132
+ size: 180,
133
+ header: ({ column }) => <SortableAuditLogHeader column={column} label="Time" />,
134
+ cell: ({ row }) => (
135
+ <span className="whitespace-nowrap text-muted-foreground">
136
+ {formatDateTime(row.original.createdAt)}
137
+ </span>
138
+ )
139
+ },
140
+ {
141
+ id: 'event',
142
+ size: 160,
143
+ header: ({ column }) => <SortableAuditLogHeader column={column} label="Event" />,
144
+ cell: ({ row }) => <Badge variant="secondary">{formatEventLabel(row.original)}</Badge>
145
+ },
146
+ {
147
+ id: 'target',
148
+ size: 240,
149
+ header: ({ column }) => <SortableAuditLogHeader column={column} label="Target" />,
150
+ cell: ({ row }) => (
151
+ <div className="flex min-w-0 flex-col">
152
+ <span className="truncate font-medium">
153
+ {row.original.targetLabel ?? row.original.targetId ?? 'Unknown'}
154
+ </span>
155
+ <span className="truncate text-xs text-muted-foreground">{row.original.targetType}</span>
156
+ </div>
157
+ )
158
+ },
159
+ {
160
+ id: 'actor',
161
+ size: 220,
162
+ header: ({ column }) => <SortableAuditLogHeader column={column} label="Actor" />,
163
+ cell: ({ row }) => (
164
+ <div className="flex min-w-0 flex-col">
165
+ <span className="truncate">
166
+ {row.original.actorEmail ?? row.original.actorName ?? 'Unknown'}
167
+ </span>
168
+ <span className="truncate text-xs text-muted-foreground">
169
+ {row.original.actorRole ?? 'No role'}
170
+ </span>
171
+ </div>
172
+ )
173
+ },
174
+ {
175
+ accessorKey: 'sourceType',
176
+ id: 'sourceType',
177
+ size: 180,
178
+ header: ({ column }) => <SortableAuditLogHeader column={column} label="Source" />,
179
+ cell: ({ row }) => (
180
+ <div className="text-muted-foreground">
181
+ <div className="truncate">{row.original.sourceType}</div>
182
+ <div className="truncate text-xs">{row.original.ipAddress ?? 'No IP'}</div>
183
+ </div>
184
+ )
185
+ },
186
+ {
187
+ accessorKey: 'changedFields',
188
+ id: 'changedFields',
189
+ size: 180,
190
+ enableSorting: false,
191
+ header: 'Fields',
192
+ cell: ({ row }) =>
193
+ row.original.changedFields.length > 0 ? (
194
+ <div className="flex flex-wrap gap-1">
195
+ {row.original.changedFields.slice(0, 3).map((field) => (
196
+ <Badge key={field} variant="outline">
197
+ {field}
198
+ </Badge>
199
+ ))}
200
+ {row.original.changedFields.length > 3 ? (
201
+ <Badge variant="outline">+{row.original.changedFields.length - 3}</Badge>
202
+ ) : null}
203
+ </div>
204
+ ) : (
205
+ <span className="text-muted-foreground">None</span>
206
+ )
207
+ }
208
+ ]
209
+ }
210
+
53
211
  export function AuditLogPageContent() {
54
212
  const [selectedAuditLog, setSelectedAuditLog] = React.useState<AuditLogEntry | null>(null)
55
213
  const [filters, setFilters] = useQueryStates(auditLogParsers)
56
- const safePage = Math.max(1, filters.page)
214
+ const safePageIndex = Number.isFinite(filters.page) && filters.page > 0 ? filters.page : 0
215
+ const safePageSize =
216
+ filters.size === -1 || (Number.isFinite(filters.size) && filters.size > 0)
217
+ ? filters.size
218
+ : DEFAULT_PAGE_SIZE
219
+ const safeSortField = isAuditLogSortField(filters.sort) ? filters.sort : undefined
220
+ const safeSortDir = safeSortField
221
+ ? isAuditLogSortDirection(filters.dir)
222
+ ? filters.dir
223
+ : 'asc'
224
+ : undefined
57
225
  const query = React.useMemo<AuditLogQuery>(
58
226
  () => ({
59
227
  search: filters.q || undefined,
60
228
  category: filters.category ? (filters.category as AuditLogQuery['category']) : undefined,
61
229
  action: filters.action ? (filters.action as AuditLogQuery['action']) : undefined,
62
- targetType: filters.targetType || undefined,
63
- actor: filters.actor || undefined,
64
230
  createdAtFrom: filters.createdAtFrom || undefined,
65
231
  createdAtTo: filters.createdAtTo || undefined,
66
- page: safePage,
67
- pageSize: DEFAULT_PAGE_SIZE
232
+ page: safePageIndex,
233
+ pageSize: safePageSize,
234
+ sort: safeSortField,
235
+ dir: safeSortDir
68
236
  }),
69
- [filters, safePage]
237
+ [filters, safePageIndex, safePageSize, safeSortDir, safeSortField]
70
238
  )
71
239
  const auditLogsQuery = useAuditLogs(query)
72
240
  const cleanupMutation = useCleanupAuditLogs()
73
241
  const auditLogs = auditLogsQuery.data?.auditLogs ?? []
74
- const total = auditLogsQuery.data?.total ?? 0
75
- const totalPages = auditLogsQuery.data?.totalPages ?? 1
242
+ const totalRows = auditLogsQuery.data?.total ?? 0
243
+ const effectivePageSize = safePageSize === -1 ? totalRows : safePageSize
244
+ const pageCount = effectivePageSize > 0 ? Math.ceil(totalRows / effectivePageSize) : 1
245
+ const shouldShowPagination = totalRows > DEFAULT_PAGE_SIZE
76
246
  const activeFilterCount = [
77
247
  filters.q,
78
248
  filters.category,
79
249
  filters.action,
80
- filters.targetType,
81
- filters.actor,
82
250
  filters.createdAtFrom || filters.createdAtTo
83
251
  ].filter(Boolean).length
84
252
 
85
- function updateFilter(updates: Partial<typeof filters>) {
86
- void setFilters({ ...updates, page: 1 })
87
- }
253
+ const {
254
+ columnSizing,
255
+ handleColumnSizingChange,
256
+ handlePaginationChange,
257
+ handleSortingChange,
258
+ hoveredColumnId,
259
+ registerTable,
260
+ resetColumnSizingToContainer,
261
+ setHoveredColumnId,
262
+ sorting,
263
+ tableContainerRef
264
+ } = useTableUtils<AuditLogEntry>({
265
+ data: auditLogs,
266
+ pageIndex: safePageIndex,
267
+ pageSize: effectivePageSize,
268
+ setPageIndex: (nextPageIndex) => setFilters({ page: nextPageIndex }),
269
+ setSortingParams: ({
270
+ pageIndex: nextPageIndex,
271
+ sortDir: nextSortDir,
272
+ sortField: nextSortField
273
+ }) =>
274
+ setFilters({
275
+ page: nextPageIndex,
276
+ sort: nextSortField,
277
+ dir: nextSortDir
278
+ }),
279
+ sortField: safeSortField,
280
+ sortDir: safeSortDir
281
+ })
282
+ const columns = React.useMemo(() => createAuditLogColumns(), [])
283
+
284
+ const table = useReactTable({
285
+ data: auditLogs,
286
+ columns,
287
+ columnResizeMode: 'onChange',
288
+ defaultColumn: {
289
+ minSize: 48,
290
+ size: 220
291
+ },
292
+ getCoreRowModel: getCoreRowModel(),
293
+ manualPagination: true,
294
+ manualSorting: true,
295
+ pageCount,
296
+ rowCount: totalRows,
297
+ onColumnSizingChange: handleColumnSizingChange,
298
+ onPaginationChange: handlePaginationChange,
299
+ onSortingChange: handleSortingChange,
300
+ state: {
301
+ sorting,
302
+ columnSizing,
303
+ pagination: {
304
+ pageIndex: safePageIndex,
305
+ pageSize: effectivePageSize
306
+ }
307
+ }
308
+ })
309
+ registerTable(table)
310
+ const gridTemplateColumns = table
311
+ .getVisibleLeafColumns()
312
+ .map((column) => `${column.getSize()}px`)
313
+ .join(' ')
314
+
315
+ const updateFilter = React.useCallback(
316
+ (updates: Partial<typeof filters>) => {
317
+ React.startTransition(() => {
318
+ void setFilters({ ...updates, page: 0 })
319
+ })
320
+ },
321
+ [setFilters]
322
+ )
88
323
 
89
- function clearFilters() {
90
- void setFilters({
91
- q: null,
92
- category: null,
93
- action: null,
94
- targetType: null,
95
- actor: null,
96
- createdAtFrom: null,
97
- createdAtTo: null,
98
- page: 1
324
+ const handleSearch = React.useCallback(
325
+ (value: string | null) => {
326
+ updateFilter({ q: value ?? '' })
327
+ },
328
+ [updateFilter]
329
+ )
330
+
331
+ const clearFilters = React.useCallback(() => {
332
+ React.startTransition(() => {
333
+ void setFilters({
334
+ q: null,
335
+ category: null,
336
+ action: null,
337
+ createdAtFrom: null,
338
+ createdAtTo: null,
339
+ page: 0
340
+ })
99
341
  })
100
- }
342
+ }, [setFilters])
101
343
 
102
- function runCleanup() {
344
+ const runCleanup = React.useCallback(() => {
103
345
  cleanupMutation.mutate(undefined, {
104
346
  onSuccess: (result) => {
105
347
  if (!result.success) {
@@ -116,155 +358,199 @@ export function AuditLogPageContent() {
116
358
  toast.error('Failed to clean up audit logs')
117
359
  }
118
360
  })
119
- }
361
+ }, [cleanupMutation])
362
+
363
+ const filtersConfig = [
364
+ {
365
+ key: 'category',
366
+ label: 'Category',
367
+ type: 'select' as const,
368
+ value: filters.category,
369
+ options: categorySelectOptions
370
+ },
371
+ {
372
+ key: 'action',
373
+ label: 'Action',
374
+ type: 'select' as const,
375
+ value: filters.action,
376
+ options: actionSelectOptions
377
+ },
378
+ {
379
+ key: 'createdAt',
380
+ label: 'Created',
381
+ type: 'date-range' as const,
382
+ from: filters.createdAtFrom,
383
+ to: filters.createdAtTo
384
+ }
385
+ ]
120
386
 
121
387
  return (
122
- <>
388
+ <React.Fragment>
123
389
  <PageHeader
124
390
  title="Audit Log"
125
391
  actions={
126
- <Button
127
- type="button"
128
- variant="outline"
129
- size="sm"
130
- disabled={cleanupMutation.isPending}
131
- onClick={runCleanup}
132
- >
133
- {cleanupMutation.isPending ? <LoaderCircle className="animate-spin" /> : <Trash2 />}
134
- Cleanup
135
- </Button>
136
- }
137
- />
138
- <main className="flex min-h-0 flex-1 flex-col">
139
- <AuditLogFilters
140
- search={filters.q}
141
- category={filters.category}
142
- action={filters.action}
143
- targetType={filters.targetType}
144
- actor={filters.actor}
145
- createdAtFrom={filters.createdAtFrom}
146
- createdAtTo={filters.createdAtTo}
147
- activeFilterCount={activeFilterCount}
148
- onSearchChange={(value) => updateFilter({ q: value })}
149
- onCategoryChange={(value) => updateFilter({ category: value })}
150
- onActionChange={(value) => updateFilter({ action: value })}
151
- onTargetTypeChange={(value) => updateFilter({ targetType: value })}
152
- onActorChange={(value) => updateFilter({ actor: value })}
153
- onCreatedAtFromChange={(value) => updateFilter({ createdAtFrom: value })}
154
- onCreatedAtToChange={(value) => updateFilter({ createdAtTo: value })}
155
- onClear={clearFilters}
156
- />
157
- <div className="min-h-0 flex-1 overflow-auto px-4 py-3">
158
- <div className="overflow-hidden rounded-lg border border-border bg-card">
159
- <Table>
160
- <TableHeader>
161
- <TableRow>
162
- <TableHead className="w-[180px]">Time</TableHead>
163
- <TableHead className="w-[160px]">Event</TableHead>
164
- <TableHead>Target</TableHead>
165
- <TableHead className="w-[220px]">Actor</TableHead>
166
- <TableHead className="w-[180px]">Source</TableHead>
167
- <TableHead className="w-[180px]">Fields</TableHead>
168
- </TableRow>
169
- </TableHeader>
170
- <TableBody>
171
- {auditLogsQuery.isPending ? (
172
- <TableRow>
173
- <TableCell colSpan={6} className="h-40 text-center text-muted-foreground">
174
- Loading audit logs...
175
- </TableCell>
176
- </TableRow>
177
- ) : null}
178
- {!auditLogsQuery.isPending && auditLogs.length === 0 ? (
179
- <TableRow>
180
- <TableCell colSpan={6} className="h-40 text-center text-muted-foreground">
181
- No audit logs found.
182
- </TableCell>
183
- </TableRow>
184
- ) : null}
185
- {auditLogs.map((auditLog) => (
186
- <TableRow
187
- key={auditLog.id}
188
- className="cursor-pointer"
189
- onClick={() => setSelectedAuditLog(auditLog)}
190
- >
191
- <TableCell className="whitespace-nowrap text-muted-foreground">
192
- {formatDateTime(auditLog.createdAt)}
193
- </TableCell>
194
- <TableCell>
195
- <Badge variant="secondary">{formatEventLabel(auditLog)}</Badge>
196
- </TableCell>
197
- <TableCell className="min-w-[220px]">
198
- <div className="flex min-w-0 flex-col">
199
- <span className="truncate font-medium">
200
- {auditLog.targetLabel ?? auditLog.targetId ?? 'Unknown'}
201
- </span>
202
- <span className="truncate text-xs text-muted-foreground">
203
- {auditLog.targetType}
204
- </span>
205
- </div>
206
- </TableCell>
207
- <TableCell className="min-w-[180px]">
208
- <div className="flex min-w-0 flex-col">
209
- <span className="truncate">
210
- {auditLog.actorEmail ?? auditLog.actorName ?? 'Unknown'}
211
- </span>
212
- <span className="truncate text-xs text-muted-foreground">
213
- {auditLog.actorRole ?? 'No role'}
214
- </span>
215
- </div>
216
- </TableCell>
217
- <TableCell className="min-w-[160px] text-muted-foreground">
218
- <div className="truncate">{auditLog.sourceType}</div>
219
- <div className="truncate text-xs">{auditLog.ipAddress ?? 'No IP'}</div>
220
- </TableCell>
221
- <TableCell className="min-w-[160px]">
222
- {auditLog.changedFields.length > 0 ? (
223
- <div className="flex flex-wrap gap-1">
224
- {auditLog.changedFields.slice(0, 3).map((field) => (
225
- <Badge key={field} variant="outline">
226
- {field}
227
- </Badge>
228
- ))}
229
- {auditLog.changedFields.length > 3 ? (
230
- <Badge variant="outline">+{auditLog.changedFields.length - 3}</Badge>
231
- ) : null}
232
- </div>
233
- ) : (
234
- <span className="text-muted-foreground">None</span>
235
- )}
236
- </TableCell>
237
- </TableRow>
238
- ))}
239
- </TableBody>
240
- </Table>
241
- </div>
242
- </div>
243
- <div className="flex h-[53px] shrink-0 items-center justify-between border-t border-border bg-background px-4">
244
- <p className="text-sm text-muted-foreground">{total} rows</p>
245
- <div className="flex items-center gap-2">
246
- <Button
247
- type="button"
248
- variant="outline"
249
- size="sm"
250
- disabled={safePage <= 1}
251
- onClick={() => void setFilters({ page: safePage - 1 })}
252
- >
253
- Previous
254
- </Button>
255
- <span className="min-w-20 text-center text-sm text-muted-foreground">
256
- {safePage} / {totalPages}
257
- </span>
392
+ <div className="flex items-center gap-1">
258
393
  <Button
259
394
  type="button"
260
395
  variant="outline"
261
- size="sm"
262
- disabled={safePage >= totalPages}
263
- onClick={() => void setFilters({ page: safePage + 1 })}
396
+ disabled={cleanupMutation.isPending}
397
+ onClick={runCleanup}
264
398
  >
265
- Next
399
+ {cleanupMutation.isPending ? <LoaderCircle className="animate-spin" /> : <Trash2 />}
400
+ Cleanup
266
401
  </Button>
267
402
  </div>
403
+ }
404
+ />
405
+
406
+ <EntityFiltersBar
407
+ filters={filtersConfig}
408
+ activeFilterCount={activeFilterCount}
409
+ onFilterChange={updateFilter}
410
+ onClearAll={clearFilters}
411
+ search={filters.q}
412
+ searchPlaceholder="Search audit logs..."
413
+ isPending={auditLogsQuery.isFetching}
414
+ onSearch={handleSearch}
415
+ />
416
+ <main className="space-y-4 px-4 pb-4 flex-1">
417
+ <div className="flex flex-col gap-6 pt-px pb-2 h-full">
418
+ <Card className="min-h-0 flex-1 p-0">
419
+ <CardContent ref={tableContainerRef} className="flex min-h-0 flex-1 flex-col p-0">
420
+ <DataGrid
421
+ containerClassName="min-h-0 flex-1"
422
+ gridTemplateColumns={gridTemplateColumns}
423
+ style={{ width: table.getTotalSize() }}
424
+ >
425
+ <DataGridHeader>
426
+ {table.getHeaderGroups().map((headerGroup) => (
427
+ <DataGridRow key={headerGroup.id}>
428
+ {headerGroup.headers.map((header, headerIndex) => {
429
+ const resizeLabel =
430
+ typeof header.column.columnDef.header === 'string'
431
+ ? header.column.columnDef.header
432
+ : header.column.id
433
+ const nextHeader = headerGroup.headers[headerIndex + 1]
434
+ const canResizeColumn =
435
+ header.column.getCanResize() && nextHeader?.column.getCanResize() === true
436
+ return (
437
+ <DataGridHead
438
+ key={header.id}
439
+ className="group/resize-header relative overflow-hidden"
440
+ data-column-id={header.column.id}
441
+ onMouseEnter={() => setHoveredColumnId(header.column.id)}
442
+ onMouseLeave={() =>
443
+ setHoveredColumnId((current) =>
444
+ current === header.column.id ? null : current
445
+ )
446
+ }
447
+ >
448
+ {header.isPlaceholder
449
+ ? null
450
+ : flexRender(header.column.columnDef.header, header.getContext())}
451
+ {canResizeColumn ? (
452
+ <button
453
+ type="button"
454
+ aria-label={`Resize ${resizeLabel} column`}
455
+ className="group/resize-handle absolute right-0 top-0 z-10 flex h-full w-5 cursor-col-resize touch-none select-none items-center justify-center text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:text-foreground focus-visible:opacity-100 focus-visible:outline-none group-focus-within/resize-header:opacity-100 data-[column-hovered=true]:opacity-100 data-[resizing=true]:opacity-100"
456
+ data-resize-handle
457
+ data-column-hovered={
458
+ hoveredColumnId === header.column.id ? 'true' : 'false'
459
+ }
460
+ data-resizing={header.column.getIsResizing() ? 'true' : 'false'}
461
+ onDoubleClick={resetColumnSizingToContainer}
462
+ onMouseDown={header.getResizeHandler()}
463
+ onTouchStart={header.getResizeHandler()}
464
+ >
465
+ <GripVertical
466
+ aria-hidden="true"
467
+ className={cn('size-3.5', {
468
+ 'text-ring': header.column.getIsResizing()
469
+ })}
470
+ />
471
+ </button>
472
+ ) : null}
473
+ </DataGridHead>
474
+ )
475
+ })}
476
+ </DataGridRow>
477
+ ))}
478
+ </DataGridHeader>
479
+ <DataGridBody>
480
+ {auditLogsQuery.isPending ? (
481
+ <DataGridRow>
482
+ <DataGridCell
483
+ className="flex h-24 items-center justify-center text-center"
484
+ style={{ gridColumn: '1 / -1' }}
485
+ >
486
+ <div className="text-muted-foreground">Loading Audit Logs...</div>
487
+ </DataGridCell>
488
+ </DataGridRow>
489
+ ) : auditLogsQuery.error ? (
490
+ <DataGridRow>
491
+ <DataGridCell
492
+ className="flex h-24 items-center justify-center text-center"
493
+ style={{ gridColumn: '1 / -1' }}
494
+ >
495
+ <div className="text-destructive">
496
+ Error loading Audit Logs: {auditLogsQuery.error.message}
497
+ </div>
498
+ </DataGridCell>
499
+ </DataGridRow>
500
+ ) : table.getRowModel().rows.length ? (
501
+ table.getRowModel().rows.map((row) => (
502
+ <DataGridRow
503
+ key={row.id}
504
+ className="cursor-pointer hover:bg-muted/50"
505
+ onClick={(event) => {
506
+ const target = event.target as HTMLElement
507
+ if (!event.currentTarget.contains(target)) return
508
+ if (target.closest('button, a, input, [role="checkbox"]')) return
509
+ setSelectedAuditLog(row.original)
510
+ }}
511
+ >
512
+ {row.getVisibleCells().map((cell) => (
513
+ <DataGridCell
514
+ key={cell.id}
515
+ data-column-id={cell.column.id}
516
+ onMouseEnter={() => setHoveredColumnId(cell.column.id)}
517
+ onMouseLeave={() =>
518
+ setHoveredColumnId((current) =>
519
+ current === cell.column.id ? null : current
520
+ )
521
+ }
522
+ >
523
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
524
+ </DataGridCell>
525
+ ))}
526
+ </DataGridRow>
527
+ ))
528
+ ) : (
529
+ <DataGridRow>
530
+ <DataGridCell
531
+ className="flex h-24 items-center justify-center text-center"
532
+ style={{ gridColumn: '1 / -1' }}
533
+ >
534
+ No Audit Logs found.
535
+ </DataGridCell>
536
+ </DataGridRow>
537
+ )}
538
+ </DataGridBody>
539
+ </DataGrid>
540
+ </CardContent>
541
+ </Card>
542
+
543
+ {shouldShowPagination ? (
544
+ <DataTablePagination
545
+ table={table}
546
+ paginationMode="controlled"
547
+ pageSize={safePageSize}
548
+ pageIndex={safePageIndex}
549
+ pageCount={pageCount}
550
+ setPageIndex={(nextPageIndex) => setFilters({ page: nextPageIndex })}
551
+ setPageSize={(nextPageSize) => setFilters({ page: 0, size: nextPageSize })}
552
+ />
553
+ ) : null}
268
554
  </div>
269
555
  </main>
270
556
  <AuditLogDetailDrawer
@@ -274,6 +560,6 @@ export function AuditLogPageContent() {
274
560
  if (!open) setSelectedAuditLog(null)
275
561
  }}
276
562
  />
277
- </>
563
+ </React.Fragment>
278
564
  )
279
565
  }