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,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
+ }
@@ -0,0 +1,128 @@
1
+ 'use client'
2
+
3
+ import { upsertFormSettings } from '@admin/actions/forms'
4
+ import { Button } from '@admin/components/ui/button'
5
+ import {
6
+ Dialog,
7
+ DialogBody,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle
13
+ } from '@admin/components/ui/dialog'
14
+ import {
15
+ Form,
16
+ FormControl,
17
+ FormDescription,
18
+ FormField,
19
+ FormItem,
20
+ FormLabel,
21
+ FormMessage
22
+ } from '@admin/components/ui/form'
23
+ import { Textarea } from '@admin/components/ui/textarea'
24
+ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
25
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
26
+ import { LoaderCircle } from 'lucide-react'
27
+ import { useForm } from 'react-hook-form'
28
+ import { toast } from 'sonner'
29
+ import { z } from 'zod/v3'
30
+
31
+ const notificationsSchema = z.object({
32
+ notificationEmails: z.string().trim()
33
+ })
34
+
35
+ type NotificationsFormValues = z.infer<typeof notificationsSchema>
36
+
37
+ interface EditFormNotificationsDialogProps {
38
+ formLabel: string
39
+ settingsKey: string
40
+ initialEmails: string
41
+ open: boolean
42
+ onOpenChange: (open: boolean) => void
43
+ }
44
+
45
+ export function EditFormNotificationsDialog({
46
+ formLabel,
47
+ settingsKey,
48
+ initialEmails,
49
+ open,
50
+ onOpenChange
51
+ }: EditFormNotificationsDialogProps) {
52
+ const queryClient = useQueryClient()
53
+
54
+ const form = useForm<NotificationsFormValues>({
55
+ resolver: standardSchemaResolver(notificationsSchema),
56
+ defaultValues: { notificationEmails: initialEmails }
57
+ })
58
+
59
+ const mutation = useMutation({
60
+ mutationFn: async (values: NotificationsFormValues) => {
61
+ const result = await upsertFormSettings(settingsKey, {
62
+ notificationEmails: values.notificationEmails || null
63
+ })
64
+ if (!result.success) {
65
+ throw new Error(result.error || 'Failed to save notification emails')
66
+ }
67
+
68
+ return result
69
+ },
70
+ onSuccess: async () => {
71
+ toast.success('Notification emails saved')
72
+ await queryClient.invalidateQueries({ queryKey: ['form-settings'] })
73
+ onOpenChange(false)
74
+ },
75
+ onError: (error: Error) => {
76
+ toast.error(error.message || 'An unexpected error occurred')
77
+ }
78
+ })
79
+
80
+ const isPending = mutation.isPending
81
+
82
+ return (
83
+ <Dialog open={open} onOpenChange={onOpenChange}>
84
+ <DialogContent className="sm:max-w-135">
85
+ <Form {...form}>
86
+ <form onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
87
+ <DialogHeader>
88
+ <DialogTitle>{formLabel} notifications</DialogTitle>
89
+ <DialogDescription>
90
+ Send an email to these addresses when a submission arrives
91
+ </DialogDescription>
92
+ </DialogHeader>
93
+
94
+ <DialogBody>
95
+ <FormField
96
+ control={form.control}
97
+ name="notificationEmails"
98
+ render={({ field }) => (
99
+ <FormItem>
100
+ <FormLabel>Notification emails</FormLabel>
101
+ <FormControl>
102
+ <Textarea
103
+ placeholder="sales@example.com, team@example.com"
104
+ disabled={isPending}
105
+ {...field}
106
+ />
107
+ </FormControl>
108
+ <FormDescription>
109
+ Comma-separated email addresses. Leave empty to disable notifications.
110
+ </FormDescription>
111
+ <FormMessage />
112
+ </FormItem>
113
+ )}
114
+ />
115
+ </DialogBody>
116
+
117
+ <DialogFooter showCloseButton={true}>
118
+ <Button type="submit" disabled={isPending || !form.formState.isDirty} size="lg">
119
+ {isPending && <LoaderCircle className="animate-spin" />}
120
+ Save
121
+ </Button>
122
+ </DialogFooter>
123
+ </form>
124
+ </Form>
125
+ </DialogContent>
126
+ </Dialog>
127
+ )
128
+ }
@@ -0,0 +1,111 @@
1
+ 'use client'
2
+
3
+ import { PageHeader } from '@admin/components/shared/page-header'
4
+ import { Button } from '@admin/components/ui/button'
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle
11
+ } from '@admin/components/ui/card'
12
+ import {
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableHead,
17
+ TableHeader,
18
+ TableRow
19
+ } from '@admin/components/ui/table'
20
+ import { webhookEventSources } from '@admin/data/webhook-events'
21
+ import { useFormSettingsList } from '@admin/hooks/use-webhooks'
22
+ import * as React from 'react'
23
+ import { EditFormNotificationsDialog } from './edit-form-notifications-dialog'
24
+
25
+ export function FormsSettingsPageContent() {
26
+ const formSources = webhookEventSources.filter((source) => source.kind === 'form')
27
+ const { data: settings, isPending } = useFormSettingsList()
28
+ const [editingKey, setEditingKey] = React.useState<string | null>(null)
29
+
30
+ const editingSource = formSources.find((source) => source.settingsKey === editingKey)
31
+
32
+ return (
33
+ <React.Fragment>
34
+ <PageHeader title="Form Settings" />
35
+ <main className="mx-auto max-w-5xl w-full space-y-4 p-4">
36
+ <Card>
37
+ <CardHeader>
38
+ <CardTitle>Form notifications</CardTitle>
39
+ <CardDescription>
40
+ Emails notified when a submission arrives. Webhooks are managed on the Webhooks tab.
41
+ </CardDescription>
42
+ </CardHeader>
43
+ <CardContent>
44
+ {formSources.length === 0 ? (
45
+ <p className="text-sm text-muted-foreground">
46
+ No forms yet. Create a form schema to configure its notifications here.
47
+ </p>
48
+ ) : (
49
+ <Table>
50
+ <TableHeader>
51
+ <TableRow>
52
+ <TableHead>Form</TableHead>
53
+ <TableHead>Notification emails</TableHead>
54
+ <TableHead className="w-20" />
55
+ </TableRow>
56
+ </TableHeader>
57
+ <TableBody>
58
+ {formSources.map((source) => {
59
+ const formSettings = settings?.find(
60
+ (entry) => entry.formName === source.settingsKey
61
+ )
62
+ return (
63
+ <TableRow key={source.schema}>
64
+ <TableCell className="font-medium">{source.label}</TableCell>
65
+ <TableCell>
66
+ {isPending ? (
67
+ <span className="text-muted-foreground">Loading…</span>
68
+ ) : formSettings?.notificationEmails ? (
69
+ <span className="font-mono text-xs">
70
+ {formSettings.notificationEmails}
71
+ </span>
72
+ ) : (
73
+ <span className="text-muted-foreground">No emails configured</span>
74
+ )}
75
+ </TableCell>
76
+ <TableCell className="text-right">
77
+ <Button
78
+ variant="outline"
79
+ size="sm"
80
+ onClick={() => setEditingKey(source.settingsKey ?? source.schema)}
81
+ >
82
+ Edit
83
+ </Button>
84
+ </TableCell>
85
+ </TableRow>
86
+ )
87
+ })}
88
+ </TableBody>
89
+ </Table>
90
+ )}
91
+ </CardContent>
92
+ </Card>
93
+ </main>
94
+ {editingSource?.settingsKey && (
95
+ <EditFormNotificationsDialog
96
+ key={editingSource.settingsKey}
97
+ formLabel={editingSource.label}
98
+ settingsKey={editingSource.settingsKey}
99
+ initialEmails={
100
+ settings?.find((entry) => entry.formName === editingSource.settingsKey)
101
+ ?.notificationEmails ?? ''
102
+ }
103
+ open={editingKey !== null}
104
+ onOpenChange={(nextOpen) => {
105
+ if (!nextOpen) setEditingKey(null)
106
+ }}
107
+ />
108
+ )}
109
+ </React.Fragment>
110
+ )
111
+ }
@@ -0,0 +1,7 @@
1
+ export function FormsSettingsPageSkeleton() {
2
+ return (
3
+ <div className="flex items-center justify-center h-48">
4
+ <div className="text-muted-foreground">Loading Form Settings…</div>
5
+ </div>
6
+ )
7
+ }
@@ -0,0 +1,14 @@
1
+ import { requireRole, UserRole } from '@admin/auth/middleware'
2
+ import * as React from 'react'
3
+ import { FormsSettingsPageContent } from './forms-settings-page-content'
4
+ import { FormsSettingsPageSkeleton } from './forms-settings-page-skeleton'
5
+
6
+ export default async function FormsSettingsPage() {
7
+ await requireRole([UserRole.ADMIN, UserRole.EDITOR])
8
+
9
+ return (
10
+ <React.Suspense fallback={<FormsSettingsPageSkeleton />}>
11
+ <FormsSettingsPageContent />
12
+ </React.Suspense>
13
+ )
14
+ }
@@ -0,0 +1,10 @@
1
+ import { AdminSettingsSidebar } from '@admin/components/layouts/admin-settings-sidebar'
2
+
3
+ export default function SettingsLayout({ children }: { children: React.ReactNode }) {
4
+ return (
5
+ <div className="fixed inset-y-0 right-0 left-(--admin-shell-content-left) flex min-w-0 transition-[left] duration-200 ease-linear">
6
+ <AdminSettingsSidebar />
7
+ <div className="min-w-0 flex-1 overflow-auto">{children}</div>
8
+ </div>
9
+ )
10
+ }
@@ -0,0 +1,82 @@
1
+ 'use client'
2
+
3
+ import type { WebhookEndpointListItem } from '@admin/actions/webhooks'
4
+ import { deleteWebhook } from '@admin/actions/webhooks'
5
+ import { Button } from '@admin/components/ui/button'
6
+ import {
7
+ Dialog,
8
+ DialogBody,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle
14
+ } from '@admin/components/ui/dialog'
15
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
16
+ import { LoaderCircle } from 'lucide-react'
17
+ import { toast } from 'sonner'
18
+
19
+ interface DeleteWebhookDialogProps {
20
+ webhook: WebhookEndpointListItem | null
21
+ open: boolean
22
+ onOpenChange: (open: boolean) => void
23
+ }
24
+
25
+ export function DeleteWebhookDialog({ webhook, open, onOpenChange }: DeleteWebhookDialogProps) {
26
+ const queryClient = useQueryClient()
27
+
28
+ const mutation = useMutation({
29
+ mutationFn: async () => {
30
+ if (!webhook) throw new Error('No endpoint selected')
31
+ const result = await deleteWebhook(webhook.id)
32
+ if (!result.success) {
33
+ throw new Error(result.error || 'Failed to delete endpoint')
34
+ }
35
+
36
+ return result
37
+ },
38
+ onSuccess: async () => {
39
+ toast.success('Endpoint deleted')
40
+ await queryClient.invalidateQueries({ queryKey: ['webhooks'] })
41
+ await queryClient.invalidateQueries({
42
+ queryKey: ['webhook-subscriptions']
43
+ })
44
+ await queryClient.invalidateQueries({ queryKey: ['webhook-deliveries'] })
45
+ onOpenChange(false)
46
+ },
47
+ onError: (error: Error) => {
48
+ toast.error(error.message || 'An unexpected error occurred')
49
+ }
50
+ })
51
+
52
+ const isPending = mutation.isPending
53
+
54
+ return (
55
+ <Dialog open={open} onOpenChange={onOpenChange}>
56
+ <DialogContent className="sm:max-w-105">
57
+ <DialogHeader>
58
+ <DialogTitle>Delete endpoint</DialogTitle>
59
+ <DialogDescription>
60
+ {webhook
61
+ ? `"${webhook.name}" will stop receiving events. Its subscriptions and delivery history are removed too.`
62
+ : 'This endpoint will stop receiving events.'}
63
+ </DialogDescription>
64
+ </DialogHeader>
65
+ <DialogBody>
66
+ <p className="text-sm text-muted-foreground">This action cannot be undone.</p>
67
+ </DialogBody>
68
+ <DialogFooter showCloseButton={true}>
69
+ <Button
70
+ variant="destructive"
71
+ size="lg"
72
+ disabled={isPending}
73
+ onClick={() => mutation.mutate()}
74
+ >
75
+ {isPending && <LoaderCircle className="animate-spin" />}
76
+ Delete
77
+ </Button>
78
+ </DialogFooter>
79
+ </DialogContent>
80
+ </Dialog>
81
+ )
82
+ }