@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.
- package/CHANGELOG.md +12 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +2 -0
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +2 -1
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-service.d.ts +42 -0
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +27 -0
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/router/admin-routes.d.ts.map +1 -1
- package/dist/router/admin-routes.js +1 -0
- package/dist/router/admin-routes.js.map +1 -1
- package/dist/views/FormDeliveries.d.ts +5 -0
- package/dist/views/FormDeliveries.d.ts.map +1 -0
- package/dist/views/FormDeliveries.js +90 -0
- package/dist/views/FormDeliveries.js.map +1 -0
- package/dist/views/settings/CaptchaStatusCard.d.ts +7 -0
- package/dist/views/settings/CaptchaStatusCard.d.ts.map +1 -0
- package/dist/views/settings/CaptchaStatusCard.js +49 -0
- package/dist/views/settings/CaptchaStatusCard.js.map +1 -0
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +2 -1
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/package.json +3 -3
- package/src/AdminRoot.tsx +4 -0
- package/src/components/Breadcrumbs.tsx +2 -0
- package/src/index.ts +2 -0
- package/src/layout/Sidebar.tsx +2 -0
- package/src/lib/forms-service.ts +74 -0
- package/src/router/admin-routes.ts +1 -0
- package/src/views/FormDeliveries.tsx +214 -0
- package/src/views/settings/CaptchaStatusCard.tsx +119 -0
- 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} />
|
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'
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -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,
|
package/src/lib/forms-service.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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 && (
|