@actuate-media/cms-admin 0.81.0 → 0.83.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.
@@ -0,0 +1,178 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Shows CMS Published vs publish-webhook delivery for a document (#677).
5
+ * Never claims the public host is "live" — only webhook notify state.
6
+ */
7
+ import { useCallback, useEffect, useState } from 'react'
8
+ import { AlertTriangle, Info, RefreshCw, RotateCcw } from 'lucide-react'
9
+ import { toast } from 'sonner'
10
+ import { cmsApi } from '../lib/api.js'
11
+
12
+ export interface PublishSyncStatus {
13
+ documentId: string
14
+ cmsStatus: string
15
+ publishedAt: string | null
16
+ publishWebhookConfigured: boolean
17
+ webhookDelivery: {
18
+ state: 'not_configured' | 'no_delivery' | 'pending' | 'retrying' | 'delivered' | 'failed'
19
+ label: string
20
+ deliveries: Array<{
21
+ id: string
22
+ status: string
23
+ endpointName: string | null
24
+ endpointUrl: string
25
+ attempts: number
26
+ maxAttempts: number
27
+ }>
28
+ }
29
+ publicHostNote: string
30
+ }
31
+
32
+ export interface PublishSyncCardProps {
33
+ documentId: string
34
+ /** When status flips to PUBLISHED, refresh sync state. */
35
+ docStatus: string
36
+ onNavigate?: (path: string) => void
37
+ }
38
+
39
+ function stateClass(state: PublishSyncStatus['webhookDelivery']['state']): string {
40
+ switch (state) {
41
+ case 'failed':
42
+ return 'border-destructive/30 bg-destructive/10 text-destructive'
43
+ case 'retrying':
44
+ case 'pending':
45
+ return 'border-border bg-accent text-accent-foreground'
46
+ case 'delivered':
47
+ return 'border-border bg-muted text-foreground'
48
+ default:
49
+ return 'border-border bg-muted text-muted-foreground'
50
+ }
51
+ }
52
+
53
+ export function PublishSyncCard({ documentId, docStatus, onNavigate }: PublishSyncCardProps) {
54
+ const [status, setStatus] = useState<PublishSyncStatus | null>(null)
55
+ const [loading, setLoading] = useState(false)
56
+ const [retryingId, setRetryingId] = useState<string | null>(null)
57
+
58
+ const load = useCallback(async () => {
59
+ if (!documentId || docStatus !== 'PUBLISHED') {
60
+ setStatus(null)
61
+ return
62
+ }
63
+ setLoading(true)
64
+ const res = await cmsApi<PublishSyncStatus>(
65
+ `/documents/${encodeURIComponent(documentId)}/publish-sync`,
66
+ )
67
+ setLoading(false)
68
+ if (res.error) {
69
+ setStatus(null)
70
+ return
71
+ }
72
+ setStatus(res.data ?? null)
73
+ }, [documentId, docStatus])
74
+
75
+ useEffect(() => {
76
+ void load()
77
+ }, [load])
78
+
79
+ if (docStatus !== 'PUBLISHED') return null
80
+
81
+ async function retry(deliveryId: string) {
82
+ setRetryingId(deliveryId)
83
+ const res = await cmsApi<{ status: string; error?: string }>(
84
+ `/webhooks/deliveries/${encodeURIComponent(deliveryId)}/retry`,
85
+ { method: 'POST' },
86
+ )
87
+ setRetryingId(null)
88
+ if (res.error) {
89
+ toast.error(res.error)
90
+ return
91
+ }
92
+ if (res.data?.status === 'success') {
93
+ toast.success('Publish webhook delivered')
94
+ } else {
95
+ toast.error(res.data?.error ?? 'Webhook delivery still failing')
96
+ }
97
+ void load()
98
+ }
99
+
100
+ const state = status?.webhookDelivery.state
101
+ const failed = status?.webhookDelivery.deliveries.filter(
102
+ (d) => d.status === 'failed' || d.status === 'retrying',
103
+ )
104
+
105
+ return (
106
+ <div className="border-border bg-card rounded-lg border p-4">
107
+ <div className="mb-3 flex items-center justify-between gap-2">
108
+ <h3 className="text-foreground font-medium">Public host sync</h3>
109
+ <button
110
+ type="button"
111
+ onClick={() => void load()}
112
+ className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs"
113
+ aria-label="Refresh publish sync status"
114
+ >
115
+ <RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
116
+ Refresh
117
+ </button>
118
+ </div>
119
+
120
+ <p className="text-foreground mb-2 text-sm">
121
+ <span className="font-medium">CMS:</span> Published
122
+ </p>
123
+
124
+ {status ? (
125
+ <>
126
+ <div
127
+ className={`mb-2 rounded-md border px-3 py-2 text-sm ${stateClass(status.webhookDelivery.state)}`}
128
+ role="status"
129
+ >
130
+ {state === 'failed' || state === 'retrying' ? (
131
+ <AlertTriangle size={16} className="mb-1 inline" aria-hidden />
132
+ ) : (
133
+ <Info size={16} className="mb-1 inline" aria-hidden />
134
+ )}{' '}
135
+ {status.webhookDelivery.label}
136
+ </div>
137
+ <p className="text-muted-foreground text-xs">{status.publicHostNote}</p>
138
+ {failed && failed.length > 0 && (
139
+ <ul className="mt-3 space-y-2">
140
+ {failed.map((d) => (
141
+ <li
142
+ key={d.id}
143
+ className="text-foreground flex items-center justify-between gap-2 text-xs"
144
+ >
145
+ <span className="truncate">
146
+ {d.endpointName || d.endpointUrl} · {d.status} ({d.attempts}/{d.maxAttempts})
147
+ </span>
148
+ <button
149
+ type="button"
150
+ onClick={() => void retry(d.id)}
151
+ disabled={retryingId === d.id}
152
+ className="border-border hover:bg-accent inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 disabled:opacity-50"
153
+ >
154
+ <RotateCcw size={12} />
155
+ Retry
156
+ </button>
157
+ </li>
158
+ ))}
159
+ </ul>
160
+ )}
161
+ {onNavigate && (
162
+ <button
163
+ type="button"
164
+ onClick={() => onNavigate('/settings?tab=webhooks')}
165
+ className="text-primary mt-3 text-xs font-medium hover:underline"
166
+ >
167
+ Manage publish webhooks
168
+ </button>
169
+ )}
170
+ </>
171
+ ) : (
172
+ <p className="text-muted-foreground text-sm">
173
+ {loading ? 'Checking webhook delivery…' : 'Unable to load sync status.'}
174
+ </p>
175
+ )}
176
+ </div>
177
+ )
178
+ }
@@ -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,342 @@
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
+ /** Present only on create / rotate responses. */
21
+ secret?: string | null
22
+ }
23
+
24
+ interface DeliveryRow {
25
+ id: string
26
+ event: string
27
+ status: string
28
+ attempts: number
29
+ maxAttempts: number
30
+ responseStatus: number | null
31
+ createdAt: string
32
+ endpoint?: { id: string; name: string | null; url: string }
33
+ }
34
+
35
+ const PUBLISH_EVENTS = ['document.published'] as const
36
+
37
+ export function WebhooksTab({ role }: { role?: string }) {
38
+ const canEdit = role === 'ADMIN'
39
+ const [endpoints, setEndpoints] = useState<WebhookEndpoint[]>([])
40
+ const [deliveries, setDeliveries] = useState<DeliveryRow[]>([])
41
+ const [loading, setLoading] = useState(true)
42
+ const [error, setError] = useState<string | null>(null)
43
+ const [url, setUrl] = useState('')
44
+ const [name, setName] = useState('')
45
+ const [saving, setSaving] = useState(false)
46
+ const [retryingId, setRetryingId] = useState<string | null>(null)
47
+ const [revealedSecret, setRevealedSecret] = useState<{ id: string; secret: string } | null>(null)
48
+
49
+ const load = useCallback(async () => {
50
+ setLoading(true)
51
+ setError(null)
52
+ const [epRes, delRes] = await Promise.all([
53
+ cmsApi<WebhookEndpoint[]>('/webhooks'),
54
+ cmsApi<{ docs: DeliveryRow[] }>('/webhooks/deliveries?status=failed,retrying&pageSize=25'),
55
+ ])
56
+ setLoading(false)
57
+ if (epRes.error) {
58
+ setError(epRes.error)
59
+ return
60
+ }
61
+ setEndpoints(Array.isArray(epRes.data) ? epRes.data : [])
62
+ const docs = delRes.data?.docs
63
+ setDeliveries(Array.isArray(docs) ? docs : [])
64
+ }, [])
65
+
66
+ useEffect(() => {
67
+ void load()
68
+ }, [load])
69
+
70
+ async function createEndpoint() {
71
+ if (!url.trim()) {
72
+ toast.error('Webhook URL is required')
73
+ return
74
+ }
75
+ setSaving(true)
76
+ const res = await cmsApi<WebhookEndpoint>('/webhooks', {
77
+ method: 'POST',
78
+ body: JSON.stringify({
79
+ url: url.trim(),
80
+ name: name.trim() || 'Publish / rebuild',
81
+ events: [...PUBLISH_EVENTS],
82
+ active: true,
83
+ }),
84
+ })
85
+ setSaving(false)
86
+ if (res.error) {
87
+ toast.error(res.error)
88
+ return
89
+ }
90
+ toast.success('Publish webhook created — copy the signing secret now')
91
+ if (res.data?.id && typeof res.data.secret === 'string' && res.data.secret) {
92
+ setRevealedSecret({ id: res.data.id, secret: res.data.secret })
93
+ }
94
+ setUrl('')
95
+ setName('')
96
+ void load()
97
+ }
98
+
99
+ async function removeEndpoint(id: string) {
100
+ const res = await cmsApi(`/webhooks/${encodeURIComponent(id)}`, { method: 'DELETE' })
101
+ if (res.error) {
102
+ toast.error(res.error)
103
+ return
104
+ }
105
+ toast.success('Webhook removed')
106
+ if (revealedSecret?.id === id) setRevealedSecret(null)
107
+ void load()
108
+ }
109
+
110
+ async function rotateSecret(id: string) {
111
+ const res = await cmsApi<WebhookEndpoint>(`/webhooks/${encodeURIComponent(id)}/rotate-secret`, {
112
+ method: 'POST',
113
+ })
114
+ if (res.error) {
115
+ toast.error(res.error)
116
+ return
117
+ }
118
+ toast.success('Secret rotated — previous secret remains valid for 24 hours')
119
+ if (res.data?.id && typeof res.data.secret === 'string' && res.data.secret) {
120
+ setRevealedSecret({ id: res.data.id, secret: res.data.secret })
121
+ }
122
+ void load()
123
+ }
124
+
125
+ async function retryDelivery(id: string) {
126
+ setRetryingId(id)
127
+ const res = await cmsApi<{ status: string; error?: string }>(
128
+ `/webhooks/deliveries/${encodeURIComponent(id)}/retry`,
129
+ { method: 'POST' },
130
+ )
131
+ setRetryingId(null)
132
+ if (res.error) {
133
+ toast.error(res.error)
134
+ return
135
+ }
136
+ if (res.data?.status === 'success') {
137
+ toast.success('Webhook delivered')
138
+ } else {
139
+ toast.error(res.data?.error ?? 'Delivery still failing')
140
+ }
141
+ void load()
142
+ }
143
+
144
+ if (loading) {
145
+ return <p className="text-muted-foreground text-sm">Loading webhooks…</p>
146
+ }
147
+
148
+ if (error) {
149
+ return (
150
+ <div
151
+ role="alert"
152
+ className="border-destructive/30 bg-destructive/10 text-destructive rounded-lg border p-3 text-sm"
153
+ >
154
+ {error}
155
+ <button type="button" className="ml-3 underline" onClick={() => void load()}>
156
+ Retry
157
+ </button>
158
+ </div>
159
+ )
160
+ }
161
+
162
+ return (
163
+ <div className="space-y-4">
164
+ <SettingsCard
165
+ title="Publish webhooks"
166
+ description="Signed document.published notifications (HMAC + timestamp skew). Consumers must verify X-Actuate-Signature and reject replays outside the skew window before rebuild/purge. A 2xx response means notified — not that the public page is live."
167
+ >
168
+ <div className="space-y-3">
169
+ {revealedSecret && (
170
+ <div className="border-border bg-muted rounded-md border p-3 text-sm" role="status">
171
+ <p className="text-foreground mb-1 font-medium">
172
+ Signing secret (shown once — copy now)
173
+ </p>
174
+ <code className="text-foreground block font-mono text-xs break-all">
175
+ {revealedSecret.secret}
176
+ </code>
177
+ <div className="mt-2 flex gap-2">
178
+ <button
179
+ type="button"
180
+ className="text-primary text-xs font-medium hover:underline"
181
+ onClick={() => {
182
+ void navigator.clipboard.writeText(revealedSecret.secret)
183
+ toast.success('Secret copied')
184
+ }}
185
+ >
186
+ Copy
187
+ </button>
188
+ <button
189
+ type="button"
190
+ className="text-muted-foreground text-xs hover:underline"
191
+ onClick={() => setRevealedSecret(null)}
192
+ >
193
+ Dismiss
194
+ </button>
195
+ </div>
196
+ </div>
197
+ )}
198
+ {endpoints.length === 0 ? (
199
+ <p className="text-muted-foreground text-sm">
200
+ No webhooks yet. Without one, editors only see “Published in CMS” and the public host
201
+ may lag behind TTL caches.
202
+ </p>
203
+ ) : (
204
+ <ul className="space-y-2" aria-label="Webhook endpoints">
205
+ {endpoints.map((ep) => (
206
+ <li
207
+ key={ep.id}
208
+ className="border-border flex items-start justify-between gap-3 rounded-md border p-3"
209
+ >
210
+ <div className="min-w-0">
211
+ <p className="text-foreground truncate text-sm font-medium">
212
+ {ep.name || 'Untitled webhook'}
213
+ {!ep.active && (
214
+ <span className="text-muted-foreground ml-2 text-xs">(inactive)</span>
215
+ )}
216
+ </p>
217
+ <p className="text-muted-foreground truncate font-mono text-xs">{ep.url}</p>
218
+ <p className="text-muted-foreground mt-1 text-xs">
219
+ Events: {(Array.isArray(ep.events) ? ep.events : []).join(', ') || '—'}
220
+ </p>
221
+ </div>
222
+ {canEdit && (
223
+ <div className="flex shrink-0 flex-col gap-1">
224
+ <button
225
+ type="button"
226
+ onClick={() => void rotateSecret(ep.id)}
227
+ className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs"
228
+ aria-label={`Rotate secret for ${ep.name || ep.url}`}
229
+ >
230
+ <RefreshCw size={14} />
231
+ Rotate secret
232
+ </button>
233
+ <button
234
+ type="button"
235
+ onClick={() => void removeEndpoint(ep.id)}
236
+ className="text-muted-foreground hover:text-destructive inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs"
237
+ aria-label={`Delete ${ep.name || ep.url}`}
238
+ >
239
+ <Trash2 size={14} />
240
+ Delete
241
+ </button>
242
+ </div>
243
+ )}
244
+ </li>
245
+ ))}
246
+ </ul>
247
+ )}
248
+
249
+ {canEdit && (
250
+ <div className="border-border mt-4 space-y-2 border-t pt-4">
251
+ <label className="block text-sm">
252
+ <span className="text-muted-foreground mb-1 block">Name</span>
253
+ <input
254
+ type="text"
255
+ value={name}
256
+ onChange={(e) => setName(e.target.value)}
257
+ placeholder="Astro rebuild"
258
+ className="border-border bg-input-background w-full rounded-md border px-3 py-2 text-sm"
259
+ />
260
+ </label>
261
+ <label className="block text-sm">
262
+ <span className="text-muted-foreground mb-1 block">HTTPS URL</span>
263
+ <input
264
+ type="url"
265
+ value={url}
266
+ onChange={(e) => setUrl(e.target.value)}
267
+ placeholder="https://api.vercel.com/.../deployments"
268
+ className="border-border bg-input-background w-full rounded-md border px-3 py-2 font-mono text-sm"
269
+ />
270
+ </label>
271
+ <p className="text-muted-foreground text-xs">
272
+ Creates an active endpoint for <code>document.published</code> only. Payload is
273
+ HMAC-signed with <code>X-Actuate-Signature</code>.
274
+ </p>
275
+ <Button
276
+ type="button"
277
+ variant="primary"
278
+ size="sm"
279
+ onClick={() => void createEndpoint()}
280
+ loading={saving}
281
+ >
282
+ <Plus size={16} />
283
+ Add publish webhook
284
+ </Button>
285
+ </div>
286
+ )}
287
+ </div>
288
+ </SettingsCard>
289
+
290
+ <SettingsCard
291
+ title="Failed deliveries"
292
+ description="Retry outbound webhook failures. Automatic retries also run on the cleanup cron."
293
+ >
294
+ <div className="mb-3 flex justify-end">
295
+ <button
296
+ type="button"
297
+ onClick={() => void load()}
298
+ className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
299
+ >
300
+ <RefreshCw size={14} />
301
+ Refresh
302
+ </button>
303
+ </div>
304
+ {deliveries.length === 0 ? (
305
+ <p className="text-muted-foreground text-sm">No failed or retrying deliveries.</p>
306
+ ) : (
307
+ <ul className="space-y-2" aria-label="Failed webhook deliveries">
308
+ {deliveries.map((d) => (
309
+ <li
310
+ key={d.id}
311
+ className="border-border flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
312
+ >
313
+ <div className="min-w-0">
314
+ <p className="text-foreground flex items-center gap-1.5 font-medium">
315
+ <AlertTriangle size={14} className="text-destructive shrink-0" aria-hidden />
316
+ {d.event} · {d.status}
317
+ </p>
318
+ <p className="text-muted-foreground truncate text-xs">
319
+ {d.endpoint?.name || d.endpoint?.url || d.endpoint?.id} · attempt {d.attempts}/
320
+ {d.maxAttempts}
321
+ {d.responseStatus != null ? ` · HTTP ${d.responseStatus}` : ''}
322
+ </p>
323
+ </div>
324
+ {canEdit && (
325
+ <button
326
+ type="button"
327
+ onClick={() => void retryDelivery(d.id)}
328
+ disabled={retryingId === d.id}
329
+ 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"
330
+ >
331
+ <RotateCcw size={12} />
332
+ Retry
333
+ </button>
334
+ )}
335
+ </li>
336
+ ))}
337
+ </ul>
338
+ )}
339
+ </SettingsCard>
340
+ </div>
341
+ )
342
+ }