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.
- package/dist/assets/adapters/next/plugins/blog/plugin.ts +1 -1
- package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +155 -0
- package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +2 -7
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +9 -3
- 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 +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.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/entity-versions/entity-versions-drawer.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +20 -17
- package/dist/assets/adapters/next/templates/init/components/shared/sort-indicator.tsx +7 -3
- package/dist/assets/adapters/next/templates/init/data/navigation.ts +4 -1
- package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor-slash-menu.ts +28 -2
- package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor.ts +22 -1
- package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-table-button.ts +85 -0
- package/dist/assets/adapters/next/templates/init/hooks/use-audit-log.ts +23 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/cleanup-audit-logs.ts +63 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/get-audit-logs.ts +122 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/index.ts +19 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/record-audit-event.ts +37 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/register.ts +13 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/request-metadata.ts +37 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +77 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +3 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/media/create-media.ts +19 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media-bulk.ts +23 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media.ts +21 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/media/update-media.ts +23 -1
- package/dist/assets/adapters/next/templates/init/lib/actions/profile/update-email.ts +17 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-files.ts +3 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-media-from-url.ts +3 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/users/create-user.ts +19 -1
- package/dist/assets/adapters/next/templates/init/lib/actions/users/delete-user.ts +23 -1
- package/dist/assets/adapters/next/templates/init/lib/actions/users/update-user-role.ts +23 -1
- package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +49 -0
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/index.ts +6 -1
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +3 -0
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/registry.ts +36 -1
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +117 -12
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +35 -1
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-drawer.tsx +104 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-row.tsx +15 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +565 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-skeleton.tsx +7 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page.tsx +14 -0
- 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/audit/audit.ts +54 -0
- package/dist/assets/adapters/next/templates/init/utils/editor/tiptap.ts +7 -32
- 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/horizontal-rule-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/index.tsx +6 -0
- package/dist/assets/shared-assets/react-admin/custom/content-editor/main-toolbar-content.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/math-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/math-editor-controls.tsx +27 -40
- package/dist/assets/shared-assets/react-admin/custom/content-editor/math-popover-button.tsx +0 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/mobile-toolbar-content.tsx +4 -4
- package/dist/assets/shared-assets/react-admin/custom/content-editor/mode-toggle-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/selection-bubble-menu.tsx +45 -8
- package/dist/assets/shared-assets/react-admin/custom/content-editor/slash-command-menu.tsx +153 -27
- package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode-dropdown-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode.tsx +9 -11
- package/dist/assets/shared-assets/react-admin/custom/content-editor/table-bubble-menu.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/table-button.tsx +24 -67
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/blockquote-button/blockquote-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/code-block-button/code-block-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-content.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +11 -2
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/source-color-highlight-popover.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu-item.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +9 -4
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-control-popover.tsx +5 -12
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-main.tsx +19 -45
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-popover.tsx +2 -3
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu-item.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +9 -4
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/mark-button/mark-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/undo-redo-button/undo-redo-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/date-range-picker.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/custom/dynamic-list-field.tsx +20 -8
- package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/nested-object-list-field.tsx +211 -0
- package/dist/assets/shared-assets/react-admin/schema.json +136 -23
- package/dist/assets/shared-assets/react-admin/ui/button.tsx +18 -18
- package/dist/assets/shared-assets/react-admin/ui/card.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 +27 -3
- package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +9 -5
- package/dist/assets/shared-assets/react-admin/ui/dropdown-menu.tsx +27 -12
- package/dist/assets/shared-assets/react-admin/ui/form.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +4 -4
- package/dist/cli.js +5396 -2385
- package/dist/cli.js.map +1 -1
- 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
|
+
}
|