betterstart-cli 0.0.12 → 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 (99) hide show
  1. package/dist/assets/adapters/next/plugins/blog/plugin.ts +1 -1
  2. package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +155 -0
  3. package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +2 -7
  4. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +9 -3
  5. package/dist/assets/adapters/next/templates/init/components/layouts/sidebar-branding.tsx +1 -1
  6. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +107 -0
  7. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +58 -45
  8. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/lifecycle-hooks-tab.tsx +1 -1
  9. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
  10. package/dist/assets/adapters/next/templates/init/components/shared/entity-filters-bar.tsx +1 -1
  11. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
  12. package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +20 -17
  13. package/dist/assets/adapters/next/templates/init/components/shared/sort-indicator.tsx +7 -3
  14. package/dist/assets/adapters/next/templates/init/data/navigation.ts +4 -1
  15. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor-slash-menu.ts +28 -2
  16. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor.ts +22 -1
  17. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-table-button.ts +85 -0
  18. package/dist/assets/adapters/next/templates/init/hooks/use-audit-log.ts +23 -0
  19. package/dist/assets/adapters/next/templates/init/lib/actions/audit/cleanup-audit-logs.ts +63 -0
  20. package/dist/assets/adapters/next/templates/init/lib/actions/audit/get-audit-logs.ts +122 -0
  21. package/dist/assets/adapters/next/templates/init/lib/actions/audit/index.ts +19 -0
  22. package/dist/assets/adapters/next/templates/init/lib/actions/audit/record-audit-event.ts +37 -0
  23. package/dist/assets/adapters/next/templates/init/lib/actions/audit/register.ts +13 -0
  24. package/dist/assets/adapters/next/templates/init/lib/actions/audit/request-metadata.ts +37 -0
  25. package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +77 -0
  26. package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +3 -0
  27. package/dist/assets/adapters/next/templates/init/lib/actions/media/create-media.ts +19 -0
  28. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media-bulk.ts +23 -0
  29. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media.ts +21 -0
  30. package/dist/assets/adapters/next/templates/init/lib/actions/media/update-media.ts +23 -1
  31. package/dist/assets/adapters/next/templates/init/lib/actions/profile/update-email.ts +17 -0
  32. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-files.ts +3 -0
  33. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-media-from-url.ts +3 -0
  34. package/dist/assets/adapters/next/templates/init/lib/actions/users/create-user.ts +19 -1
  35. package/dist/assets/adapters/next/templates/init/lib/actions/users/delete-user.ts +23 -1
  36. package/dist/assets/adapters/next/templates/init/lib/actions/users/update-user-role.ts +23 -1
  37. package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +49 -0
  38. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/index.ts +6 -1
  39. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +3 -0
  40. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/registry.ts +36 -1
  41. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +117 -12
  42. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +35 -1
  43. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-drawer.tsx +104 -0
  44. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-row.tsx +15 -0
  45. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +565 -0
  46. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-skeleton.tsx +7 -0
  47. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page.tsx +14 -0
  48. package/dist/assets/adapters/next/templates/init/pages/users/users-page-content.tsx +83 -2
  49. package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +126 -78
  50. package/dist/assets/adapters/next/templates/init/utils/audit/audit.ts +54 -0
  51. package/dist/assets/adapters/next/templates/init/utils/editor/tiptap.ts +7 -32
  52. package/dist/assets/adapters/next/templates/init/utils/form/list-field.ts +17 -0
  53. package/dist/assets/adapters/next/templates/init/utils/user/user.ts +70 -0
  54. package/dist/assets/shared-assets/react-admin/custom/content-editor/horizontal-rule-button.tsx +1 -1
  55. package/dist/assets/shared-assets/react-admin/custom/content-editor/index.tsx +6 -0
  56. package/dist/assets/shared-assets/react-admin/custom/content-editor/main-toolbar-content.tsx +1 -1
  57. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-button.tsx +1 -1
  58. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-editor-controls.tsx +27 -40
  59. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-popover-button.tsx +0 -1
  60. package/dist/assets/shared-assets/react-admin/custom/content-editor/mobile-toolbar-content.tsx +4 -4
  61. package/dist/assets/shared-assets/react-admin/custom/content-editor/mode-toggle-button.tsx +1 -1
  62. package/dist/assets/shared-assets/react-admin/custom/content-editor/selection-bubble-menu.tsx +45 -8
  63. package/dist/assets/shared-assets/react-admin/custom/content-editor/slash-command-menu.tsx +153 -27
  64. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode-dropdown-button.tsx +1 -1
  65. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode.tsx +9 -11
  66. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-bubble-menu.tsx +1 -1
  67. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-button.tsx +24 -67
  68. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/blockquote-button/blockquote-button.tsx +1 -1
  69. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/code-block-button/code-block-button.tsx +1 -1
  70. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-button.tsx +1 -1
  71. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-content.tsx +1 -1
  72. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +11 -2
  73. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/source-color-highlight-popover.tsx +1 -1
  74. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu-item.tsx +1 -1
  75. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +9 -4
  76. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-button.tsx +1 -1
  77. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-control-popover.tsx +5 -12
  78. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-main.tsx +19 -45
  79. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-popover.tsx +2 -3
  80. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu-item.tsx +1 -1
  81. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +9 -4
  82. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/mark-button/mark-button.tsx +1 -1
  83. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/undo-redo-button/undo-redo-button.tsx +1 -1
  84. package/dist/assets/shared-assets/react-admin/custom/date-range-picker.tsx +2 -2
  85. package/dist/assets/shared-assets/react-admin/custom/dynamic-list-field.tsx +20 -8
  86. package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +1 -1
  87. package/dist/assets/shared-assets/react-admin/custom/nested-object-list-field.tsx +211 -0
  88. package/dist/assets/shared-assets/react-admin/schema.json +136 -23
  89. package/dist/assets/shared-assets/react-admin/ui/button.tsx +18 -18
  90. package/dist/assets/shared-assets/react-admin/ui/card.tsx +1 -1
  91. package/dist/assets/shared-assets/react-admin/ui/checkbox.tsx +1 -1
  92. package/dist/assets/shared-assets/react-admin/ui/combobox.tsx +27 -3
  93. package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +9 -5
  94. package/dist/assets/shared-assets/react-admin/ui/dropdown-menu.tsx +27 -12
  95. package/dist/assets/shared-assets/react-admin/ui/form.tsx +1 -1
  96. package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +4 -4
  97. package/dist/cli.js +5396 -2385
  98. package/dist/cli.js.map +1 -1
  99. package/package.json +10 -2
