betterstart-cli 0.0.28 → 0.0.30
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/integrations/mailchimp/actions/mailchimp.ts +0 -1
- package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +1 -1
- package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +1 -1
- package/dist/assets/adapters/next/templates/init/admin-globals.css +1 -1
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-nav-link.tsx +12 -2
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-settings-sidebar.tsx +75 -0
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar-nav-link.tsx +12 -2
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +2 -8
- package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +14 -5
- package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +111 -112
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/dev-mode-types.ts +4 -0
- 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-versions/entity-versions-drawer.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +45 -47
- package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/data/webhook-events.ts +5 -0
- package/dist/assets/adapters/next/templates/init/hooks/use-table-utils.ts +27 -7
- package/dist/assets/adapters/next/templates/init/hooks/use-webhooks.ts +211 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +2 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/forms/index.ts +0 -1
- package/dist/assets/adapters/next/templates/init/lib/actions/forms/types.ts +0 -4
- package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +0 -2
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/create-webhook.ts +55 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/delete-webhook.ts +26 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/dispatch.ts +135 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-deliveries.ts +124 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-secret.ts +28 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-subscriptions.ts +32 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhooks.ts +154 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/index.ts +30 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/internal-get-active-endpoints.ts +20 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/regenerate-webhook-secret.ts +35 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/register.ts +70 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/send-test-webhook.ts +81 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/types.ts +104 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/update-webhook.ts +72 -0
- package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +71 -2
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +2 -0
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +3 -1
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +6 -1
- package/dist/assets/adapters/next/templates/init/pages/auth-gate-rsc.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/media/media-page-content.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/media/media-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-columns.tsx +150 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-page-content.tsx +137 -0
- package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-table.tsx +295 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/forms/edit-form-notifications-dialog.tsx +128 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-content.tsx +111 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-skeleton.tsx +7 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page.tsx +14 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/settings-layout.tsx +10 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/delete-webhook-dialog.tsx +82 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-actions.tsx +53 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-enabled-switch.tsx +18 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-endpoint-dialog.tsx +370 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-columns.tsx +258 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-columns.tsx +200 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page-content.tsx +117 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page.tsx +14 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-table.tsx +253 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-content.tsx +161 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-skeleton.tsx +7 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page.tsx +14 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-table.tsx +264 -0
- package/dist/assets/adapters/next/templates/init/pages/users/users-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +103 -109
- package/dist/assets/adapters/next/templates/init/types/webhooks.ts +7 -0
- package/dist/assets/adapters/next/templates/init/utils/webhook/signature.ts +13 -0
- package/dist/assets/shared-assets/react-admin/ui/card.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +3 -3
- package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +1 -1
- package/dist/cli.js +947 -620
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/dist/assets/adapters/next/templates/init/lib/actions/forms/test-form-webhook.ts +0 -40
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +0 -550
- package/dist/assets/adapters/next/templates/init/utils/webhook/webhook.ts +0 -28
- /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-drawer.tsx +0 -0
- /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-row.tsx +0 -0
- /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page.tsx +0 -0
|
@@ -8,5 +8,7 @@
|
|
|
8
8
|
// console.log('Post created', { userId: user.id, entityId, data })
|
|
9
9
|
// })
|
|
10
10
|
import { registerAuditHooks } from '@admin/actions/audit/register'
|
|
11
|
+
import { registerWebhookHooks } from '@admin/actions/webhooks/register'
|
|
11
12
|
|
|
12
13
|
registerAuditHooks()
|
|
14
|
+
registerWebhookHooks()
|
|
@@ -42,6 +42,8 @@ function getAdminActionFromLifecycleEvent(
|
|
|
42
42
|
if (event === 'afterDelete') return 'delete'
|
|
43
43
|
if (event === 'afterPublish') return 'publish'
|
|
44
44
|
if (event === 'afterUnpublish') return 'unpublish'
|
|
45
|
+
if (event === 'afterRestore') return 'restore'
|
|
46
|
+
if (event === 'afterReorder') return 'reorder'
|
|
45
47
|
return null
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -76,7 +78,7 @@ function toAdminEventContext(
|
|
|
76
78
|
if (!action) return null
|
|
77
79
|
|
|
78
80
|
return {
|
|
79
|
-
category: context.schemaType === 'form' ? 'form' : 'entity',
|
|
81
|
+
category: context.auditCategory ?? (context.schemaType === 'form' ? 'form' : 'entity'),
|
|
80
82
|
action,
|
|
81
83
|
targetType: context.entityType,
|
|
82
84
|
targetId: context.entityId ?? null,
|
|
@@ -16,6 +16,10 @@ export type LifecycleHookEvent =
|
|
|
16
16
|
| 'afterPublish'
|
|
17
17
|
| 'beforeUnpublish'
|
|
18
18
|
| 'afterUnpublish'
|
|
19
|
+
| 'beforeRestore'
|
|
20
|
+
| 'afterRestore'
|
|
21
|
+
| 'beforeReorder'
|
|
22
|
+
| 'afterReorder'
|
|
19
23
|
|
|
20
24
|
export type BeforeLifecycleHookEvent = Extract<LifecycleHookEvent, `before${string}`>
|
|
21
25
|
export type AfterLifecycleHookEvent = Extract<LifecycleHookEvent, `after${string}`>
|
|
@@ -23,7 +27,8 @@ export type AfterLifecycleHookEvent = Extract<LifecycleHookEvent, `after${string
|
|
|
23
27
|
export interface LifecycleHookContext {
|
|
24
28
|
user: User | null
|
|
25
29
|
entityType: string
|
|
26
|
-
schemaType?: 'entity' | 'form' | undefined
|
|
30
|
+
schemaType?: 'entity' | 'form' | 'single' | undefined
|
|
31
|
+
auditCategory?: AuditLogCategory | undefined
|
|
27
32
|
timestamp: string
|
|
28
33
|
entityId?: string | undefined
|
|
29
34
|
input?: Record<string, unknown> | undefined
|
|
@@ -8,7 +8,7 @@ export async function AuthGateRsc({ children }: { children: React.ReactNode }) {
|
|
|
8
8
|
return (
|
|
9
9
|
<SidebarProvider className="min-h-svh">
|
|
10
10
|
<AdminSidebar />
|
|
11
|
-
<div className="relative flex min-w-0 flex-1 flex-col [--admin-shell-content-left:0px] md:[--admin-shell-content-left:var(--sidebar-width)] md:peer-data-[state=collapsed]:[--admin-shell-content-left:var(--sidebar-width-icon)]">
|
|
11
|
+
<div className="relative flex min-w-0 flex-1 flex-col [--admin-shell-content-left:0px] md:[--admin-shell-content-left:var(--sidebar-width)] md:peer-data-[state=collapsed]:[--admin-shell-content-left:var(--sidebar-width-icon)] pb-14">
|
|
12
12
|
{children}
|
|
13
13
|
</div>
|
|
14
14
|
</SidebarProvider>
|
|
@@ -237,7 +237,7 @@ export function MediaPageContent() {
|
|
|
237
237
|
selectedIds={selected}
|
|
238
238
|
isSelectMode={isSelectMode}
|
|
239
239
|
onItemClick={handleItemClick}
|
|
240
|
-
className="lg:grid-cols-
|
|
240
|
+
className="lg:grid-cols-12 pb-12 pt-px px-px"
|
|
241
241
|
/>
|
|
242
242
|
{total > DEFAULT_MEDIA_PAGE_SIZE ? (
|
|
243
243
|
<MediaGridPagination
|
package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-columns.tsx
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { AuditLogEntry, AuditLogSortField } from '@admin/actions/audit'
|
|
4
|
+
import { SortIndicator } from '@admin/components/shared/sort-indicator'
|
|
5
|
+
import { Badge } from '@admin/components/ui/badge'
|
|
6
|
+
import { Button } from '@admin/components/ui/button'
|
|
7
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
8
|
+
|
|
9
|
+
export const AUDIT_LOG_SORT_FIELDS = [
|
|
10
|
+
'createdAt',
|
|
11
|
+
'event',
|
|
12
|
+
'target',
|
|
13
|
+
'actor',
|
|
14
|
+
'sourceType'
|
|
15
|
+
] as const satisfies AuditLogSortField[]
|
|
16
|
+
|
|
17
|
+
function formatDateTime(value: string): string {
|
|
18
|
+
const date = new Date(value)
|
|
19
|
+
if (Number.isNaN(date.getTime())) return value
|
|
20
|
+
|
|
21
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
22
|
+
month: 'short',
|
|
23
|
+
day: '2-digit',
|
|
24
|
+
year: 'numeric',
|
|
25
|
+
hour: 'numeric',
|
|
26
|
+
minute: '2-digit'
|
|
27
|
+
}).format(date)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatEventLabel(auditLog: AuditLogEntry): string {
|
|
31
|
+
return `${auditLog.category}.${auditLog.action}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function SortableAuditLogHeader({
|
|
35
|
+
column,
|
|
36
|
+
label
|
|
37
|
+
}: {
|
|
38
|
+
column: {
|
|
39
|
+
getIsSorted: () => false | 'asc' | 'desc'
|
|
40
|
+
toggleSorting: (desc?: boolean) => void
|
|
41
|
+
}
|
|
42
|
+
label: string
|
|
43
|
+
}) {
|
|
44
|
+
const sortDirection = column.getIsSorted()
|
|
45
|
+
const sortLabel =
|
|
46
|
+
sortDirection === 'asc'
|
|
47
|
+
? 'sorted ascending'
|
|
48
|
+
: sortDirection === 'desc'
|
|
49
|
+
? 'sorted descending'
|
|
50
|
+
: 'not sorted'
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Button
|
|
54
|
+
variant="ghost"
|
|
55
|
+
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
|
56
|
+
className="w-full min-w-0 justify-start overflow-hidden px-0! border-none rounded-none bg-transparent! scale-100! group"
|
|
57
|
+
aria-label={`Sort by ${label}, ${sortLabel}`}
|
|
58
|
+
aria-sort={
|
|
59
|
+
sortDirection === 'asc' ? 'ascending' : sortDirection === 'desc' ? 'descending' : 'none'
|
|
60
|
+
}
|
|
61
|
+
>
|
|
62
|
+
<span className="truncate">{label}</span>
|
|
63
|
+
<SortIndicator direction={sortDirection} />
|
|
64
|
+
</Button>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createAuditLogColumns(): ColumnDef<AuditLogEntry>[] {
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
id: 'event',
|
|
72
|
+
size: 160,
|
|
73
|
+
header: ({ column }) => <SortableAuditLogHeader column={column} label="Event" />,
|
|
74
|
+
cell: ({ row }) => <Badge variant="secondary">{formatEventLabel(row.original)}</Badge>
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'target',
|
|
78
|
+
size: 240,
|
|
79
|
+
header: ({ column }) => <SortableAuditLogHeader column={column} label="Target" />,
|
|
80
|
+
cell: ({ row }) => (
|
|
81
|
+
<div className="flex min-w-0 flex-col">
|
|
82
|
+
<span className="truncate font-medium">
|
|
83
|
+
{row.original.targetLabel ?? row.original.targetId ?? 'Unknown'}
|
|
84
|
+
</span>
|
|
85
|
+
<span className="truncate text-xs text-muted-foreground">{row.original.targetType}</span>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'actor',
|
|
91
|
+
size: 220,
|
|
92
|
+
header: ({ column }) => <SortableAuditLogHeader column={column} label="Actor" />,
|
|
93
|
+
cell: ({ row }) => (
|
|
94
|
+
<div className="flex min-w-0 flex-col">
|
|
95
|
+
<span className="truncate">
|
|
96
|
+
{row.original.actorEmail ?? row.original.actorName ?? 'Unknown'}
|
|
97
|
+
</span>
|
|
98
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
99
|
+
{row.original.actorRole ?? 'No role'}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
accessorKey: 'createdAt',
|
|
106
|
+
id: 'createdAt',
|
|
107
|
+
size: 180,
|
|
108
|
+
header: ({ column }) => <SortableAuditLogHeader column={column} label="Time" />,
|
|
109
|
+
cell: ({ row }) => (
|
|
110
|
+
<span className="whitespace-nowrap text-muted-foreground">
|
|
111
|
+
{formatDateTime(row.original.createdAt)}
|
|
112
|
+
</span>
|
|
113
|
+
)
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
accessorKey: 'sourceType',
|
|
117
|
+
id: 'sourceType',
|
|
118
|
+
size: 180,
|
|
119
|
+
header: ({ column }) => <SortableAuditLogHeader column={column} label="Source" />,
|
|
120
|
+
cell: ({ row }) => (
|
|
121
|
+
<div className="text-muted-foreground">
|
|
122
|
+
<div className="truncate">{row.original.sourceType}</div>
|
|
123
|
+
<div className="truncate text-xs">{row.original.ipAddress ?? 'No IP'}</div>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
accessorKey: 'changedFields',
|
|
129
|
+
id: 'changedFields',
|
|
130
|
+
size: 180,
|
|
131
|
+
enableSorting: false,
|
|
132
|
+
header: 'Fields',
|
|
133
|
+
cell: ({ row }) =>
|
|
134
|
+
row.original.changedFields.length > 0 ? (
|
|
135
|
+
<div className="flex flex-wrap gap-1">
|
|
136
|
+
{row.original.changedFields.slice(0, 3).map((field) => (
|
|
137
|
+
<Badge key={field} variant="outline">
|
|
138
|
+
{field}
|
|
139
|
+
</Badge>
|
|
140
|
+
))}
|
|
141
|
+
{row.original.changedFields.length > 3 ? (
|
|
142
|
+
<Badge variant="outline">+{row.original.changedFields.length - 3}</Badge>
|
|
143
|
+
) : null}
|
|
144
|
+
</div>
|
|
145
|
+
) : (
|
|
146
|
+
<span className="text-muted-foreground">None</span>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
}
|
package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-page-content.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { AuditLogEntry } from '@admin/actions/audit'
|
|
4
|
+
import { AUDIT_LOG_ACTIONS, AUDIT_LOG_CATEGORIES } from '@admin/actions/audit'
|
|
5
|
+
import { EntityFiltersBar } from '@admin/components/shared/entity-filters-bar'
|
|
6
|
+
import { PageHeader } from '@admin/components/shared/page-header'
|
|
7
|
+
import { useIsFetching } from '@tanstack/react-query'
|
|
8
|
+
import { parseAsInteger, parseAsString, useQueryState, useQueryStates } from 'nuqs'
|
|
9
|
+
import * as React from 'react'
|
|
10
|
+
import { createAuditLogColumns } from './audit-log-columns'
|
|
11
|
+
import { AuditLogDetailDrawer } from './audit-log-detail-drawer'
|
|
12
|
+
import { AuditLogTable } from './audit-log-table'
|
|
13
|
+
|
|
14
|
+
const filterParsers = {
|
|
15
|
+
category: parseAsString.withDefault(''),
|
|
16
|
+
action: parseAsString.withDefault(''),
|
|
17
|
+
createdAtFrom: parseAsString.withDefault(''),
|
|
18
|
+
createdAtTo: parseAsString.withDefault('')
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
function formatFilterLabel(value: string): string {
|
|
22
|
+
return value
|
|
23
|
+
.split('_')
|
|
24
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
25
|
+
.join(' ')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const categorySelectOptions = AUDIT_LOG_CATEGORIES.map((value) => ({
|
|
29
|
+
label: formatFilterLabel(value),
|
|
30
|
+
value
|
|
31
|
+
}))
|
|
32
|
+
const actionSelectOptions = AUDIT_LOG_ACTIONS.map((value) => ({
|
|
33
|
+
label: formatFilterLabel(value),
|
|
34
|
+
value
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
export function AuditLogPageContent() {
|
|
38
|
+
const isSearchFetching = useIsFetching({ queryKey: ['audit-log'] }) > 0
|
|
39
|
+
const [search, setSearch] = useQueryState('q', parseAsString.withDefault(''))
|
|
40
|
+
const [, setPageIndex] = useQueryState('page', parseAsInteger.withDefault(0))
|
|
41
|
+
const [filterValues, setFilters] = useQueryStates(filterParsers)
|
|
42
|
+
const category = filterValues.category
|
|
43
|
+
const action = filterValues.action
|
|
44
|
+
const createdAtFrom = filterValues.createdAtFrom
|
|
45
|
+
const createdAtTo = filterValues.createdAtTo
|
|
46
|
+
|
|
47
|
+
const [selectedAuditLog, setSelectedAuditLog] = React.useState<AuditLogEntry | null>(null)
|
|
48
|
+
const columns = React.useMemo(() => createAuditLogColumns(), [])
|
|
49
|
+
|
|
50
|
+
const activeFilterCount = [search, category, action, createdAtFrom || createdAtTo].filter(
|
|
51
|
+
Boolean
|
|
52
|
+
).length
|
|
53
|
+
|
|
54
|
+
function handleSearch(value: string | null) {
|
|
55
|
+
React.startTransition(() => {
|
|
56
|
+
void setPageIndex(0)
|
|
57
|
+
void setSearch(value)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleClearAll() {
|
|
62
|
+
React.startTransition(() => {
|
|
63
|
+
void setPageIndex(0)
|
|
64
|
+
void setSearch(null)
|
|
65
|
+
void setFilters({
|
|
66
|
+
category: null,
|
|
67
|
+
action: null,
|
|
68
|
+
createdAtFrom: null,
|
|
69
|
+
createdAtTo: null
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const filters = [
|
|
75
|
+
{
|
|
76
|
+
key: 'category',
|
|
77
|
+
label: 'Category',
|
|
78
|
+
type: 'select' as const,
|
|
79
|
+
value: category,
|
|
80
|
+
options: categorySelectOptions
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
key: 'action',
|
|
84
|
+
label: 'Action',
|
|
85
|
+
type: 'select' as const,
|
|
86
|
+
value: action,
|
|
87
|
+
options: actionSelectOptions
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
key: 'createdAt',
|
|
91
|
+
label: 'Created',
|
|
92
|
+
type: 'date-range' as const,
|
|
93
|
+
from: createdAtFrom,
|
|
94
|
+
to: createdAtTo
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<React.Fragment>
|
|
100
|
+
<PageHeader title="Audit Log" />
|
|
101
|
+
|
|
102
|
+
<EntityFiltersBar
|
|
103
|
+
filters={filters}
|
|
104
|
+
activeFilterCount={activeFilterCount}
|
|
105
|
+
onFilterChange={(updates) => {
|
|
106
|
+
React.startTransition(() => {
|
|
107
|
+
void setPageIndex(0)
|
|
108
|
+
void setFilters(updates)
|
|
109
|
+
})
|
|
110
|
+
}}
|
|
111
|
+
onClearAll={handleClearAll}
|
|
112
|
+
search={search}
|
|
113
|
+
searchPlaceholder="Search audit logs..."
|
|
114
|
+
isPending={isSearchFetching}
|
|
115
|
+
onSearch={handleSearch}
|
|
116
|
+
/>
|
|
117
|
+
<main className="space-y-4 px-4 pb-4 flex-1">
|
|
118
|
+
<AuditLogTable
|
|
119
|
+
columns={columns}
|
|
120
|
+
search={search}
|
|
121
|
+
category={category}
|
|
122
|
+
action={action}
|
|
123
|
+
createdAtFrom={createdAtFrom}
|
|
124
|
+
createdAtTo={createdAtTo}
|
|
125
|
+
onRowClick={setSelectedAuditLog}
|
|
126
|
+
/>
|
|
127
|
+
</main>
|
|
128
|
+
<AuditLogDetailDrawer
|
|
129
|
+
auditLog={selectedAuditLog}
|
|
130
|
+
open={!!selectedAuditLog}
|
|
131
|
+
onOpenChange={(open) => {
|
|
132
|
+
if (!open) setSelectedAuditLog(null)
|
|
133
|
+
}}
|
|
134
|
+
/>
|
|
135
|
+
</React.Fragment>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { AuditLogEntry, AuditLogQuery, AuditLogSortField } from '@admin/actions/audit'
|
|
4
|
+
import {
|
|
5
|
+
DataGrid,
|
|
6
|
+
DataGridBody,
|
|
7
|
+
DataGridCell,
|
|
8
|
+
DataGridHead,
|
|
9
|
+
DataGridHeader,
|
|
10
|
+
DataGridRow
|
|
11
|
+
} from '@admin/components/shared/data-table/data-grid'
|
|
12
|
+
import { DataTablePagination } from '@admin/components/shared/data-table/data-table-pagination'
|
|
13
|
+
import { useAuditLogs } from '@admin/hooks/use-audit-log'
|
|
14
|
+
import { useTableUtils } from '@admin/hooks/use-table-utils'
|
|
15
|
+
import { cn } from '@admin/utils/shared/cn'
|
|
16
|
+
import { type ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
|
17
|
+
import { GripVertical } from 'lucide-react'
|
|
18
|
+
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'
|
|
19
|
+
import * as React from 'react'
|
|
20
|
+
import { AUDIT_LOG_SORT_FIELDS } from './audit-log-columns'
|
|
21
|
+
|
|
22
|
+
interface AuditLogTableProps<TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
|
23
|
+
columns: ColumnDef<AuditLogEntry, TValue>[]
|
|
24
|
+
search?: string
|
|
25
|
+
category?: string
|
|
26
|
+
action?: string
|
|
27
|
+
createdAtFrom?: string
|
|
28
|
+
createdAtTo?: string
|
|
29
|
+
onRowClick: (auditLog: AuditLogEntry) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type AuditLogSortDirection = NonNullable<AuditLogQuery['dir']>
|
|
33
|
+
|
|
34
|
+
function isAuditLogSortField(value: string): value is AuditLogSortField {
|
|
35
|
+
return (AUDIT_LOG_SORT_FIELDS as readonly string[]).includes(value)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isAuditLogSortDirection(value: string): value is AuditLogSortDirection {
|
|
39
|
+
return value === 'asc' || value === 'desc'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function AuditLogTable<TValue>({
|
|
43
|
+
columns,
|
|
44
|
+
search,
|
|
45
|
+
category,
|
|
46
|
+
action,
|
|
47
|
+
createdAtFrom,
|
|
48
|
+
createdAtTo,
|
|
49
|
+
onRowClick,
|
|
50
|
+
className
|
|
51
|
+
}: AuditLogTableProps<TValue>) {
|
|
52
|
+
const defaultPageSize = 20
|
|
53
|
+
const [{ page: pageIndex, size: pageSize, sort: sortField, dir: sortDir }, setTableQuery] =
|
|
54
|
+
useQueryStates({
|
|
55
|
+
page: parseAsInteger.withDefault(0),
|
|
56
|
+
size: parseAsInteger.withDefault(defaultPageSize),
|
|
57
|
+
sort: parseAsString.withDefault(''),
|
|
58
|
+
dir: parseAsString.withDefault('')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const safePageIndex = Number.isFinite(pageIndex) && pageIndex > 0 ? pageIndex : 0
|
|
62
|
+
const safePageSize =
|
|
63
|
+
pageSize === -1 || (Number.isFinite(pageSize) && pageSize > 0) ? pageSize : defaultPageSize
|
|
64
|
+
const safeSortField = isAuditLogSortField(sortField) ? sortField : undefined
|
|
65
|
+
const safeSortDir = safeSortField
|
|
66
|
+
? isAuditLogSortDirection(sortDir)
|
|
67
|
+
? sortDir
|
|
68
|
+
: 'asc'
|
|
69
|
+
: undefined
|
|
70
|
+
const query = React.useMemo<AuditLogQuery>(
|
|
71
|
+
() => ({
|
|
72
|
+
search: search || undefined,
|
|
73
|
+
category: category ? (category as AuditLogQuery['category']) : undefined,
|
|
74
|
+
action: action ? (action as AuditLogQuery['action']) : undefined,
|
|
75
|
+
createdAtFrom: createdAtFrom || undefined,
|
|
76
|
+
createdAtTo: createdAtTo || undefined,
|
|
77
|
+
page: safePageIndex,
|
|
78
|
+
pageSize: safePageSize,
|
|
79
|
+
sort: safeSortField,
|
|
80
|
+
dir: safeSortDir
|
|
81
|
+
}),
|
|
82
|
+
[
|
|
83
|
+
action,
|
|
84
|
+
category,
|
|
85
|
+
createdAtFrom,
|
|
86
|
+
createdAtTo,
|
|
87
|
+
safePageIndex,
|
|
88
|
+
safePageSize,
|
|
89
|
+
safeSortDir,
|
|
90
|
+
safeSortField,
|
|
91
|
+
search
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
const { data, error, isPending } = useAuditLogs(query)
|
|
95
|
+
const auditLogs = data?.auditLogs ?? []
|
|
96
|
+
const totalRows = data?.total ?? 0
|
|
97
|
+
const effectivePageSize = safePageSize === -1 ? totalRows : safePageSize
|
|
98
|
+
const pageCount = effectivePageSize > 0 ? Math.ceil(totalRows / effectivePageSize) : 1
|
|
99
|
+
const shouldShowPagination = totalRows > defaultPageSize
|
|
100
|
+
|
|
101
|
+
const {
|
|
102
|
+
columnSizing,
|
|
103
|
+
handleColumnSizingChange,
|
|
104
|
+
handlePaginationChange,
|
|
105
|
+
handleSortingChange,
|
|
106
|
+
hoveredColumnId,
|
|
107
|
+
registerTable,
|
|
108
|
+
resetColumnSizingToContainer,
|
|
109
|
+
setHoveredColumnId,
|
|
110
|
+
sorting,
|
|
111
|
+
tableContainerRef
|
|
112
|
+
} = useTableUtils<AuditLogEntry>({
|
|
113
|
+
data: auditLogs,
|
|
114
|
+
pageIndex: safePageIndex,
|
|
115
|
+
pageSize: effectivePageSize,
|
|
116
|
+
setPageIndex: (nextPageIndex) => setTableQuery({ page: nextPageIndex }),
|
|
117
|
+
setSortingParams: ({
|
|
118
|
+
pageIndex: nextPageIndex,
|
|
119
|
+
sortDir: nextSortDir,
|
|
120
|
+
sortField: nextSortField
|
|
121
|
+
}) =>
|
|
122
|
+
setTableQuery({
|
|
123
|
+
page: nextPageIndex,
|
|
124
|
+
sort: nextSortField,
|
|
125
|
+
dir: nextSortDir
|
|
126
|
+
}),
|
|
127
|
+
sortField: safeSortField,
|
|
128
|
+
sortDir: safeSortDir
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const table = useReactTable({
|
|
132
|
+
data: auditLogs,
|
|
133
|
+
columns,
|
|
134
|
+
columnResizeMode: 'onChange',
|
|
135
|
+
defaultColumn: {
|
|
136
|
+
minSize: 48,
|
|
137
|
+
size: 220
|
|
138
|
+
},
|
|
139
|
+
getCoreRowModel: getCoreRowModel(),
|
|
140
|
+
manualPagination: true,
|
|
141
|
+
manualSorting: true,
|
|
142
|
+
pageCount,
|
|
143
|
+
rowCount: totalRows,
|
|
144
|
+
onColumnSizingChange: handleColumnSizingChange,
|
|
145
|
+
onPaginationChange: handlePaginationChange,
|
|
146
|
+
onSortingChange: handleSortingChange,
|
|
147
|
+
state: {
|
|
148
|
+
sorting,
|
|
149
|
+
columnSizing,
|
|
150
|
+
pagination: {
|
|
151
|
+
pageIndex: safePageIndex,
|
|
152
|
+
pageSize: effectivePageSize
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
registerTable(table)
|
|
157
|
+
const gridTemplateColumns = table
|
|
158
|
+
.getVisibleLeafColumns()
|
|
159
|
+
.map((column) => `${column.getSize()}px`)
|
|
160
|
+
.join(' ')
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className={cn('flex flex-col gap-6 pt-px pb-2 h-full', className)}>
|
|
164
|
+
<DataGrid
|
|
165
|
+
containerClassName="min-h-0 flex-1"
|
|
166
|
+
containerRef={tableContainerRef}
|
|
167
|
+
gridTemplateColumns={gridTemplateColumns}
|
|
168
|
+
style={{ width: table.getTotalSize() }}
|
|
169
|
+
>
|
|
170
|
+
<DataGridHeader>
|
|
171
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
172
|
+
<DataGridRow key={headerGroup.id}>
|
|
173
|
+
{headerGroup.headers.map((header, headerIndex) => {
|
|
174
|
+
const resizeLabel =
|
|
175
|
+
typeof header.column.columnDef.header === 'string'
|
|
176
|
+
? header.column.columnDef.header
|
|
177
|
+
: header.column.id
|
|
178
|
+
const nextHeader = headerGroup.headers[headerIndex + 1]
|
|
179
|
+
const canResizeColumn =
|
|
180
|
+
header.column.getCanResize() && nextHeader?.column.getCanResize() === true
|
|
181
|
+
return (
|
|
182
|
+
<DataGridHead
|
|
183
|
+
key={header.id}
|
|
184
|
+
className="group/resize-header relative overflow-hidden"
|
|
185
|
+
data-column-id={header.column.id}
|
|
186
|
+
onMouseEnter={() => setHoveredColumnId(header.column.id)}
|
|
187
|
+
onMouseLeave={() =>
|
|
188
|
+
setHoveredColumnId((current) =>
|
|
189
|
+
current === header.column.id ? null : current
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
>
|
|
193
|
+
{header.isPlaceholder
|
|
194
|
+
? null
|
|
195
|
+
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
196
|
+
{canResizeColumn ? (
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
aria-label={`Resize ${resizeLabel} column`}
|
|
200
|
+
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"
|
|
201
|
+
data-resize-handle
|
|
202
|
+
data-column-hovered={
|
|
203
|
+
hoveredColumnId === header.column.id ? 'true' : 'false'
|
|
204
|
+
}
|
|
205
|
+
data-resizing={header.column.getIsResizing() ? 'true' : 'false'}
|
|
206
|
+
onDoubleClick={resetColumnSizingToContainer}
|
|
207
|
+
onMouseDown={header.getResizeHandler()}
|
|
208
|
+
onTouchStart={header.getResizeHandler()}
|
|
209
|
+
>
|
|
210
|
+
<GripVertical
|
|
211
|
+
aria-hidden="true"
|
|
212
|
+
className={cn('size-3.5', {
|
|
213
|
+
'text-ring': header.column.getIsResizing()
|
|
214
|
+
})}
|
|
215
|
+
/>
|
|
216
|
+
</button>
|
|
217
|
+
) : null}
|
|
218
|
+
</DataGridHead>
|
|
219
|
+
)
|
|
220
|
+
})}
|
|
221
|
+
</DataGridRow>
|
|
222
|
+
))}
|
|
223
|
+
</DataGridHeader>
|
|
224
|
+
<DataGridBody>
|
|
225
|
+
{isPending ? (
|
|
226
|
+
<DataGridRow>
|
|
227
|
+
<DataGridCell
|
|
228
|
+
className="flex h-24 items-center justify-center text-center"
|
|
229
|
+
style={{ gridColumn: '1 / -1' }}
|
|
230
|
+
>
|
|
231
|
+
<div className="text-muted-foreground">Loading Audit Logs…</div>
|
|
232
|
+
</DataGridCell>
|
|
233
|
+
</DataGridRow>
|
|
234
|
+
) : error ? (
|
|
235
|
+
<DataGridRow>
|
|
236
|
+
<DataGridCell
|
|
237
|
+
className="flex h-24 items-center justify-center text-center"
|
|
238
|
+
style={{ gridColumn: '1 / -1' }}
|
|
239
|
+
>
|
|
240
|
+
<div className="text-destructive">Error loading Audit Logs: {error.message}</div>
|
|
241
|
+
</DataGridCell>
|
|
242
|
+
</DataGridRow>
|
|
243
|
+
) : table.getRowModel().rows.length ? (
|
|
244
|
+
table.getRowModel().rows.map((row) => (
|
|
245
|
+
<DataGridRow
|
|
246
|
+
key={row.id}
|
|
247
|
+
className="cursor-pointer hover:bg-muted/50"
|
|
248
|
+
onClick={(event) => {
|
|
249
|
+
const target = event.target as HTMLElement
|
|
250
|
+
if (!event.currentTarget.contains(target)) return
|
|
251
|
+
if (target.closest('button, a, input, [role="checkbox"]')) return
|
|
252
|
+
onRowClick(row.original)
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
{row.getVisibleCells().map((cell) => (
|
|
256
|
+
<DataGridCell
|
|
257
|
+
key={cell.id}
|
|
258
|
+
data-column-id={cell.column.id}
|
|
259
|
+
onMouseEnter={() => setHoveredColumnId(cell.column.id)}
|
|
260
|
+
onMouseLeave={() =>
|
|
261
|
+
setHoveredColumnId((current) => (current === cell.column.id ? null : current))
|
|
262
|
+
}
|
|
263
|
+
>
|
|
264
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
265
|
+
</DataGridCell>
|
|
266
|
+
))}
|
|
267
|
+
</DataGridRow>
|
|
268
|
+
))
|
|
269
|
+
) : (
|
|
270
|
+
<DataGridRow>
|
|
271
|
+
<DataGridCell
|
|
272
|
+
className="flex h-24 items-center justify-center text-center"
|
|
273
|
+
style={{ gridColumn: '1 / -1' }}
|
|
274
|
+
>
|
|
275
|
+
No Audit Logs found.
|
|
276
|
+
</DataGridCell>
|
|
277
|
+
</DataGridRow>
|
|
278
|
+
)}
|
|
279
|
+
</DataGridBody>
|
|
280
|
+
</DataGrid>
|
|
281
|
+
|
|
282
|
+
{shouldShowPagination ? (
|
|
283
|
+
<DataTablePagination
|
|
284
|
+
table={table}
|
|
285
|
+
paginationMode="controlled"
|
|
286
|
+
pageSize={safePageSize}
|
|
287
|
+
pageIndex={safePageIndex}
|
|
288
|
+
pageCount={pageCount}
|
|
289
|
+
setPageIndex={(nextPageIndex) => setTableQuery({ page: nextPageIndex })}
|
|
290
|
+
setPageSize={(nextPageSize) => setTableQuery({ page: 0, size: nextPageSize })}
|
|
291
|
+
/>
|
|
292
|
+
) : null}
|
|
293
|
+
</div>
|
|
294
|
+
)
|
|
295
|
+
}
|