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.
- package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +51 -11
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +7 -1
- package/dist/assets/adapters/next/templates/init/components/layouts/sidebar-branding.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +107 -0
- package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +58 -45
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/lifecycle-hooks-tab.tsx +3 -3
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/snippets-tab.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/entity-filters-bar.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/media/edit-media-dialog-content.tsx +2 -2
- package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/sort-indicator.tsx +7 -3
- package/dist/assets/adapters/next/templates/init/data/navigation.ts +2 -0
- package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-table-button.ts +85 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/get-audit-logs.ts +41 -16
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/index.ts +2 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +5 -0
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +2 -1
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +2 -1
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-drawer.tsx +51 -43
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-row.tsx +2 -4
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +465 -179
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-skeleton.tsx +3 -15
- package/dist/assets/adapters/next/templates/init/pages/dashboard-page.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/forgot-password-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/login-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/reset-password-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/users/users-page-content.tsx +83 -2
- package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +126 -78
- package/dist/assets/adapters/next/templates/init/utils/editor/tiptap.ts +7 -8
- package/dist/assets/adapters/next/templates/init/utils/form/list-field.ts +17 -0
- package/dist/assets/adapters/next/templates/init/utils/user/user.ts +70 -0
- package/dist/assets/shared-assets/react-admin/custom/content-editor/math-bubble-menu.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/selection-bubble-menu.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode.tsx +0 -2
- package/dist/assets/shared-assets/react-admin/custom/content-editor/table-button.tsx +17 -65
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-control-popover.tsx +3 -8
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-popover.tsx +1 -2
- package/dist/assets/shared-assets/react-admin/custom/date-range-picker.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/dynamic-list-field.tsx +20 -8
- package/dist/assets/shared-assets/react-admin/custom/gallery-field.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/custom/nested-object-list-field.tsx +211 -0
- package/dist/assets/shared-assets/react-admin/custom/placeholder-card.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/placeholder.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/sortable-gallery-item.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/schema.json +136 -0
- package/dist/assets/shared-assets/react-admin/ui/accordion.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/alert.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/card.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/chart.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/checkbox.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/combobox.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/command.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/context-menu.tsx +6 -6
- package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/dropdown-menu.tsx +6 -6
- package/dist/assets/shared-assets/react-admin/ui/empty.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/field.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/form.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/hover-card.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/item.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/menubar.tsx +7 -7
- package/dist/assets/shared-assets/react-admin/ui/navigation-menu.tsx +4 -4
- package/dist/assets/shared-assets/react-admin/ui/popover.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/resizable.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/select.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +3 -3
- package/dist/assets/shared-assets/react-admin/ui/skeleton.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/tabs.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/textarea.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/tooltip.tsx +1 -1
- package/dist/cli.js +2688 -1644
- package/dist/cli.js.map +1 -1
- package/package.json +10 -2
- 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 {
|
|
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 =
|
|
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(
|
|
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
|
|
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:
|
|
67
|
-
pageSize:
|
|
232
|
+
page: safePageIndex,
|
|
233
|
+
pageSize: safePageSize,
|
|
234
|
+
sort: safeSortField,
|
|
235
|
+
dir: safeSortDir
|
|
68
236
|
}),
|
|
69
|
-
[filters,
|
|
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
|
|
75
|
-
const
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
q:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
onClick={() => void setFilters({ page: safePage + 1 })}
|
|
396
|
+
disabled={cleanupMutation.isPending}
|
|
397
|
+
onClick={runCleanup}
|
|
264
398
|
>
|
|
265
|
-
|
|
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
|
}
|