@actuate-media/cms-admin 0.79.1 → 0.80.1

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 (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +4 -0
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/actuate-admin.css +1 -1
  6. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  7. package/dist/components/Breadcrumbs.js +2 -0
  8. package/dist/components/Breadcrumbs.js.map +1 -1
  9. package/dist/components/ui/Badge.d.ts +1 -1
  10. package/dist/components/ui/StatusBadge.d.ts +1 -1
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/layout/Sidebar.d.ts.map +1 -1
  16. package/dist/layout/Sidebar.js +2 -1
  17. package/dist/layout/Sidebar.js.map +1 -1
  18. package/dist/lib/forms-service.d.ts +42 -0
  19. package/dist/lib/forms-service.d.ts.map +1 -1
  20. package/dist/lib/forms-service.js +27 -0
  21. package/dist/lib/forms-service.js.map +1 -1
  22. package/dist/router/admin-routes.d.ts.map +1 -1
  23. package/dist/router/admin-routes.js +1 -0
  24. package/dist/router/admin-routes.js.map +1 -1
  25. package/dist/views/FormDeliveries.d.ts +5 -0
  26. package/dist/views/FormDeliveries.d.ts.map +1 -0
  27. package/dist/views/FormDeliveries.js +90 -0
  28. package/dist/views/FormDeliveries.js.map +1 -0
  29. package/dist/views/settings/CaptchaStatusCard.d.ts +7 -0
  30. package/dist/views/settings/CaptchaStatusCard.d.ts.map +1 -0
  31. package/dist/views/settings/CaptchaStatusCard.js +49 -0
  32. package/dist/views/settings/CaptchaStatusCard.js.map +1 -0
  33. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  34. package/dist/views/settings/SecurityTab.js +2 -1
  35. package/dist/views/settings/SecurityTab.js.map +1 -1
  36. package/package.json +3 -3
  37. package/src/AdminRoot.tsx +4 -0
  38. package/src/components/Breadcrumbs.tsx +2 -0
  39. package/src/index.ts +2 -0
  40. package/src/layout/Sidebar.tsx +2 -0
  41. package/src/lib/forms-service.ts +74 -0
  42. package/src/router/admin-routes.ts +1 -0
  43. package/src/views/FormDeliveries.tsx +214 -0
  44. package/src/views/settings/CaptchaStatusCard.tsx +119 -0
  45. package/src/views/settings/SecurityTab.tsx +3 -0
package/src/AdminRoot.tsx CHANGED
@@ -25,6 +25,7 @@ import { Forms } from './views/Forms.js'
25
25
  import { FormEditor } from './views/FormEditor.js'
26
26
  import { FormSubmissions } from './views/FormSubmissions.js'
27
27
  import { FormEntries } from './views/FormEntries.js'
28
+ import { FormDeliveries } from './views/FormDeliveries.js'
28
29
  import { SEO, SEO_DEEP_PATHS, seoTabForPath } from './views/SEO.js'
29
30
  import { ScriptTags } from './views/ScriptTags.js'
30
31
  import { SetupWizard } from './views/SetupWizard.js'
@@ -530,6 +531,9 @@ function AdminShell({
530
531
  if (matchRoute('/forms/entries')) {
531
532
  return <FormEntries onNavigate={navigate} />
532
533
  }
534
+ if (matchRoute('/forms/deliveries')) {
535
+ return <FormDeliveries onNavigate={navigate} />
536
+ }
533
537
  const formEdit = matchRoute('/forms/:id/edit')
534
538
  if (formEdit?.id) {
535
539
  return <FormEditor formId={formEdit.id} onNavigate={navigate} />
@@ -17,6 +17,8 @@ const LABEL_MAP: Record<string, string> = {
17
17
  profile: 'Your Profile',
18
18
  collections: 'Collections',
19
19
  submissions: 'Submissions',
20
+ entries: 'All Entries',
21
+ deliveries: 'Deliveries',
20
22
  new: 'New',
21
23
  edit: 'Edit',
22
24
  }
package/src/index.ts CHANGED
@@ -59,6 +59,8 @@ export { Forms } from './views/Forms.js'
59
59
  export { FormEditor } from './views/FormEditor.js'
60
60
  export type { FormEditorProps } from './views/FormEditor.js'
61
61
  export { FormSubmissions } from './views/FormSubmissions.js'
62
+ export { FormDeliveries } from './views/FormDeliveries.js'
63
+ export type { FormDeliveriesProps } from './views/FormDeliveries.js'
62
64
  /** @deprecated Superseded by the Redirects tab inside the `SEO` view; will be removed in the next major. */
63
65
  export { Redirects } from './views/Redirects.js'
64
66
  export { Users } from './views/Users.js'
@@ -16,6 +16,7 @@ import {
16
16
  ChevronDown,
17
17
  ClipboardList,
18
18
  Inbox,
19
+ AlertTriangle,
19
20
  Search as SearchIcon,
20
21
  Briefcase,
21
22
  FolderOpen,
@@ -355,6 +356,7 @@ function applyFormsNav(items: NavItem[], state: FormsSidebarState): NavItem[] {
355
356
  if (item.path !== '/forms') return item
356
357
  const children: NavItem[] = [
357
358
  { path: '/forms/entries', label: 'All Entries', icon: Inbox },
359
+ { path: '/forms/deliveries', label: 'Deliveries', icon: AlertTriangle },
358
360
  ...state.forms.map((f) => ({
359
361
  path: `/forms/${f.formId}/submissions`,
360
362
  label: f.name,
@@ -493,3 +493,77 @@ export async function downloadEntriesExport(
493
493
  URL.revokeObjectURL(url)
494
494
  return {}
495
495
  }
496
+
497
+ // ─── Delivery attempts (#675) ─────────────────────────────────────────────
498
+
499
+ export type FormDeliveryChannel = 'email_notify' | 'email_autoresponder' | 'form_webhook'
500
+ export type FormDeliveryStatus = 'pending' | 'retrying' | 'success' | 'failed' | 'skipped'
501
+
502
+ export interface FormDeliveryAttempt {
503
+ id: string
504
+ submissionId: string
505
+ formId: string
506
+ channel: FormDeliveryChannel
507
+ destination: string
508
+ webhookId: string | null
509
+ status: FormDeliveryStatus
510
+ attempts: number
511
+ maxAttempts: number
512
+ lastAttemptAt: string | null
513
+ nextRetryAt: string | null
514
+ error: string | null
515
+ responseStatus: number | null
516
+ skippedReason: string | null
517
+ alertedAt: string | null
518
+ createdAt: string
519
+ updatedAt: string
520
+ entryNumber?: number | null
521
+ formName?: string | null
522
+ }
523
+
524
+ export interface FormDeliveriesPage {
525
+ docs: FormDeliveryAttempt[]
526
+ total: number
527
+ page: number
528
+ pageSize: number
529
+ }
530
+
531
+ export interface FetchFormDeliveriesParams {
532
+ formId?: string
533
+ submissionId?: string
534
+ status?: string
535
+ page?: number
536
+ pageSize?: number
537
+ }
538
+
539
+ export async function fetchFormDeliveries(
540
+ params: FetchFormDeliveriesParams = {},
541
+ ): Promise<FormDeliveriesPage> {
542
+ const qs = new URLSearchParams()
543
+ if (params.formId) qs.set('formId', params.formId)
544
+ if (params.submissionId) qs.set('submissionId', params.submissionId)
545
+ if (params.status) qs.set('status', params.status)
546
+ if (params.page) qs.set('page', String(params.page))
547
+ if (params.pageSize) qs.set('pageSize', String(params.pageSize))
548
+ const suffix = qs.toString() ? `?${qs.toString()}` : ''
549
+ const res = await cmsApi<FormDeliveriesPage>(`/forms/deliveries${suffix}`)
550
+ throwIfError(res)
551
+ return res.data ?? { docs: [], total: 0, page: 1, pageSize: 25 }
552
+ }
553
+
554
+ export async function retryFormDelivery(
555
+ attemptId: string,
556
+ ): Promise<{ data?: FormDeliveryAttempt; error?: string }> {
557
+ const res = await cmsApi<FormDeliveryAttempt>(
558
+ `/forms/deliveries/${encodeURIComponent(attemptId)}/retry`,
559
+ { method: 'POST' },
560
+ )
561
+ return { data: res.data, error: res.error }
562
+ }
563
+
564
+ export async function fetchFailedDeliveryCount(formId?: string): Promise<number> {
565
+ const qs = formId ? `?formId=${encodeURIComponent(formId)}` : ''
566
+ const res = await cmsApi<{ count: number }>(`/forms/deliveries/failed-count${qs}`)
567
+ throwIfError(res)
568
+ return res.data?.count ?? 0
569
+ }
@@ -31,6 +31,7 @@ export const ADMIN_STATIC_ROUTE_PATTERNS: readonly string[] = [
31
31
  '/forms/:id/edit',
32
32
  '/forms/:id/submissions',
33
33
  '/forms/entries',
34
+ '/forms/deliveries',
34
35
  '/forms/new',
35
36
  '/media',
36
37
  '/page-builder/:id',
@@ -0,0 +1,214 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Failed / retrying form delivery attempts (#675).
5
+ * Operators can see silent notify/webhook failures and retry without
6
+ * re-submitting the lead.
7
+ */
8
+ import { useCallback, useEffect, useState } from 'react'
9
+ import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react'
10
+ import { toast } from 'sonner'
11
+ import {
12
+ fetchFormDeliveries,
13
+ retryFormDelivery,
14
+ type FormDeliveryAttempt,
15
+ } from '../lib/forms-service.js'
16
+ import {
17
+ FormsEmptyState,
18
+ FormsErrorState,
19
+ FormsLoading,
20
+ FormsPagination,
21
+ } from '../components/forms/primitives.js'
22
+
23
+ export interface FormDeliveriesProps {
24
+ onNavigate?: (path: string) => void
25
+ }
26
+
27
+ const PAGE_SIZE = 25
28
+
29
+ function channelLabel(channel: string): string {
30
+ switch (channel) {
31
+ case 'email_notify':
32
+ return 'Admin email'
33
+ case 'email_autoresponder':
34
+ return 'Autoresponder'
35
+ case 'form_webhook':
36
+ return 'Webhook'
37
+ default:
38
+ return channel
39
+ }
40
+ }
41
+
42
+ function statusClass(status: string): string {
43
+ switch (status) {
44
+ case 'failed':
45
+ return 'bg-destructive/10 text-destructive'
46
+ case 'retrying':
47
+ return 'bg-accent text-accent-foreground'
48
+ case 'success':
49
+ return 'bg-primary/10 text-primary'
50
+ default:
51
+ return 'bg-muted text-muted-foreground'
52
+ }
53
+ }
54
+
55
+ export function FormDeliveries({ onNavigate }: FormDeliveriesProps) {
56
+ const [docs, setDocs] = useState<FormDeliveryAttempt[]>([])
57
+ const [total, setTotal] = useState(0)
58
+ const [page, setPage] = useState(1)
59
+ const [loading, setLoading] = useState(true)
60
+ const [error, setError] = useState<string | null>(null)
61
+ const [retryingId, setRetryingId] = useState<string | null>(null)
62
+
63
+ const load = useCallback(async () => {
64
+ setLoading(true)
65
+ setError(null)
66
+ try {
67
+ const result = await fetchFormDeliveries({
68
+ status: 'failed,retrying',
69
+ page,
70
+ pageSize: PAGE_SIZE,
71
+ })
72
+ setDocs(result.docs)
73
+ setTotal(result.total)
74
+ } catch (err) {
75
+ setError(err instanceof Error ? err.message : 'Failed to load deliveries')
76
+ } finally {
77
+ setLoading(false)
78
+ }
79
+ }, [page])
80
+
81
+ useEffect(() => {
82
+ void load()
83
+ }, [load])
84
+
85
+ async function handleRetry(id: string) {
86
+ setRetryingId(id)
87
+ const res = await retryFormDelivery(id)
88
+ setRetryingId(null)
89
+ if (res.error) {
90
+ toast.error(res.error)
91
+ return
92
+ }
93
+ if (res.data?.status === 'success') {
94
+ toast.success('Delivery succeeded')
95
+ } else if (res.data?.status === 'retrying') {
96
+ toast.message('Delivery still failing — scheduled for another retry')
97
+ } else {
98
+ toast.error(res.data?.error ?? 'Delivery failed again')
99
+ }
100
+ void load()
101
+ }
102
+
103
+ return (
104
+ <div className="flex h-full flex-col gap-4 p-6">
105
+ <div className="flex items-start justify-between gap-4">
106
+ <div>
107
+ <h1 className="text-foreground text-2xl font-medium">Deliveries</h1>
108
+ <p className="text-muted-foreground text-sm">
109
+ Failed or retrying notification and webhook deliveries. Leads stay saved — retry here
110
+ without re-submitting the form.
111
+ </p>
112
+ </div>
113
+ <button
114
+ type="button"
115
+ onClick={() => void load()}
116
+ className="border-border bg-card text-foreground hover:bg-muted inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-medium"
117
+ aria-label="Refresh deliveries"
118
+ >
119
+ <RefreshCw size={16} />
120
+ Refresh
121
+ </button>
122
+ </div>
123
+
124
+ {loading ? (
125
+ <FormsLoading />
126
+ ) : error ? (
127
+ <FormsErrorState message={error} onRetry={() => void load()} />
128
+ ) : docs.length === 0 ? (
129
+ <FormsEmptyState
130
+ icon={<AlertTriangle size={24} />}
131
+ title="No failed deliveries"
132
+ description="When a notify email or webhook fails after a submission is saved, it will appear here."
133
+ />
134
+ ) : (
135
+ <>
136
+ <div className="border-border overflow-hidden rounded-lg border">
137
+ <table className="w-full text-left text-sm" aria-label="Failed form deliveries">
138
+ <thead className="bg-muted/50 border-border border-b">
139
+ <tr>
140
+ <th className="text-muted-foreground px-4 py-3 font-medium">Form</th>
141
+ <th className="text-muted-foreground px-4 py-3 font-medium">Entry</th>
142
+ <th className="text-muted-foreground px-4 py-3 font-medium">Channel</th>
143
+ <th className="text-muted-foreground px-4 py-3 font-medium">Destination</th>
144
+ <th className="text-muted-foreground px-4 py-3 font-medium">Status</th>
145
+ <th className="text-muted-foreground px-4 py-3 font-medium">Error</th>
146
+ <th className="text-muted-foreground px-4 py-3 font-medium">Attempts</th>
147
+ <th className="text-muted-foreground px-4 py-3 font-medium">
148
+ <span className="sr-only">Actions</span>
149
+ </th>
150
+ </tr>
151
+ </thead>
152
+ <tbody>
153
+ {docs.map((row) => (
154
+ <tr key={row.id} className="border-border border-b last:border-b-0">
155
+ <td className="text-foreground px-4 py-3">
156
+ <button
157
+ type="button"
158
+ className="hover:text-primary font-medium"
159
+ onClick={() => onNavigate?.(`/forms/${row.formId}/submissions`)}
160
+ >
161
+ {row.formName ?? row.formId}
162
+ </button>
163
+ </td>
164
+ <td className="text-muted-foreground px-4 py-3">
165
+ {row.entryNumber != null
166
+ ? `#${String(row.entryNumber).padStart(5, '0')}`
167
+ : '—'}
168
+ </td>
169
+ <td className="text-foreground px-4 py-3">{channelLabel(row.channel)}</td>
170
+ <td
171
+ className="text-muted-foreground max-w-[220px] truncate px-4 py-3"
172
+ title={row.destination}
173
+ >
174
+ {row.destination}
175
+ </td>
176
+ <td className="px-4 py-3">
177
+ <span
178
+ className={`inline-flex rounded-md px-2 py-0.5 text-xs font-medium ${statusClass(row.status)}`}
179
+ >
180
+ {row.status}
181
+ </span>
182
+ </td>
183
+ <td
184
+ className="text-muted-foreground max-w-[240px] truncate px-4 py-3"
185
+ title={row.error ?? row.skippedReason ?? undefined}
186
+ >
187
+ {row.error ?? row.skippedReason ?? '—'}
188
+ </td>
189
+ <td className="text-muted-foreground px-4 py-3">
190
+ {row.attempts}/{row.maxAttempts}
191
+ </td>
192
+ <td className="px-4 py-3">
193
+ <button
194
+ type="button"
195
+ disabled={retryingId === row.id}
196
+ onClick={() => void handleRetry(row.id)}
197
+ className="border-border bg-card text-foreground hover:bg-muted inline-flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-sm font-medium disabled:opacity-50"
198
+ aria-label={`Retry delivery to ${row.destination}`}
199
+ >
200
+ <RotateCcw size={14} />
201
+ Retry
202
+ </button>
203
+ </td>
204
+ </tr>
205
+ ))}
206
+ </tbody>
207
+ </table>
208
+ </div>
209
+ <FormsPagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
210
+ </>
211
+ )}
212
+ </div>
213
+ )
214
+ }
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { ShieldCheck } from 'lucide-react'
5
+ import { cmsApi } from '../../lib/api.js'
6
+ import { SettingsCard } from './components.js'
7
+
8
+ interface PublicCaptchaConfig {
9
+ provider: 'recaptcha' | 'turnstile' | 'none'
10
+ siteKey: string | null
11
+ enforce: boolean
12
+ onProviderError: 'fail-open' | 'fail-closed'
13
+ timeoutMs: number
14
+ scoreThreshold: number | null
15
+ }
16
+
17
+ function providerLabel(provider: string): string {
18
+ switch (provider) {
19
+ case 'turnstile':
20
+ return 'Cloudflare Turnstile'
21
+ case 'recaptcha':
22
+ return 'Google reCAPTCHA v3'
23
+ default:
24
+ return 'Not configured'
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Read-only CAPTCHA status for Settings → Security (#676).
30
+ * Keys and policy are env / actuate.config driven — this surface shows what
31
+ * public form submit will enforce.
32
+ */
33
+ export function CaptchaStatusCard() {
34
+ const [config, setConfig] = useState<PublicCaptchaConfig | null>(null)
35
+ const [error, setError] = useState<string | null>(null)
36
+
37
+ useEffect(() => {
38
+ let cancelled = false
39
+ void cmsApi<PublicCaptchaConfig>('/captcha/config')
40
+ .then((res) => {
41
+ if (cancelled) return
42
+ if (res.error || !res.data) {
43
+ setError(res.error ?? 'Unable to load captcha config')
44
+ return
45
+ }
46
+ setConfig(res.data)
47
+ })
48
+ .catch((err) => {
49
+ if (!cancelled)
50
+ setError(err instanceof Error ? err.message : 'Unable to load captcha config')
51
+ })
52
+ return () => {
53
+ cancelled = true
54
+ }
55
+ }, [])
56
+
57
+ return (
58
+ <div id="security-captcha">
59
+ <SettingsCard
60
+ title="Form CAPTCHA"
61
+ description="Server-side Turnstile / reCAPTCHA verification on public form submit. Provider keys stay in environment variables."
62
+ >
63
+ {error ? (
64
+ <p className="text-destructive text-sm">{error}</p>
65
+ ) : !config ? (
66
+ <p className="text-muted-foreground text-sm">Loading captcha status…</p>
67
+ ) : (
68
+ <div className="space-y-3 text-sm">
69
+ <div className="flex items-start gap-2">
70
+ <ShieldCheck
71
+ size={16}
72
+ className="text-muted-foreground mt-0.5 shrink-0"
73
+ aria-hidden
74
+ />
75
+ <div className="space-y-1">
76
+ <p className="text-foreground font-medium">{providerLabel(config.provider)}</p>
77
+ <p className="text-muted-foreground">
78
+ {config.provider === 'none'
79
+ ? 'Set TURNSTILE_* or RECAPTCHA_* env vars to enable. Honeypot spam protection still runs without captcha.'
80
+ : `Site key configured${config.siteKey ? ` (${config.siteKey.slice(0, 8)}…)` : ''}.`}
81
+ </p>
82
+ </div>
83
+ </div>
84
+ <dl className="border-border grid gap-2 rounded-md border p-3 sm:grid-cols-2">
85
+ <div>
86
+ <dt className="text-muted-foreground">Enforce on submit</dt>
87
+ <dd className="text-foreground font-medium">{config.enforce ? 'On' : 'Off'}</dd>
88
+ </div>
89
+ <div>
90
+ <dt className="text-muted-foreground">Provider outage policy</dt>
91
+ <dd className="text-foreground font-medium">{config.onProviderError}</dd>
92
+ </div>
93
+ <div>
94
+ <dt className="text-muted-foreground">Verify timeout</dt>
95
+ <dd className="text-foreground font-medium">{config.timeoutMs} ms</dd>
96
+ </div>
97
+ {config.scoreThreshold != null && (
98
+ <div>
99
+ <dt className="text-muted-foreground">reCAPTCHA score threshold</dt>
100
+ <dd className="text-foreground font-medium">{config.scoreThreshold}</dd>
101
+ </div>
102
+ )}
103
+ </dl>
104
+ <p className="text-muted-foreground text-xs">
105
+ Policy via <code className="text-foreground">CAPTCHA_ENFORCE</code>,{' '}
106
+ <code className="text-foreground">CAPTCHA_ON_PROVIDER_ERROR</code> (
107
+ <code className="text-foreground">fail-open</code> /{' '}
108
+ <code className="text-foreground">fail-closed</code>),{' '}
109
+ <code className="text-foreground">CAPTCHA_TIMEOUT_MS</code>, or{' '}
110
+ <code className="text-foreground">forms.captcha</code> in actuate.config. Token
111
+ failures always reject when enforce is on; only provider outages follow the outage
112
+ policy.
113
+ </p>
114
+ </div>
115
+ )}
116
+ </SettingsCard>
117
+ </div>
118
+ )
119
+ }
@@ -12,6 +12,7 @@ import { AccessProtectionCard } from './AccessProtectionCard.js'
12
12
  import { AuthMfaCard } from './AuthMfaCard.js'
13
13
  import { SecretsAuditCard } from './SecretsAuditCard.js'
14
14
  import { SecurityStatusCard } from './SecurityStatusCard.js'
15
+ import { CaptchaStatusCard } from './CaptchaStatusCard.js'
15
16
  import { SessionPolicyCard } from './SessionPolicyCard.js'
16
17
  import { useSecuritySettings } from './useSecuritySettings.js'
17
18
 
@@ -98,6 +99,8 @@ export function SecurityTab({
98
99
  <div className="space-y-4 pb-4">
99
100
  <SecurityStatusCard status={data.status} onNavigate={onNavigate} />
100
101
 
102
+ <CaptchaStatusCard />
103
+
101
104
  {issues.length > 0 && <SettingsValidationSummary issues={issues} />}
102
105
 
103
106
  {!canEdit && (