@@ -0,0 +1,104 @@
1
+ 'use client'
2
+
3
+ import type { AuditLogEntry } from '@admin/actions/audit'
4
+ import { Badge } from '@admin/components/ui/badge'
5
+ import { Card, CardContent, CardHeader, CardTitle } from '@admin/components/ui/card'
6
+ import {
7
+ Drawer,
8
+ DrawerContent,
9
+ DrawerDescription,
10
+ DrawerHeader,
11
+ DrawerTitle
12
+ } from '@admin/components/ui/drawer'
13
+ import { ScrollArea } from '@admin/components/ui/scroll-area'
14
+ import { AuditLogDetailRow } from './audit-log-detail-row'
15
+
16
+ interface AuditLogDetailDrawerProps {
17
+ auditLog: AuditLogEntry | null
18
+ open: boolean
19
+ onOpenChange: (open: boolean) => void
20
+ }
21
+
22
+ function formatDateTime(value: string): string {
23
+ const date = new Date(value)
24
+ if (Number.isNaN(date.getTime())) return value
25
+
26
+ return new Intl.DateTimeFormat('en-US', {
27
+ dateStyle: 'medium',
28
+ timeStyle: 'medium'
29
+ }).format(date)
30
+ }
31
+
32
+ function formatMetadata(metadata: Record<string, unknown> | null): string {
33
+ if (!metadata || Object.keys(metadata).length === 0) return '{}'
34
+ return JSON.stringify(metadata, null, 2)
35
+ }
36
+
37
+ export function AuditLogDetailDrawer({ auditLog, open, onOpenChange }: AuditLogDetailDrawerProps) {
38
+ return (
39
+ <Drawer direction="right" open={open} onOpenChange={onOpenChange} modal>
40
+ <DrawerContent className="h-full sm:max-w-xl">
41
+ <DrawerHeader>
42
+ <DrawerTitle>Audit Event</DrawerTitle>
43
+ <DrawerDescription>
44
+ {auditLog ? `${auditLog.category}.${auditLog.action}` : 'No event selected'}
45
+ </DrawerDescription>
46
+ </DrawerHeader>
47
+ <ScrollArea className="min-h-0 flex-1 px-6">
48
+ {auditLog ? (
49
+ <div className="flex flex-col w-full gap-4">
50
+ <Card>
51
+ <CardHeader>
52
+ <CardTitle>Event Details</CardTitle>
53
+ </CardHeader>
54
+
55
+ <CardContent className="p-0!">
56
+ <AuditLogDetailRow label="Time" value={formatDateTime(auditLog.createdAt)} />
57
+ <AuditLogDetailRow label="Category" value={auditLog.category} />
58
+ <AuditLogDetailRow label="Action" value={auditLog.action} />
59
+ <AuditLogDetailRow
60
+ label="Target"
61
+ value={auditLog.targetLabel ?? auditLog.targetId}
62
+ />
63
+ <AuditLogDetailRow label="Target Type" value={auditLog.targetType} />
64
+ <AuditLogDetailRow label="Target ID" value={auditLog.targetId} />
65
+ <AuditLogDetailRow
66
+ label="Actor"
67
+ value={auditLog.actorEmail ?? auditLog.actorName ?? auditLog.actorUserId}
68
+ />
69
+ <AuditLogDetailRow label="Actor Role" value={auditLog.actorRole} />
70
+ <AuditLogDetailRow label="Source" value={auditLog.sourceType} />
71
+ <AuditLogDetailRow label="IP" value={auditLog.ipAddress} />
72
+ <AuditLogDetailRow label="User Agent" value={auditLog.userAgent} />
73
+ <AuditLogDetailRow
74
+ label="Fields"
75
+ value={
76
+ auditLog.changedFields.length > 0 ? (
77
+ <div className="flex flex-wrap gap-1">
78
+ {auditLog.changedFields.map((field) => (
79
+ <Badge key={field} variant="secondary">
80
+ {field}
81
+ </Badge>
82
+ ))}
83
+ </div>
84
+ ) : (
85
+ 'None'
86
+ )
87
+ }
88
+ />
89
+ </CardContent>
90
+ </Card>
91
+
92
+ <Card>
93
+ <CardHeader>
94
+ <CardTitle>Event Metadata</CardTitle>
95
+ </CardHeader>
96
+ <CardContent>{formatMetadata(auditLog.metadata)}</CardContent>
97
+ </Card>
98
+ </div>
99
+ ) : null}
100
+ </ScrollArea>
101
+ </DrawerContent>
102
+ </Drawer>
103
+ )
104
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ interface AuditLogDetailRowProps {
4
+ label: string
5
+ value: ReactNode
6
+ }
7
+
8
+ export function AuditLogDetailRow({ label, value }: AuditLogDetailRowProps) {
9
+ return (
10
+ <div className="grid grid-cols-[120px_1fr] gap-3 border-b border-border py-2 px-4 last:border-b-0">
11
+ <dt className="text-sm font-medium text-muted-foreground">{label}</dt>
12
+ <dd className="min-w-0 text-sm text-foreground break-words">{value || 'Unknown'}</dd>
13
+ </div>
14
+ )
15
+ }
@@ -0,0 +1,565 @@
1
+ 'use client'
2
+
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'
15
+ import { PageHeader } from '@admin/components/shared/page-header'
16
+ import { SortIndicator } from '@admin/components/shared/sort-indicator'
17
+ import { Badge } from '@admin/components/ui/badge'
18
+ import { Button } from '@admin/components/ui/button'
19
+ import { Card, CardContent } from '@admin/components/ui/card'
20
+ import { useAuditLogs, useCleanupAuditLogs } from '@admin/hooks/use-audit-log'
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'
25
+ import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'
26
+ import * as React from 'react'
27
+ import { toast } from 'sonner'
28
+ import { AuditLogDetailDrawer } from './audit-log-detail-drawer'
29
+
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']>
39
+
40
+ const auditLogParsers = {
41
+ q: parseAsString.withDefault(''),
42
+ category: parseAsString.withDefault(''),
43
+ action: parseAsString.withDefault(''),
44
+ createdAtFrom: parseAsString.withDefault(''),
45
+ createdAtTo: parseAsString.withDefault(''),
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'
58
+ }
59
+
60
+ function formatDateTime(value: string): string {
61
+ const date = new Date(value)
62
+ if (Number.isNaN(date.getTime())) return value
63
+
64
+ return new Intl.DateTimeFormat('en-US', {
65
+ month: 'short',
66
+ day: '2-digit',
67
+ year: 'numeric',
68
+ hour: 'numeric',
69
+ minute: '2-digit'
70
+ }).format(date)
71
+ }
72
+
73
+ function formatEventLabel(auditLog: AuditLogEntry): string {
74
+ return `${auditLog.category}.${auditLog.action}`
75
+ }
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
+
211
+ export function AuditLogPageContent() {
212
+ const [selectedAuditLog, setSelectedAuditLog] = React.useState<AuditLogEntry | null>(null)
213
+ const [filters, setFilters] = useQueryStates(auditLogParsers)
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
225
+ const query = React.useMemo<AuditLogQuery>(
226
+ () => ({
227
+ search: filters.q || undefined,
228
+ category: filters.category ? (filters.category as AuditLogQuery['category']) : undefined,
229
+ action: filters.action ? (filters.action as AuditLogQuery['action']) : undefined,
230
+ createdAtFrom: filters.createdAtFrom || undefined,
231
+ createdAtTo: filters.createdAtTo || undefined,
232
+ page: safePageIndex,
233
+ pageSize: safePageSize,
234
+ sort: safeSortField,
235
+ dir: safeSortDir
236
+ }),
237
+ [filters, safePageIndex, safePageSize, safeSortDir, safeSortField]
238
+ )
239
+ const auditLogsQuery = useAuditLogs(query)
240
+ const cleanupMutation = useCleanupAuditLogs()
241
+ const auditLogs = auditLogsQuery.data?.auditLogs ?? []
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
246
+ const activeFilterCount = [
247
+ filters.q,
248
+ filters.category,
249
+ filters.action,
250
+ filters.createdAtFrom || filters.createdAtTo
251
+ ].filter(Boolean).length
252
+
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
+ )
323
+
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
+ })
341
+ })
342
+ }, [setFilters])
343
+
344
+ const runCleanup = React.useCallback(() => {
345
+ cleanupMutation.mutate(undefined, {
346
+ onSuccess: (result) => {
347
+ if (!result.success) {
348
+ toast.error(result.error ?? 'Failed to clean up audit logs')
349
+ return
350
+ }
351
+ if (result.skipped) {
352
+ toast.info('Audit retention is not enabled')
353
+ return
354
+ }
355
+ toast.success(`Deleted ${result.deletedCount ?? 0} audit log rows`)
356
+ },
357
+ onError: () => {
358
+ toast.error('Failed to clean up audit logs')
359
+ }
360
+ })
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
+ ]
386
+
387
+ return (
388
+ <React.Fragment>
389
+ <PageHeader
390
+ title="Audit Log"
391
+ actions={
392
+ <div className="flex items-center gap-1">
393
+ <Button
394
+ type="button"
395
+ variant="outline"
396
+ disabled={cleanupMutation.isPending}
397
+ onClick={runCleanup}
398
+ >
399
+ {cleanupMutation.isPending ? <LoaderCircle className="animate-spin" /> : <Trash2 />}
400
+ Cleanup
401
+ </Button>
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}
554
+ </div>
555
+ </main>
556
+ <AuditLogDetailDrawer
557
+ auditLog={selectedAuditLog}
558
+ open={!!selectedAuditLog}
559
+ onOpenChange={(open) => {
560
+ if (!open) setSelectedAuditLog(null)
561
+ }}
562
+ />
563
+ </React.Fragment>
564
+ )
565
+ }
@@ -0,0 +1,7 @@
1
+ export function AuditLogPageSkeleton() {
2
+ return (
3
+ <div className="flex items-center justify-center h-48">
4
+ <div className="text-muted-foreground">Loading Audit Logs...</div>
5
+ </div>
6
+ )
7
+ }