@actuate-media/cms-admin 0.81.0 → 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.
@@ -26,6 +26,7 @@ import { SEOPanel } from '../components/SEOPanel.js'
26
26
  import type { SEOData } from '../components/SEOPanel.js'
27
27
  import { SchedulePublishDialog } from '../components/SchedulePublishDialog.js'
28
28
  import { SharePreviewLinkDialog } from '../components/SharePreviewLinkDialog.js'
29
+ import { PublishSyncCard } from '../components/PublishSyncCard.js'
29
30
  import { cmsApi } from '../lib/api.js'
30
31
 
31
32
  export interface DocumentEditProps {
@@ -226,7 +227,7 @@ export function DocumentEdit({
226
227
  if (res.error) {
227
228
  toast.error(res.error)
228
229
  } else {
229
- toast.success('Document published')
230
+ toast.success('Published in CMS')
230
231
  setDocStatus('PUBLISHED')
231
232
  setInitialValues(values)
232
233
  setInitialSeoData(seoData)
@@ -386,6 +387,13 @@ export function DocumentEdit({
386
387
  </div>
387
388
 
388
389
  <div className="space-y-6">
390
+ {!isNew && documentId && (
391
+ <PublishSyncCard
392
+ documentId={documentId}
393
+ docStatus={docStatus}
394
+ onNavigate={onNavigate}
395
+ />
396
+ )}
389
397
  <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
390
398
  <h3 className="mb-4 font-medium">Status</h3>
391
399
  <div className="space-y-3">
@@ -25,6 +25,7 @@ import { AppearanceTab } from './settings/AppearanceTab.js'
25
25
  import { SecurityTab } from './settings/SecurityTab.js'
26
26
  import { SettingsCard } from './settings/components.js'
27
27
  import { UpdatesTab } from './settings/UpdatesTab.js'
28
+ import { WebhooksTab } from './settings/WebhooksTab.js'
28
29
  import { Users as UsersView } from './Users.js'
29
30
  import { ApiKeys as ApiKeysView } from './ApiKeys.js'
30
31
 
@@ -93,6 +94,7 @@ export function Settings({
93
94
  'api-keys',
94
95
  'seo',
95
96
  'email',
97
+ 'webhooks',
96
98
  'ai',
97
99
  'updates',
98
100
  ] as const,
@@ -228,6 +230,9 @@ export function Settings({
228
230
  Email
229
231
  </span>
230
232
  </Tabs.Trigger>
233
+ <Tabs.Trigger value="webhooks" className={tabTriggerClass}>
234
+ Webhooks
235
+ </Tabs.Trigger>
231
236
  <Tabs.Trigger value="ai" className={tabTriggerClass}>
232
237
  <span className="flex items-center gap-1.5">
233
238
  <Bot className="h-4 w-4" />
@@ -329,6 +334,10 @@ export function Settings({
329
334
  <EmailSettingsTab role={session?.user?.role} />
330
335
  </Tabs.Content>
331
336
 
337
+ <Tabs.Content value="webhooks" className="space-y-4">
338
+ <WebhooksTab role={session?.user?.role} />
339
+ </Tabs.Content>
340
+
332
341
  <Tabs.Content value="ai" className="space-y-4">
333
342
  <AISettingsTab role={session?.user?.role} config={config} />
334
343
  </Tabs.Content>
@@ -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
+ }