betterstart-cli 0.0.27 → 0.0.29

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 (84) hide show
  1. package/dist/assets/adapters/next/integrations/mailchimp/actions/mailchimp.ts +0 -1
  2. package/dist/assets/adapters/next/integrations/vercel-blob/actions/vercel-blob.ts +39 -0
  3. package/dist/assets/adapters/next/integrations/vercel-blob/integration.ts +36 -0
  4. package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +1 -1
  5. package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +1 -1
  6. package/dist/assets/adapters/next/templates/init/admin-globals.css +1 -1
  7. package/dist/assets/adapters/next/templates/init/components/layouts/admin-nav-link.tsx +12 -2
  8. package/dist/assets/adapters/next/templates/init/components/layouts/admin-settings-sidebar.tsx +75 -0
  9. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar-nav-link.tsx +12 -2
  10. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +2 -8
  11. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +14 -5
  12. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +111 -112
  13. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/dev-mode-types.ts +4 -0
  14. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
  15. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
  16. package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +45 -47
  17. package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +1 -1
  18. package/dist/assets/adapters/next/templates/init/data/webhook-events.ts +5 -0
  19. package/dist/assets/adapters/next/templates/init/hooks/use-table-utils.ts +27 -7
  20. package/dist/assets/adapters/next/templates/init/hooks/use-webhooks.ts +211 -0
  21. package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +2 -0
  22. package/dist/assets/adapters/next/templates/init/lib/actions/forms/index.ts +0 -1
  23. package/dist/assets/adapters/next/templates/init/lib/actions/forms/types.ts +0 -4
  24. package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +0 -2
  25. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/create-webhook.ts +55 -0
  26. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/delete-webhook.ts +26 -0
  27. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/dispatch.ts +135 -0
  28. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-deliveries.ts +124 -0
  29. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-secret.ts +28 -0
  30. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-subscriptions.ts +32 -0
  31. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhooks.ts +154 -0
  32. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/index.ts +30 -0
  33. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/internal-get-active-endpoints.ts +20 -0
  34. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/regenerate-webhook-secret.ts +35 -0
  35. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/register.ts +70 -0
  36. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/send-test-webhook.ts +81 -0
  37. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/types.ts +104 -0
  38. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/update-webhook.ts +72 -0
  39. package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +71 -2
  40. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +2 -0
  41. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +3 -1
  42. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +6 -1
  43. package/dist/assets/adapters/next/templates/init/pages/auth-gate-rsc.tsx +1 -1
  44. package/dist/assets/adapters/next/templates/init/pages/media/media-page-content.tsx +1 -1
  45. package/dist/assets/adapters/next/templates/init/pages/media/media-page-skeleton.tsx +1 -1
  46. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-columns.tsx +150 -0
  47. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-page-content.tsx +137 -0
  48. package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page-skeleton.tsx +1 -1
  49. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-table.tsx +295 -0
  50. package/dist/assets/adapters/next/templates/init/pages/settings/forms/edit-form-notifications-dialog.tsx +128 -0
  51. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-content.tsx +111 -0
  52. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-skeleton.tsx +7 -0
  53. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page.tsx +14 -0
  54. package/dist/assets/adapters/next/templates/init/pages/settings/settings-layout.tsx +10 -0
  55. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/delete-webhook-dialog.tsx +82 -0
  56. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-actions.tsx +53 -0
  57. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-enabled-switch.tsx +18 -0
  58. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-endpoint-dialog.tsx +370 -0
  59. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-columns.tsx +258 -0
  60. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-columns.tsx +200 -0
  61. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page-content.tsx +117 -0
  62. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page.tsx +14 -0
  63. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-table.tsx +253 -0
  64. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-content.tsx +161 -0
  65. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-skeleton.tsx +7 -0
  66. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page.tsx +14 -0
  67. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-table.tsx +264 -0
  68. package/dist/assets/adapters/next/templates/init/pages/users/users-page-skeleton.tsx +1 -1
  69. package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +103 -109
  70. package/dist/assets/adapters/next/templates/init/types/webhooks.ts +7 -0
  71. package/dist/assets/adapters/next/templates/init/utils/webhook/signature.ts +13 -0
  72. package/dist/assets/shared-assets/react-admin/ui/card.tsx +1 -1
  73. package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +2 -2
  74. package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +3 -3
  75. package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +1 -1
  76. package/dist/cli.js +2191 -1304
  77. package/dist/cli.js.map +1 -1
  78. package/package.json +1 -1
  79. package/dist/assets/adapters/next/templates/init/lib/actions/forms/test-form-webhook.ts +0 -40
  80. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +0 -550
  81. package/dist/assets/adapters/next/templates/init/utils/webhook/webhook.ts +0 -28
  82. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-drawer.tsx +0 -0
  83. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-row.tsx +0 -0
  84. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page.tsx +0 -0
