PlatformWebhooksPage.tsx517 lines · main
1import { LOCAL_STORAGE_KEYS, useParams } from 'common'
2import { EllipsisVertical, Pencil, RotateCw, Trash2 } from 'lucide-react'
3import { useRouter } from 'next/router'
4import { parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
5import { useEffect, useMemo, useState } from 'react'
6import { toast } from 'sonner'
7import {
8 AlertDialog,
9 AlertDialogAction,
10 AlertDialogCancel,
11 AlertDialogContent,
12 AlertDialogDescription,
13 AlertDialogFooter,
14 AlertDialogHeader,
15 AlertDialogTitle,
16 Button,
17 copyToClipboard,
18 DropdownMenu,
19 DropdownMenuContent,
20 DropdownMenuItem,
21 DropdownMenuTrigger,
22 Label,
23} from 'ui'
24import { Admonition } from 'ui-patterns'
25import { Input } from 'ui-patterns/DataInputs/Input'
26import { PageContainer } from 'ui-patterns/PageContainer'
27import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
28
29import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock'
30import {
31 filterWebhookDeliveries,
32 filterWebhookEndpoints,
33 usePlatformWebhooksMockStore,
34} from './PlatformWebhooks.store'
35import type { WebhookScope } from './PlatformWebhooks.types'
36import { getWebhookEndpointDisplayName } from './PlatformWebhooks.utils'
37import { PlatformWebhooksDeliveryDetailsSheet } from './PlatformWebhooksDeliveryDetailsSheet'
38import { PlatformWebhooksEndpointDetails } from './PlatformWebhooksEndpointDetails'
39import { PlatformWebhooksEndpointList } from './PlatformWebhooksEndpointList'
40import {
41 EndpointFormValues,
42 PlatformWebhooksEndpointSheet,
43 toEndpointPayload,
44} from './PlatformWebhooksEndpointSheet'
45import { PlatformWebhooksHeader } from './PlatformWebhooksHeader'
46import {
47 clearPendingSigningSecretReveal,
48 getPendingSigningSecretReveal,
49 setPendingSigningSecretReveal,
50 shouldHandleEndpointNotFound,
51} from './PlatformWebhooksPage.utils'
52import { useIsPlatformWebhooksEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
53import { InlineLink } from '@/components/ui/InlineLink'
54import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
55
56const PANEL_VALUES = ['create', 'edit'] as const
57
58interface PlatformWebhooksPageProps {
59 scope: WebhookScope
60 endpointId?: string
61}
62
63export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPageProps) => {
64 const router = useRouter()
65 const { slug, ref } = useParams()
66 const { data: selectedOrganization } = useSelectedOrganizationQuery({
67 enabled: scope === 'project',
68 })
69 const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
70 const {
71 endpoints,
72 deliveries,
73 createEndpoint,
74 updateEndpoint,
75 deleteEndpoint,
76 regenerateSecret,
77 retryDelivery,
78 } = usePlatformWebhooksMockStore(scope)
79 const [deliveryId, setDeliveryId] = useQueryState('deliveryId', parseAsString)
80 const [panel, setPanel] = useQueryState('panel', parseAsStringLiteral(PANEL_VALUES))
81 const [search, setSearch] = useQueryState(
82 'search',
83 parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
84 )
85 const [deliverySearch, setDeliverySearch] = useQueryState(
86 'deliverySearch',
87 parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
88 )
89
90 const [endpointIdPendingDelete, setEndpointIdPendingDelete] = useState<string | null>(null)
91 const [signingSecretReveal, setSigningSecretReveal] = useState<{ signingSecret: string } | null>(
92 null
93 )
94 const [showRegenerateSecretConfirm, setShowRegenerateSecretConfirm] = useState(false)
95 const [editEnabledOverride, setEditEnabledOverride] = useState<boolean | null>(null)
96 const [deliveryDetailsTab, setDeliveryDetailsTab] = useState<'event' | 'response'>('event')
97 const [pendingCreatedEndpointId, setPendingCreatedEndpointId] = useState<string | null>(null)
98
99 const scopeLabel = scope === 'organization' ? 'Organization Webhooks' : 'Project Webhooks'
100 const scopeDescription =
101 scope === 'organization'
102 ? 'Organization-level webhook endpoints and deliveries'
103 : 'Webhook endpoints specific to this project'
104 const fallbackHref =
105 scope === 'organization' ? `/org/${slug}/general` : `/project/${ref}/settings/general`
106
107 const eventTypeOptions = PLATFORM_WEBHOOKS_MOCK_DATA[scope].eventTypes
108 const webhooksHref =
109 scope === 'organization' ? `/org/${slug}/webhooks` : `/project/${ref}/settings/webhooks`
110
111 const selectedEndpoint = useMemo(
112 () => endpoints.find((endpoint) => endpoint.id === endpointId) ?? null,
113 [endpoints, endpointId]
114 )
115 const isEndpointView = !!selectedEndpoint
116 const selectedEndpointHasName = selectedEndpoint ? selectedEndpoint.name.trim().length > 0 : false
117 const selectedEndpointDisplayName = selectedEndpoint
118 ? getWebhookEndpointDisplayName(selectedEndpoint)
119 : ''
120 const headerTitle = isEndpointView ? selectedEndpointDisplayName : scopeLabel
121 const headerDescription = isEndpointView
122 ? selectedEndpointHasName
123 ? (selectedEndpoint?.url ?? '')
124 : ''
125 : scopeDescription
126
127 const endpointPendingDelete = useMemo(
128 () => endpoints.find((endpoint) => endpoint.id === endpointIdPendingDelete) ?? null,
129 [endpoints, endpointIdPendingDelete]
130 )
131 const endpointPendingDeleteHasName = endpointPendingDelete
132 ? endpointPendingDelete.name.trim().length > 0
133 : false
134 const endpointPendingDeleteDisplayName = endpointPendingDelete
135 ? getWebhookEndpointDisplayName(endpointPendingDelete)
136 : ''
137 let deleteEndpointDescription = 'This action cannot be undone.'
138 if (endpointPendingDelete) {
139 deleteEndpointDescription = endpointPendingDeleteHasName
140 ? `Deleting “${endpointPendingDeleteDisplayName}” stops all deliveries to the URL below. This can’t be undone.`
141 : 'Deleting this endpoint stops all deliveries to the URL below. This can’t be undone.'
142 }
143
144 useEffect(() => {
145 if (!platformWebhooksEnabled) {
146 router.replace(fallbackHref)
147 }
148 }, [fallbackHref, platformWebhooksEnabled, router])
149
150 useEffect(() => {
151 if (
152 shouldHandleEndpointNotFound({
153 endpointId,
154 hasSelectedEndpoint: !!selectedEndpoint,
155 pendingCreatedEndpointId,
156 })
157 ) {
158 toast('Endpoint not found')
159 router.replace(webhooksHref)
160 }
161 }, [endpointId, pendingCreatedEndpointId, selectedEndpoint, router, webhooksHref])
162
163 useEffect(() => {
164 if (!pendingCreatedEndpointId) return
165 if (
166 endpointId !== pendingCreatedEndpointId ||
167 selectedEndpoint?.id === pendingCreatedEndpointId
168 ) {
169 setPendingCreatedEndpointId(null)
170 }
171 }, [endpointId, pendingCreatedEndpointId, selectedEndpoint])
172
173 useEffect(() => {
174 if (signingSecretReveal || !endpointId) return
175 const pendingReveal = getPendingSigningSecretReveal(scope, endpointId)
176 if (!pendingReveal) return
177 setSigningSecretReveal({ signingSecret: pendingReveal.signingSecret })
178 }, [endpointId, scope, signingSecretReveal])
179
180 const filteredEndpoints = useMemo(() => {
181 return filterWebhookEndpoints(endpoints, search)
182 }, [endpoints, search])
183
184 const filteredDeliveries = useMemo(() => {
185 if (!selectedEndpoint) return []
186 return filterWebhookDeliveries(deliveries, selectedEndpoint.id, deliverySearch)
187 }, [deliveries, deliverySearch, selectedEndpoint])
188
189 const selectedDelivery = useMemo(() => {
190 if (!selectedEndpoint || !deliveryId) return null
191 return (
192 deliveries.find(
193 (delivery) => delivery.id === deliveryId && delivery.endpointId === selectedEndpoint.id
194 ) ?? null
195 )
196 }, [deliveries, deliveryId, selectedEndpoint])
197
198 const deliveryAttempt = useMemo(() => {
199 if (!selectedEndpoint || !selectedDelivery) return null
200 const endpointDeliveries = deliveries
201 .filter((delivery) => delivery.endpointId === selectedEndpoint.id)
202 .sort((a, b) => new Date(b.attemptAt).getTime() - new Date(a.attemptAt).getTime())
203 const index = endpointDeliveries.findIndex((delivery) => delivery.id === selectedDelivery.id)
204 return index >= 0 ? index + 1 : null
205 }, [deliveries, selectedDelivery, selectedEndpoint])
206
207 const deliveryEventPayload = useMemo(() => {
208 if (!selectedEndpoint || !selectedDelivery) return ''
209 return JSON.stringify(
210 {
211 endpoint_id: selectedEndpoint.id,
212 endpoint_url: selectedEndpoint.url,
213 event_type: selectedDelivery.eventType,
214 event_id: selectedDelivery.id,
215 attempted_at: selectedDelivery.attemptAt,
216 scope,
217 },
218 null,
219 2
220 )
221 }, [scope, selectedDelivery, selectedEndpoint])
222
223 const deliveryResponsePayload = useMemo(() => {
224 if (!selectedEndpoint || !selectedDelivery) return ''
225 return JSON.stringify(
226 {
227 endpoint_id: selectedEndpoint.id,
228 delivery_id: selectedDelivery.id,
229 status: selectedDelivery.status,
230 response_code: selectedDelivery.responseCode ?? null,
231 },
232 null,
233 2
234 )
235 }, [selectedDelivery, selectedEndpoint])
236
237 const handleDeleteEndpoint = () => {
238 if (!endpointPendingDelete) return
239 deleteEndpoint(endpointPendingDelete.id)
240 if (endpointPendingDelete.id === endpointId) {
241 router.push(webhooksHref)
242 setDeliverySearch('')
243 }
244 setEndpointIdPendingDelete(null)
245 toast.success('Endpoint deleted')
246 }
247
248 const handleUpsertEndpoint = (values: EndpointFormValues) => {
249 if (panel === 'create') {
250 const { endpointId: createdEndpointId, signingSecret } = createEndpoint(
251 toEndpointPayload(values)
252 )
253 setPendingCreatedEndpointId(createdEndpointId)
254 setPendingSigningSecretReveal(scope, {
255 endpointId: createdEndpointId,
256 signingSecret,
257 })
258 router.push(`${webhooksHref}/${encodeURIComponent(createdEndpointId)}`)
259 setSigningSecretReveal({ signingSecret })
260 setPanel(null)
261 setEditEnabledOverride(null)
262 toast.success('Endpoint created')
263 return
264 }
265
266 if (panel === 'edit' && selectedEndpoint) {
267 updateEndpoint(selectedEndpoint.id, toEndpointPayload(values))
268 setPanel(null)
269 setEditEnabledOverride(null)
270 toast.success('Endpoint updated')
271 }
272 }
273
274 const handleRegenerateSecret = () => {
275 if (!selectedEndpoint) return
276 const nextSecret = regenerateSecret(selectedEndpoint.id)
277 if (!nextSecret) return
278 setSigningSecretReveal({ signingSecret: nextSecret })
279 setShowRegenerateSecretConfirm(false)
280 toast.success('Signing secret regenerated')
281 }
282
283 const handleRetryDelivery = (deliveryId: string) => {
284 const delivery = deliveries.find((item) => item.id === deliveryId)
285 if (!delivery || delivery.status === 'success') return
286
287 retryDelivery(deliveryId)
288 toast.success('Delivery queued for retry')
289 }
290
291 const handleCopy = (value: string, label: string) => {
292 copyToClipboard(value)
293 toast.success(`Copied ${label}`)
294 }
295
296 const isEndpointSheetOpen = panel === 'create' || (panel === 'edit' && !!selectedEndpoint)
297
298 useEffect(() => {
299 if (!selectedEndpoint && !!deliveryId) {
300 setDeliveryId(null)
301 }
302 }, [deliveryId, selectedEndpoint, setDeliveryId])
303
304 useEffect(() => {
305 if (!!deliveryId && !selectedDelivery) {
306 setDeliveryId(null)
307 }
308 }, [deliveryId, selectedDelivery, setDeliveryId])
309
310 if (!platformWebhooksEnabled) {
311 return null
312 }
313
314 return (
315 <>
316 <PlatformWebhooksHeader
317 hasSelectedEndpoint={!!selectedEndpoint}
318 headerTitle={headerTitle}
319 featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS}
320 headerDescription={headerDescription}
321 endpointStatus={
322 selectedEndpoint ? (selectedEndpoint.enabled ? 'enabled' : 'disabled') : undefined
323 }
324 endpointActions={
325 selectedEndpoint ? (
326 <>
327 <Button
328 type="default"
329 icon={<Pencil size={14} />}
330 onClick={() => {
331 setEditEnabledOverride(null)
332 setPanel('edit')
333 }}
334 >
335 Edit
336 </Button>
337 <DropdownMenu>
338 <DropdownMenuTrigger asChild>
339 <Button type="default" icon={<EllipsisVertical />} className="w-7" />
340 </DropdownMenuTrigger>
341 <DropdownMenuContent align="end" side="bottom" className="w-48">
342 <DropdownMenuItem
343 className="gap-x-2"
344 onClick={() => setShowRegenerateSecretConfirm(true)}
345 >
346 <RotateCw size={14} className="text-foreground-lighter" />
347 <span>Regenerate secret</span>
348 </DropdownMenuItem>
349 <DropdownMenuItem
350 className="gap-x-2"
351 onClick={() => setEndpointIdPendingDelete(selectedEndpoint.id)}
352 >
353 <Trash2 size={14} className="text-foreground-lighter" />
354 <span>Delete endpoint</span>
355 </DropdownMenuItem>
356 </DropdownMenuContent>
357 </DropdownMenu>
358 </>
359 ) : undefined
360 }
361 webhooksHref={webhooksHref}
362 scopeLabel={scopeLabel}
363 />
364
365 <PageContainer size="default">
366 <PageSection>
367 <PageSectionContent>
368 {!selectedEndpoint ? (
369 <PlatformWebhooksEndpointList
370 filteredEndpoints={filteredEndpoints}
371 search={search}
372 webhooksHref={webhooksHref}
373 onCreateEndpoint={() => setPanel('create')}
374 onDeleteEndpoint={(id) => setEndpointIdPendingDelete(id)}
375 onSearchChange={setSearch}
376 onViewEndpoint={(id) => {
377 router.push(`${webhooksHref}/${encodeURIComponent(id)}`)
378 setPanel(null)
379 }}
380 />
381 ) : (
382 <PlatformWebhooksEndpointDetails
383 deliverySearch={deliverySearch}
384 filteredDeliveries={filteredDeliveries}
385 selectedEndpoint={selectedEndpoint}
386 onDeliverySearchChange={setDeliverySearch}
387 onOpenDelivery={(id) => {
388 setDeliveryDetailsTab('event')
389 setDeliveryId(id)
390 }}
391 onRetryDelivery={handleRetryDelivery}
392 />
393 )}
394 </PageSectionContent>
395 </PageSection>
396 </PageContainer>
397
398 <PlatformWebhooksDeliveryDetailsSheet
399 deliveryAttempt={deliveryAttempt}
400 deliveryDetailsTab={deliveryDetailsTab}
401 deliveryEventPayload={deliveryEventPayload}
402 deliveryResponsePayload={deliveryResponsePayload}
403 open={!!selectedDelivery}
404 selectedDelivery={selectedDelivery}
405 onCopy={handleCopy}
406 onOpenChange={(open) => !open && setDeliveryId(null)}
407 onRetryDelivery={handleRetryDelivery}
408 onTabChange={setDeliveryDetailsTab}
409 />
410
411 <PlatformWebhooksEndpointSheet
412 visible={isEndpointSheetOpen}
413 mode={panel === 'create' ? 'create' : 'edit'}
414 scope={scope}
415 orgSlug={scope === 'project' ? selectedOrganization?.slug : undefined}
416 endpoint={panel === 'edit' ? (selectedEndpoint ?? undefined) : undefined}
417 enabledOverride={panel === 'edit' ? editEnabledOverride : null}
418 eventTypes={eventTypeOptions}
419 onClose={() => {
420 setPanel(null)
421 setEditEnabledOverride(null)
422 }}
423 onSubmit={handleUpsertEndpoint}
424 />
425
426 <AlertDialog
427 open={!!endpointPendingDelete}
428 onOpenChange={(open) => !open && setEndpointIdPendingDelete(null)}
429 >
430 <AlertDialogContent>
431 <AlertDialogHeader>
432 <AlertDialogTitle>Delete endpoint</AlertDialogTitle>
433 <AlertDialogDescription>{deleteEndpointDescription}</AlertDialogDescription>
434 </AlertDialogHeader>
435 {endpointPendingDelete && (
436 <pre className="mx-5 -mt-1 mb-5 overflow-auto whitespace-nowrap rounded-md border border-muted bg-surface-200 px-4 py-3 font-mono text-xs tracking-tight text-foreground">
437 {endpointPendingDelete.url}
438 </pre>
439 )}
440 <AlertDialogFooter>
441 <AlertDialogCancel>Cancel</AlertDialogCancel>
442 <AlertDialogAction variant="danger" onClick={handleDeleteEndpoint}>
443 Delete endpoint
444 </AlertDialogAction>
445 </AlertDialogFooter>
446 </AlertDialogContent>
447 </AlertDialog>
448
449 <AlertDialog open={showRegenerateSecretConfirm} onOpenChange={setShowRegenerateSecretConfirm}>
450 <AlertDialogContent>
451 <AlertDialogHeader>
452 <AlertDialogTitle>Regenerate secret</AlertDialogTitle>
453 <AlertDialogDescription>
454 This will rotate the current signing secret used for webhook signature verification.
455 </AlertDialogDescription>
456 </AlertDialogHeader>
457 <AlertDialogFooter>
458 <AlertDialogCancel>Cancel</AlertDialogCancel>
459 <AlertDialogAction variant="warning" onClick={handleRegenerateSecret}>
460 Regenerate
461 </AlertDialogAction>
462 </AlertDialogFooter>
463 </AlertDialogContent>
464 </AlertDialog>
465
466 <AlertDialog
467 open={!!signingSecretReveal}
468 onOpenChange={(open) => {
469 if (open) return
470 setSigningSecretReveal(null)
471 clearPendingSigningSecretReveal(scope)
472 }}
473 >
474 <AlertDialogContent>
475 <AlertDialogHeader>
476 <AlertDialogTitle>Signing secret</AlertDialogTitle>
477 <AlertDialogDescription>
478 Use this secret to verify webhook signatures using the{' '}
479 <InlineLink href="https://www.standardwebhooks.com/">Standard Webhooks</InlineLink>{' '}
480 specification.
481 </AlertDialogDescription>
482 </AlertDialogHeader>
483 {/* Content */}
484 <div className="space-y-4 mx-5 pb-5">
485 <div className="space-y-1">
486 <Label>Signing secret</Label>
487 <Input
488 copy
489 readOnly
490 value={signingSecretReveal?.signingSecret ?? ''}
491 onChange={() => {}}
492 onCopy={() => toast.success('Copied signing secret')}
493 />
494 </div>
495 <div>
496 <Admonition
497 type="warning"
498 title="This secret won’t be shown again"
499 description="Copy and store it securely now. You will not be able to view or copy it again after closing this dialog."
500 />
501 </div>
502 </div>
503 <AlertDialogFooter>
504 <AlertDialogAction
505 onClick={() => {
506 setSigningSecretReveal(null)
507 clearPendingSigningSecretReveal(scope)
508 }}
509 >
510 I’ve stored the secret
511 </AlertDialogAction>
512 </AlertDialogFooter>
513 </AlertDialogContent>
514 </AlertDialog>
515 </>
516 )
517}