@actuate-media/cms-admin 0.80.1 → 0.82.0
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/actuate-admin.css +1 -1
- package/dist/components/PublishSyncCard.d.ts +27 -0
- package/dist/components/PublishSyncCard.d.ts.map +1 -0
- package/dist/components/PublishSyncCard.js +67 -0
- package/dist/components/PublishSyncCard.js.map +1 -0
- package/dist/fields/NavBuilderField.d.ts.map +1 -1
- package/dist/fields/NavBuilderField.js +70 -6
- package/dist/fields/NavBuilderField.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +3 -2
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +4 -2
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/WebhooksTab.d.ts +4 -0
- package/dist/views/settings/WebhooksTab.d.ts.map +1 -0
- package/dist/views/settings/WebhooksTab.js +101 -0
- package/dist/views/settings/WebhooksTab.js.map +1 -0
- package/package.json +3 -3
- package/src/components/PublishSyncCard.tsx +178 -0
- package/src/fields/NavBuilderField.tsx +140 -29
- package/src/views/DocumentEdit.tsx +9 -1
- package/src/views/Settings.tsx +9 -0
- package/src/views/settings/WebhooksTab.tsx +280 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Settings → Webhooks (#677): configure document.published endpoints and
|
|
5
|
+
* retry failed deliveries. Delivery success means "notified", not "live".
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
8
|
+
import { AlertTriangle, Plus, RefreshCw, RotateCcw, Trash2 } from 'lucide-react'
|
|
9
|
+
import { toast } from 'sonner'
|
|
10
|
+
import { cmsApi } from '../../lib/api.js'
|
|
11
|
+
import { Button } from '../../components/ui/Button.js'
|
|
12
|
+
import { SettingsCard } from './components.js'
|
|
13
|
+
|
|
14
|
+
interface WebhookEndpoint {
|
|
15
|
+
id: string
|
|
16
|
+
name: string | null
|
|
17
|
+
url: string
|
|
18
|
+
events: string[]
|
|
19
|
+
active: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface DeliveryRow {
|
|
23
|
+
id: string
|
|
24
|
+
event: string
|
|
25
|
+
status: string
|
|
26
|
+
attempts: number
|
|
27
|
+
maxAttempts: number
|
|
28
|
+
responseStatus: number | null
|
|
29
|
+
createdAt: string
|
|
30
|
+
endpoint?: { id: string; name: string | null; url: string }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const PUBLISH_EVENTS = ['document.published'] as const
|
|
34
|
+
|
|
35
|
+
export function WebhooksTab({ role }: { role?: string }) {
|
|
36
|
+
const canEdit = role === 'ADMIN'
|
|
37
|
+
const [endpoints, setEndpoints] = useState<WebhookEndpoint[]>([])
|
|
38
|
+
const [deliveries, setDeliveries] = useState<DeliveryRow[]>([])
|
|
39
|
+
const [loading, setLoading] = useState(true)
|
|
40
|
+
const [error, setError] = useState<string | null>(null)
|
|
41
|
+
const [url, setUrl] = useState('')
|
|
42
|
+
const [name, setName] = useState('')
|
|
43
|
+
const [saving, setSaving] = useState(false)
|
|
44
|
+
const [retryingId, setRetryingId] = useState<string | null>(null)
|
|
45
|
+
|
|
46
|
+
const load = useCallback(async () => {
|
|
47
|
+
setLoading(true)
|
|
48
|
+
setError(null)
|
|
49
|
+
const [epRes, delRes] = await Promise.all([
|
|
50
|
+
cmsApi<WebhookEndpoint[]>('/webhooks'),
|
|
51
|
+
cmsApi<{ docs: DeliveryRow[] }>('/webhooks/deliveries?status=failed,retrying&pageSize=25'),
|
|
52
|
+
])
|
|
53
|
+
setLoading(false)
|
|
54
|
+
if (epRes.error) {
|
|
55
|
+
setError(epRes.error)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
setEndpoints(Array.isArray(epRes.data) ? epRes.data : [])
|
|
59
|
+
const docs = delRes.data?.docs
|
|
60
|
+
setDeliveries(Array.isArray(docs) ? docs : [])
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
void load()
|
|
65
|
+
}, [load])
|
|
66
|
+
|
|
67
|
+
async function createEndpoint() {
|
|
68
|
+
if (!url.trim()) {
|
|
69
|
+
toast.error('Webhook URL is required')
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
setSaving(true)
|
|
73
|
+
const res = await cmsApi<WebhookEndpoint>('/webhooks', {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
url: url.trim(),
|
|
77
|
+
name: name.trim() || 'Publish / rebuild',
|
|
78
|
+
events: [...PUBLISH_EVENTS],
|
|
79
|
+
active: true,
|
|
80
|
+
}),
|
|
81
|
+
})
|
|
82
|
+
setSaving(false)
|
|
83
|
+
if (res.error) {
|
|
84
|
+
toast.error(res.error)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
toast.success('Publish webhook created')
|
|
88
|
+
setUrl('')
|
|
89
|
+
setName('')
|
|
90
|
+
void load()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function removeEndpoint(id: string) {
|
|
94
|
+
const res = await cmsApi(`/webhooks/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
95
|
+
if (res.error) {
|
|
96
|
+
toast.error(res.error)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
toast.success('Webhook removed')
|
|
100
|
+
void load()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function retryDelivery(id: string) {
|
|
104
|
+
setRetryingId(id)
|
|
105
|
+
const res = await cmsApi<{ status: string; error?: string }>(
|
|
106
|
+
`/webhooks/deliveries/${encodeURIComponent(id)}/retry`,
|
|
107
|
+
{ method: 'POST' },
|
|
108
|
+
)
|
|
109
|
+
setRetryingId(null)
|
|
110
|
+
if (res.error) {
|
|
111
|
+
toast.error(res.error)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (res.data?.status === 'success') {
|
|
115
|
+
toast.success('Webhook delivered')
|
|
116
|
+
} else {
|
|
117
|
+
toast.error(res.data?.error ?? 'Delivery still failing')
|
|
118
|
+
}
|
|
119
|
+
void load()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (loading) {
|
|
123
|
+
return <p className="text-muted-foreground text-sm">Loading webhooks…</p>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (error) {
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
role="alert"
|
|
130
|
+
className="border-destructive/30 bg-destructive/10 text-destructive rounded-lg border p-3 text-sm"
|
|
131
|
+
>
|
|
132
|
+
{error}
|
|
133
|
+
<button type="button" className="ml-3 underline" onClick={() => void load()}>
|
|
134
|
+
Retry
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="space-y-4">
|
|
142
|
+
<SettingsCard
|
|
143
|
+
title="Publish webhooks"
|
|
144
|
+
description="Signed document.published notifications for Astro/Vercel rebuild or cache purge. A 2xx response means the host was notified — not that the public page is already live."
|
|
145
|
+
>
|
|
146
|
+
<div className="space-y-3">
|
|
147
|
+
{endpoints.length === 0 ? (
|
|
148
|
+
<p className="text-muted-foreground text-sm">
|
|
149
|
+
No webhooks yet. Without one, editors only see “Published in CMS” and the public host
|
|
150
|
+
may lag behind TTL caches.
|
|
151
|
+
</p>
|
|
152
|
+
) : (
|
|
153
|
+
<ul className="space-y-2" aria-label="Webhook endpoints">
|
|
154
|
+
{endpoints.map((ep) => (
|
|
155
|
+
<li
|
|
156
|
+
key={ep.id}
|
|
157
|
+
className="border-border flex items-start justify-between gap-3 rounded-md border p-3"
|
|
158
|
+
>
|
|
159
|
+
<div className="min-w-0">
|
|
160
|
+
<p className="text-foreground truncate text-sm font-medium">
|
|
161
|
+
{ep.name || 'Untitled webhook'}
|
|
162
|
+
{!ep.active && (
|
|
163
|
+
<span className="text-muted-foreground ml-2 text-xs">(inactive)</span>
|
|
164
|
+
)}
|
|
165
|
+
</p>
|
|
166
|
+
<p className="text-muted-foreground truncate font-mono text-xs">{ep.url}</p>
|
|
167
|
+
<p className="text-muted-foreground mt-1 text-xs">
|
|
168
|
+
Events: {(Array.isArray(ep.events) ? ep.events : []).join(', ') || '—'}
|
|
169
|
+
</p>
|
|
170
|
+
</div>
|
|
171
|
+
{canEdit && (
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
onClick={() => void removeEndpoint(ep.id)}
|
|
175
|
+
className="text-muted-foreground hover:text-destructive inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs"
|
|
176
|
+
aria-label={`Delete ${ep.name || ep.url}`}
|
|
177
|
+
>
|
|
178
|
+
<Trash2 size={14} />
|
|
179
|
+
Delete
|
|
180
|
+
</button>
|
|
181
|
+
)}
|
|
182
|
+
</li>
|
|
183
|
+
))}
|
|
184
|
+
</ul>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{canEdit && (
|
|
188
|
+
<div className="border-border mt-4 space-y-2 border-t pt-4">
|
|
189
|
+
<label className="block text-sm">
|
|
190
|
+
<span className="text-muted-foreground mb-1 block">Name</span>
|
|
191
|
+
<input
|
|
192
|
+
type="text"
|
|
193
|
+
value={name}
|
|
194
|
+
onChange={(e) => setName(e.target.value)}
|
|
195
|
+
placeholder="Astro rebuild"
|
|
196
|
+
className="border-border bg-input-background w-full rounded-md border px-3 py-2 text-sm"
|
|
197
|
+
/>
|
|
198
|
+
</label>
|
|
199
|
+
<label className="block text-sm">
|
|
200
|
+
<span className="text-muted-foreground mb-1 block">HTTPS URL</span>
|
|
201
|
+
<input
|
|
202
|
+
type="url"
|
|
203
|
+
value={url}
|
|
204
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
205
|
+
placeholder="https://api.vercel.com/.../deployments"
|
|
206
|
+
className="border-border bg-input-background w-full rounded-md border px-3 py-2 font-mono text-sm"
|
|
207
|
+
/>
|
|
208
|
+
</label>
|
|
209
|
+
<p className="text-muted-foreground text-xs">
|
|
210
|
+
Creates an active endpoint for <code>document.published</code> only. Payload is
|
|
211
|
+
HMAC-signed with <code>X-Actuate-Signature</code>.
|
|
212
|
+
</p>
|
|
213
|
+
<Button
|
|
214
|
+
type="button"
|
|
215
|
+
variant="primary"
|
|
216
|
+
size="sm"
|
|
217
|
+
onClick={() => void createEndpoint()}
|
|
218
|
+
loading={saving}
|
|
219
|
+
>
|
|
220
|
+
<Plus size={16} />
|
|
221
|
+
Add publish webhook
|
|
222
|
+
</Button>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
</SettingsCard>
|
|
227
|
+
|
|
228
|
+
<SettingsCard
|
|
229
|
+
title="Failed deliveries"
|
|
230
|
+
description="Retry outbound webhook failures. Automatic retries also run on the cleanup cron."
|
|
231
|
+
>
|
|
232
|
+
<div className="mb-3 flex justify-end">
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={() => void load()}
|
|
236
|
+
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
|
|
237
|
+
>
|
|
238
|
+
<RefreshCw size={14} />
|
|
239
|
+
Refresh
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
{deliveries.length === 0 ? (
|
|
243
|
+
<p className="text-muted-foreground text-sm">No failed or retrying deliveries.</p>
|
|
244
|
+
) : (
|
|
245
|
+
<ul className="space-y-2" aria-label="Failed webhook deliveries">
|
|
246
|
+
{deliveries.map((d) => (
|
|
247
|
+
<li
|
|
248
|
+
key={d.id}
|
|
249
|
+
className="border-border flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
|
|
250
|
+
>
|
|
251
|
+
<div className="min-w-0">
|
|
252
|
+
<p className="text-foreground flex items-center gap-1.5 font-medium">
|
|
253
|
+
<AlertTriangle size={14} className="text-destructive shrink-0" aria-hidden />
|
|
254
|
+
{d.event} · {d.status}
|
|
255
|
+
</p>
|
|
256
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
257
|
+
{d.endpoint?.name || d.endpoint?.url || d.endpoint?.id} · attempt {d.attempts}/
|
|
258
|
+
{d.maxAttempts}
|
|
259
|
+
{d.responseStatus != null ? ` · HTTP ${d.responseStatus}` : ''}
|
|
260
|
+
</p>
|
|
261
|
+
</div>
|
|
262
|
+
{canEdit && (
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
onClick={() => void retryDelivery(d.id)}
|
|
266
|
+
disabled={retryingId === d.id}
|
|
267
|
+
className="border-border hover:bg-accent inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-xs disabled:opacity-50"
|
|
268
|
+
>
|
|
269
|
+
<RotateCcw size={12} />
|
|
270
|
+
Retry
|
|
271
|
+
</button>
|
|
272
|
+
)}
|
|
273
|
+
</li>
|
|
274
|
+
))}
|
|
275
|
+
</ul>
|
|
276
|
+
)}
|
|
277
|
+
</SettingsCard>
|
|
278
|
+
</div>
|
|
279
|
+
)
|
|
280
|
+
}
|