@@ -0,0 +1,72 @@
1
+ 'use server'
2
+
3
+ import { requireRole, UserRole } from '@admin/auth/middleware'
4
+ import db from '@admin/db'
5
+ import { webhookSubscriptions, webhooks } from '@admin/db/schema'
6
+ import { and, eq, notInArray } from 'drizzle-orm'
7
+ import { updateTag } from 'next/cache'
8
+ import type { UpdateWebhookInput, WebhookResult } from './types'
9
+ import { webhooksCacheTags } from './types'
10
+
11
+ export async function updateWebhook(id: string, input: UpdateWebhookInput): Promise<WebhookResult> {
12
+ await requireRole([UserRole.ADMIN])
13
+
14
+ try {
15
+ const updated = await db.transaction(async (tx) => {
16
+ const [row] = await tx
17
+ .update(webhooks)
18
+ .set({
19
+ ...(input.name !== undefined ? { name: input.name } : {}),
20
+ ...(input.url !== undefined ? { url: input.url } : {}),
21
+ ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
22
+ updatedAt: new Date().toISOString()
23
+ })
24
+ .where(eq(webhooks.id, id))
25
+ .returning()
26
+
27
+ if (!row) throw new Error('Webhook not found')
28
+
29
+ if (input.events !== undefined) {
30
+ const events = input.events
31
+ if (events.length > 0) {
32
+ await tx
33
+ .delete(webhookSubscriptions)
34
+ .where(
35
+ and(
36
+ eq(webhookSubscriptions.webhookId, id),
37
+ notInArray(webhookSubscriptions.event, events)
38
+ )
39
+ )
40
+ await tx
41
+ .insert(webhookSubscriptions)
42
+ .values(events.map((event) => ({ webhookId: id, event })))
43
+ .onConflictDoNothing()
44
+ } else {
45
+ await tx.delete(webhookSubscriptions).where(eq(webhookSubscriptions.webhookId, id))
46
+ }
47
+ }
48
+
49
+ return row
50
+ })
51
+
52
+ updateTag(webhooksCacheTags.endpoints)
53
+ updateTag(webhooksCacheTags.subscriptions)
54
+ return {
55
+ success: true,
56
+ webhook: {
57
+ id: updated.id,
58
+ name: updated.name,
59
+ url: updated.url,
60
+ enabled: updated.enabled,
61
+ createdAt: updated.createdAt,
62
+ updatedAt: updated.updatedAt
63
+ }
64
+ }
65
+ } catch (error) {
66
+ console.error('Error updating webhook:', error)
67
+ return {
68
+ success: false,
69
+ error: error instanceof Error ? error.message : 'Failed to update webhook'
70
+ }
71
+ }
72
+ }
@@ -80,8 +80,6 @@ export const formSettings = pgTable(
80
80
  {
81
81
  id: uuid().defaultRandom().primaryKey().notNull(),
82
82
  formName: varchar('formName', { length: 100 }).notNull(),
83
- webhookUrl: text('webhookUrl'),
84
- webhookEnabled: boolean('webhookEnabled').default(false).notNull(),
85
83
  notificationEmails: text('notificationEmails'),
86
84
  createdAt: timestamp('createdAt', { precision: 3, mode: 'string' })
87
85
  .default(sql`CURRENT_TIMESTAMP`)
@@ -98,6 +96,77 @@ export const formSettings = pgTable(
98
96
  ]
99
97
  )
100
98
 
99
+ // ============================================================================
100
+ // Webhooks (endpoint registry, event subscriptions, delivery log)
101
+ // ============================================================================
102
+
103
+ export const webhooks = pgTable('Webhooks', {
104
+ id: uuid().defaultRandom().primaryKey().notNull(),
105
+ name: varchar('name', { length: 200 }).notNull(),
106
+ url: text('url').notNull(),
107
+ secret: text('secret').notNull(),
108
+ enabled: boolean('enabled').default(true).notNull(),
109
+ createdAt: timestamp('createdAt', { precision: 3, mode: 'string' })
110
+ .default(sql`CURRENT_TIMESTAMP`)
111
+ .notNull(),
112
+ updatedAt: timestamp('updatedAt', { precision: 3, mode: 'string' })
113
+ .default(sql`CURRENT_TIMESTAMP`)
114
+ .notNull()
115
+ })
116
+
117
+ export const webhookSubscriptions = pgTable(
118
+ 'WebhookSubscriptions',
119
+ {
120
+ id: uuid().defaultRandom().primaryKey().notNull(),
121
+ webhookId: uuid('webhookId')
122
+ .notNull()
123
+ .references(() => webhooks.id, { onDelete: 'cascade' }),
124
+ event: varchar('event', { length: 200 }).notNull(),
125
+ createdAt: timestamp('createdAt', { precision: 3, mode: 'string' })
126
+ .default(sql`CURRENT_TIMESTAMP`)
127
+ .notNull()
128
+ },
129
+ (table) => [
130
+ uniqueIndex('WebhookSubscriptions_webhookId_event_key').using(
131
+ 'btree',
132
+ table.webhookId.asc().nullsLast().op('uuid_ops'),
133
+ table.event.asc().nullsLast().op('text_ops')
134
+ ),
135
+ index('WebhookSubscriptions_event_idx').using(
136
+ 'btree',
137
+ table.event.asc().nullsLast().op('text_ops')
138
+ )
139
+ ]
140
+ )
141
+
142
+ export const webhookDeliveries = pgTable(
143
+ 'WebhookDeliveries',
144
+ {
145
+ id: uuid().defaultRandom().primaryKey().notNull(),
146
+ webhookId: uuid('webhookId')
147
+ .notNull()
148
+ .references(() => webhooks.id, { onDelete: 'cascade' }),
149
+ event: varchar('event', { length: 200 }).notNull(),
150
+ eventId: varchar('eventId', { length: 64 }).notNull(),
151
+ attempt: integer('attempt').notNull(),
152
+ success: boolean('success').notNull(),
153
+ statusCode: integer('statusCode'),
154
+ durationMs: integer('durationMs').notNull(),
155
+ error: text('error'),
156
+ createdAt: timestamp('createdAt', { precision: 3, mode: 'string' })
157
+ .default(sql`CURRENT_TIMESTAMP`)
158
+ .notNull()
159
+ },
160
+ (table) => [
161
+ index('WebhookDeliveries_webhookId_createdAt_idx').using(
162
+ 'btree',
163
+ table.webhookId.asc().nullsLast().op('uuid_ops'),
164
+ table.createdAt.desc().nullsLast()
165
+ ),
166
+ index('WebhookDeliveries_createdAt_idx').using('btree', table.createdAt.desc().nullsLast())
167
+ ]
168
+ )
169
+
101
170
  // ============================================================================
102
171
  // Entity Versions
103
172
  // ============================================================================
@@ -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
  }