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.
Files changed (82) hide show
  1. package/dist/assets/adapters/next/integrations/mailchimp/actions/mailchimp.ts +0 -1
  2. package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +1 -1
  3. package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +1 -1
  4. package/dist/assets/adapters/next/templates/init/admin-globals.css +1 -1
  5. package/dist/assets/adapters/next/templates/init/components/layouts/admin-nav-link.tsx +12 -2
  6. package/dist/assets/adapters/next/templates/init/components/layouts/admin-settings-sidebar.tsx +75 -0
  7. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar-nav-link.tsx +12 -2
  8. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +2 -8
  9. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +14 -5
  10. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +111 -112
  11. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/dev-mode-types.ts +4 -0
  12. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
  13. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
  14. package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +45 -47
  15. package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +1 -1
  16. package/dist/assets/adapters/next/templates/init/data/webhook-events.ts +5 -0
  17. package/dist/assets/adapters/next/templates/init/hooks/use-table-utils.ts +27 -7
  18. package/dist/assets/adapters/next/templates/init/hooks/use-webhooks.ts +211 -0
  19. package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +2 -0
  20. package/dist/assets/adapters/next/templates/init/lib/actions/forms/index.ts +0 -1
  21. package/dist/assets/adapters/next/templates/init/lib/actions/forms/types.ts +0 -4
  22. package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +0 -2
  23. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/create-webhook.ts +55 -0
  24. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/delete-webhook.ts +26 -0
  25. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/dispatch.ts +135 -0
  26. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-deliveries.ts +124 -0
  27. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-secret.ts +28 -0
  28. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-subscriptions.ts +32 -0
  29. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhooks.ts +154 -0
  30. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/index.ts +30 -0
  31. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/internal-get-active-endpoints.ts +20 -0
  32. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/regenerate-webhook-secret.ts +35 -0
  33. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/register.ts +70 -0
  34. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/send-test-webhook.ts +81 -0
  35. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/types.ts +104 -0
  36. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/update-webhook.ts +72 -0
  37. package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +71 -2
  38. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +2 -0
  39. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +3 -1
  40. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +6 -1
  41. package/dist/assets/adapters/next/templates/init/pages/auth-gate-rsc.tsx +1 -1
  42. package/dist/assets/adapters/next/templates/init/pages/media/media-page-content.tsx +1 -1
  43. package/dist/assets/adapters/next/templates/init/pages/media/media-page-skeleton.tsx +1 -1
  44. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-columns.tsx +150 -0
  45. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-page-content.tsx +137 -0
  46. package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page-skeleton.tsx +1 -1
  47. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-table.tsx +295 -0
  48. package/dist/assets/adapters/next/templates/init/pages/settings/forms/edit-form-notifications-dialog.tsx +128 -0
  49. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-content.tsx +111 -0
  50. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-skeleton.tsx +7 -0
  51. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page.tsx +14 -0
  52. package/dist/assets/adapters/next/templates/init/pages/settings/settings-layout.tsx +10 -0
  53. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/delete-webhook-dialog.tsx +82 -0
  54. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-actions.tsx +53 -0
  55. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-enabled-switch.tsx +18 -0
  56. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-endpoint-dialog.tsx +370 -0
  57. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-columns.tsx +258 -0
  58. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-columns.tsx +200 -0
  59. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page-content.tsx +117 -0
  60. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page.tsx +14 -0
  61. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-table.tsx +253 -0
  62. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-content.tsx +161 -0
  63. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-skeleton.tsx +7 -0
  64. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page.tsx +14 -0
  65. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-table.tsx +264 -0
  66. package/dist/assets/adapters/next/templates/init/pages/users/users-page-skeleton.tsx +1 -1
  67. package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +103 -109
  68. package/dist/assets/adapters/next/templates/init/types/webhooks.ts +7 -0
  69. package/dist/assets/adapters/next/templates/init/utils/webhook/signature.ts +13 -0
  70. package/dist/assets/shared-assets/react-admin/ui/card.tsx +1 -1
  71. package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +2 -2
  72. package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +3 -3
  73. package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +1 -1
  74. package/dist/cli.js +947 -620
  75. package/dist/cli.js.map +1 -1
  76. package/package.json +1 -1
  77. package/dist/assets/adapters/next/templates/init/lib/actions/forms/test-form-webhook.ts +0 -40
  78. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +0 -550
  79. package/dist/assets/adapters/next/templates/init/utils/webhook/webhook.ts +0 -28
  80. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-drawer.tsx +0 -0
  81. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-row.tsx +0 -0
  82. /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-10 pb-12 pt-px px-px"
240
+ className="lg:grid-cols-12 pb-12 pt-px px-px"
241
241
  />
242
242
  {total > DEFAULT_MEDIA_PAGE_SIZE ? (
243
243
  <MediaGridPagination
@@ -1,7 +1,7 @@
1
1
  export function MediaPageSkeleton() {
2
2
  return (
3
3
  <div className="flex items-center justify-center h-48">
4
- <div className="text-muted-foreground">Loading Media...</div>
4
+ <div className="text-muted-foreground">Loading Media…</div>
5
5
  </div>
6
6
  )
7
7
  }
@@ -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
+ }
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  export function AuditLogPageSkeleton() {
2
2
  return (
3
3
  <div className="flex items-center justify-center h-48">
4
- <div className="text-muted-foreground">Loading Audit Logs...</div>
4
+ <div className="text-muted-foreground">Loading Audit Logs…</div>
5
5
  </div>
6
6
  )
7
7
  }
@@ -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
+ }