| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517 |
- import { LOCAL_STORAGE_KEYS, useParams } from 'common'
- import { EllipsisVertical, Pencil, RotateCw, Trash2 } from 'lucide-react'
- import { useRouter } from 'next/router'
- import { parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
- import { useEffect, useMemo, useState } from 'react'
- import { toast } from 'sonner'
- import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- Button,
- copyToClipboard,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
- Label,
- } from 'ui'
- import { Admonition } from 'ui-patterns'
- import { Input } from 'ui-patterns/DataInputs/Input'
- import { PageContainer } from 'ui-patterns/PageContainer'
- import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
- import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock'
- import {
- filterWebhookDeliveries,
- filterWebhookEndpoints,
- usePlatformWebhooksMockStore,
- } from './PlatformWebhooks.store'
- import type { WebhookScope } from './PlatformWebhooks.types'
- import { getWebhookEndpointDisplayName } from './PlatformWebhooks.utils'
- import { PlatformWebhooksDeliveryDetailsSheet } from './PlatformWebhooksDeliveryDetailsSheet'
- import { PlatformWebhooksEndpointDetails } from './PlatformWebhooksEndpointDetails'
- import { PlatformWebhooksEndpointList } from './PlatformWebhooksEndpointList'
- import {
- EndpointFormValues,
- PlatformWebhooksEndpointSheet,
- toEndpointPayload,
- } from './PlatformWebhooksEndpointSheet'
- import { PlatformWebhooksHeader } from './PlatformWebhooksHeader'
- import {
- clearPendingSigningSecretReveal,
- getPendingSigningSecretReveal,
- setPendingSigningSecretReveal,
- shouldHandleEndpointNotFound,
- } from './PlatformWebhooksPage.utils'
- import { useIsPlatformWebhooksEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
- import { InlineLink } from '@/components/ui/InlineLink'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- const PANEL_VALUES = ['create', 'edit'] as const
- interface PlatformWebhooksPageProps {
- scope: WebhookScope
- endpointId?: string
- }
- export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPageProps) => {
- const router = useRouter()
- const { slug, ref } = useParams()
- const { data: selectedOrganization } = useSelectedOrganizationQuery({
- enabled: scope === 'project',
- })
- const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
- const {
- endpoints,
- deliveries,
- createEndpoint,
- updateEndpoint,
- deleteEndpoint,
- regenerateSecret,
- retryDelivery,
- } = usePlatformWebhooksMockStore(scope)
- const [deliveryId, setDeliveryId] = useQueryState('deliveryId', parseAsString)
- const [panel, setPanel] = useQueryState('panel', parseAsStringLiteral(PANEL_VALUES))
- const [search, setSearch] = useQueryState(
- 'search',
- parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
- )
- const [deliverySearch, setDeliverySearch] = useQueryState(
- 'deliverySearch',
- parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
- )
- const [endpointIdPendingDelete, setEndpointIdPendingDelete] = useState<string | null>(null)
- const [signingSecretReveal, setSigningSecretReveal] = useState<{ signingSecret: string } | null>(
- null
- )
- const [showRegenerateSecretConfirm, setShowRegenerateSecretConfirm] = useState(false)
- const [editEnabledOverride, setEditEnabledOverride] = useState<boolean | null>(null)
- const [deliveryDetailsTab, setDeliveryDetailsTab] = useState<'event' | 'response'>('event')
- const [pendingCreatedEndpointId, setPendingCreatedEndpointId] = useState<string | null>(null)
- const scopeLabel = scope === 'organization' ? 'Organization Webhooks' : 'Project Webhooks'
- const scopeDescription =
- scope === 'organization'
- ? 'Organization-level webhook endpoints and deliveries'
- : 'Webhook endpoints specific to this project'
- const fallbackHref =
- scope === 'organization' ? `/org/${slug}/general` : `/project/${ref}/settings/general`
- const eventTypeOptions = PLATFORM_WEBHOOKS_MOCK_DATA[scope].eventTypes
- const webhooksHref =
- scope === 'organization' ? `/org/${slug}/webhooks` : `/project/${ref}/settings/webhooks`
- const selectedEndpoint = useMemo(
- () => endpoints.find((endpoint) => endpoint.id === endpointId) ?? null,
- [endpoints, endpointId]
- )
- const isEndpointView = !!selectedEndpoint
- const selectedEndpointHasName = selectedEndpoint ? selectedEndpoint.name.trim().length > 0 : false
- const selectedEndpointDisplayName = selectedEndpoint
- ? getWebhookEndpointDisplayName(selectedEndpoint)
- : ''
- const headerTitle = isEndpointView ? selectedEndpointDisplayName : scopeLabel
- const headerDescription = isEndpointView
- ? selectedEndpointHasName
- ? (selectedEndpoint?.url ?? '')
- : ''
- : scopeDescription
- const endpointPendingDelete = useMemo(
- () => endpoints.find((endpoint) => endpoint.id === endpointIdPendingDelete) ?? null,
- [endpoints, endpointIdPendingDelete]
- )
- const endpointPendingDeleteHasName = endpointPendingDelete
- ? endpointPendingDelete.name.trim().length > 0
- : false
- const endpointPendingDeleteDisplayName = endpointPendingDelete
- ? getWebhookEndpointDisplayName(endpointPendingDelete)
- : ''
- let deleteEndpointDescription = 'This action cannot be undone.'
- if (endpointPendingDelete) {
- deleteEndpointDescription = endpointPendingDeleteHasName
- ? `Deleting “${endpointPendingDeleteDisplayName}” stops all deliveries to the URL below. This can’t be undone.`
- : 'Deleting this endpoint stops all deliveries to the URL below. This can’t be undone.'
- }
- useEffect(() => {
- if (!platformWebhooksEnabled) {
- router.replace(fallbackHref)
- }
- }, [fallbackHref, platformWebhooksEnabled, router])
- useEffect(() => {
- if (
- shouldHandleEndpointNotFound({
- endpointId,
- hasSelectedEndpoint: !!selectedEndpoint,
- pendingCreatedEndpointId,
- })
- ) {
- toast('Endpoint not found')
- router.replace(webhooksHref)
- }
- }, [endpointId, pendingCreatedEndpointId, selectedEndpoint, router, webhooksHref])
- useEffect(() => {
- if (!pendingCreatedEndpointId) return
- if (
- endpointId !== pendingCreatedEndpointId ||
- selectedEndpoint?.id === pendingCreatedEndpointId
- ) {
- setPendingCreatedEndpointId(null)
- }
- }, [endpointId, pendingCreatedEndpointId, selectedEndpoint])
- useEffect(() => {
- if (signingSecretReveal || !endpointId) return
- const pendingReveal = getPendingSigningSecretReveal(scope, endpointId)
- if (!pendingReveal) return
- setSigningSecretReveal({ signingSecret: pendingReveal.signingSecret })
- }, [endpointId, scope, signingSecretReveal])
- const filteredEndpoints = useMemo(() => {
- return filterWebhookEndpoints(endpoints, search)
- }, [endpoints, search])
- const filteredDeliveries = useMemo(() => {
- if (!selectedEndpoint) return []
- return filterWebhookDeliveries(deliveries, selectedEndpoint.id, deliverySearch)
- }, [deliveries, deliverySearch, selectedEndpoint])
- const selectedDelivery = useMemo(() => {
- if (!selectedEndpoint || !deliveryId) return null
- return (
- deliveries.find(
- (delivery) => delivery.id === deliveryId && delivery.endpointId === selectedEndpoint.id
- ) ?? null
- )
- }, [deliveries, deliveryId, selectedEndpoint])
- const deliveryAttempt = useMemo(() => {
- if (!selectedEndpoint || !selectedDelivery) return null
- const endpointDeliveries = deliveries
- .filter((delivery) => delivery.endpointId === selectedEndpoint.id)
- .sort((a, b) => new Date(b.attemptAt).getTime() - new Date(a.attemptAt).getTime())
- const index = endpointDeliveries.findIndex((delivery) => delivery.id === selectedDelivery.id)
- return index >= 0 ? index + 1 : null
- }, [deliveries, selectedDelivery, selectedEndpoint])
- const deliveryEventPayload = useMemo(() => {
- if (!selectedEndpoint || !selectedDelivery) return ''
- return JSON.stringify(
- {
- endpoint_id: selectedEndpoint.id,
- endpoint_url: selectedEndpoint.url,
- event_type: selectedDelivery.eventType,
- event_id: selectedDelivery.id,
- attempted_at: selectedDelivery.attemptAt,
- scope,
- },
- null,
- 2
- )
- }, [scope, selectedDelivery, selectedEndpoint])
- const deliveryResponsePayload = useMemo(() => {
- if (!selectedEndpoint || !selectedDelivery) return ''
- return JSON.stringify(
- {
- endpoint_id: selectedEndpoint.id,
- delivery_id: selectedDelivery.id,
- status: selectedDelivery.status,
- response_code: selectedDelivery.responseCode ?? null,
- },
- null,
- 2
- )
- }, [selectedDelivery, selectedEndpoint])
- const handleDeleteEndpoint = () => {
- if (!endpointPendingDelete) return
- deleteEndpoint(endpointPendingDelete.id)
- if (endpointPendingDelete.id === endpointId) {
- router.push(webhooksHref)
- setDeliverySearch('')
- }
- setEndpointIdPendingDelete(null)
- toast.success('Endpoint deleted')
- }
- const handleUpsertEndpoint = (values: EndpointFormValues) => {
- if (panel === 'create') {
- const { endpointId: createdEndpointId, signingSecret } = createEndpoint(
- toEndpointPayload(values)
- )
- setPendingCreatedEndpointId(createdEndpointId)
- setPendingSigningSecretReveal(scope, {
- endpointId: createdEndpointId,
- signingSecret,
- })
- router.push(`${webhooksHref}/${encodeURIComponent(createdEndpointId)}`)
- setSigningSecretReveal({ signingSecret })
- setPanel(null)
- setEditEnabledOverride(null)
- toast.success('Endpoint created')
- return
- }
- if (panel === 'edit' && selectedEndpoint) {
- updateEndpoint(selectedEndpoint.id, toEndpointPayload(values))
- setPanel(null)
- setEditEnabledOverride(null)
- toast.success('Endpoint updated')
- }
- }
- const handleRegenerateSecret = () => {
- if (!selectedEndpoint) return
- const nextSecret = regenerateSecret(selectedEndpoint.id)
- if (!nextSecret) return
- setSigningSecretReveal({ signingSecret: nextSecret })
- setShowRegenerateSecretConfirm(false)
- toast.success('Signing secret regenerated')
- }
- const handleRetryDelivery = (deliveryId: string) => {
- const delivery = deliveries.find((item) => item.id === deliveryId)
- if (!delivery || delivery.status === 'success') return
- retryDelivery(deliveryId)
- toast.success('Delivery queued for retry')
- }
- const handleCopy = (value: string, label: string) => {
- copyToClipboard(value)
- toast.success(`Copied ${label}`)
- }
- const isEndpointSheetOpen = panel === 'create' || (panel === 'edit' && !!selectedEndpoint)
- useEffect(() => {
- if (!selectedEndpoint && !!deliveryId) {
- setDeliveryId(null)
- }
- }, [deliveryId, selectedEndpoint, setDeliveryId])
- useEffect(() => {
- if (!!deliveryId && !selectedDelivery) {
- setDeliveryId(null)
- }
- }, [deliveryId, selectedDelivery, setDeliveryId])
- if (!platformWebhooksEnabled) {
- return null
- }
- return (
- <>
- <PlatformWebhooksHeader
- hasSelectedEndpoint={!!selectedEndpoint}
- headerTitle={headerTitle}
- featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS}
- headerDescription={headerDescription}
- endpointStatus={
- selectedEndpoint ? (selectedEndpoint.enabled ? 'enabled' : 'disabled') : undefined
- }
- endpointActions={
- selectedEndpoint ? (
- <>
- <Button
- type="default"
- icon={<Pencil size={14} />}
- onClick={() => {
- setEditEnabledOverride(null)
- setPanel('edit')
- }}
- >
- Edit
- </Button>
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button type="default" icon={<EllipsisVertical />} className="w-7" />
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" side="bottom" className="w-48">
- <DropdownMenuItem
- className="gap-x-2"
- onClick={() => setShowRegenerateSecretConfirm(true)}
- >
- <RotateCw size={14} className="text-foreground-lighter" />
- <span>Regenerate secret</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- className="gap-x-2"
- onClick={() => setEndpointIdPendingDelete(selectedEndpoint.id)}
- >
- <Trash2 size={14} className="text-foreground-lighter" />
- <span>Delete endpoint</span>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </>
- ) : undefined
- }
- webhooksHref={webhooksHref}
- scopeLabel={scopeLabel}
- />
- <PageContainer size="default">
- <PageSection>
- <PageSectionContent>
- {!selectedEndpoint ? (
- <PlatformWebhooksEndpointList
- filteredEndpoints={filteredEndpoints}
- search={search}
- webhooksHref={webhooksHref}
- onCreateEndpoint={() => setPanel('create')}
- onDeleteEndpoint={(id) => setEndpointIdPendingDelete(id)}
- onSearchChange={setSearch}
- onViewEndpoint={(id) => {
- router.push(`${webhooksHref}/${encodeURIComponent(id)}`)
- setPanel(null)
- }}
- />
- ) : (
- <PlatformWebhooksEndpointDetails
- deliverySearch={deliverySearch}
- filteredDeliveries={filteredDeliveries}
- selectedEndpoint={selectedEndpoint}
- onDeliverySearchChange={setDeliverySearch}
- onOpenDelivery={(id) => {
- setDeliveryDetailsTab('event')
- setDeliveryId(id)
- }}
- onRetryDelivery={handleRetryDelivery}
- />
- )}
- </PageSectionContent>
- </PageSection>
- </PageContainer>
- <PlatformWebhooksDeliveryDetailsSheet
- deliveryAttempt={deliveryAttempt}
- deliveryDetailsTab={deliveryDetailsTab}
- deliveryEventPayload={deliveryEventPayload}
- deliveryResponsePayload={deliveryResponsePayload}
- open={!!selectedDelivery}
- selectedDelivery={selectedDelivery}
- onCopy={handleCopy}
- onOpenChange={(open) => !open && setDeliveryId(null)}
- onRetryDelivery={handleRetryDelivery}
- onTabChange={setDeliveryDetailsTab}
- />
- <PlatformWebhooksEndpointSheet
- visible={isEndpointSheetOpen}
- mode={panel === 'create' ? 'create' : 'edit'}
- scope={scope}
- orgSlug={scope === 'project' ? selectedOrganization?.slug : undefined}
- endpoint={panel === 'edit' ? (selectedEndpoint ?? undefined) : undefined}
- enabledOverride={panel === 'edit' ? editEnabledOverride : null}
- eventTypes={eventTypeOptions}
- onClose={() => {
- setPanel(null)
- setEditEnabledOverride(null)
- }}
- onSubmit={handleUpsertEndpoint}
- />
- <AlertDialog
- open={!!endpointPendingDelete}
- onOpenChange={(open) => !open && setEndpointIdPendingDelete(null)}
- >
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>Delete endpoint</AlertDialogTitle>
- <AlertDialogDescription>{deleteEndpointDescription}</AlertDialogDescription>
- </AlertDialogHeader>
- {endpointPendingDelete && (
- <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">
- {endpointPendingDelete.url}
- </pre>
- )}
- <AlertDialogFooter>
- <AlertDialogCancel>Cancel</AlertDialogCancel>
- <AlertDialogAction variant="danger" onClick={handleDeleteEndpoint}>
- Delete endpoint
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- <AlertDialog open={showRegenerateSecretConfirm} onOpenChange={setShowRegenerateSecretConfirm}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>Regenerate secret</AlertDialogTitle>
- <AlertDialogDescription>
- This will rotate the current signing secret used for webhook signature verification.
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel>Cancel</AlertDialogCancel>
- <AlertDialogAction variant="warning" onClick={handleRegenerateSecret}>
- Regenerate
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- <AlertDialog
- open={!!signingSecretReveal}
- onOpenChange={(open) => {
- if (open) return
- setSigningSecretReveal(null)
- clearPendingSigningSecretReveal(scope)
- }}
- >
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>Signing secret</AlertDialogTitle>
- <AlertDialogDescription>
- Use this secret to verify webhook signatures using the{' '}
- <InlineLink href="https://www.standardwebhooks.com/">Standard Webhooks</InlineLink>{' '}
- specification.
- </AlertDialogDescription>
- </AlertDialogHeader>
- {/* Content */}
- <div className="space-y-4 mx-5 pb-5">
- <div className="space-y-1">
- <Label>Signing secret</Label>
- <Input
- copy
- readOnly
- value={signingSecretReveal?.signingSecret ?? ''}
- onChange={() => {}}
- onCopy={() => toast.success('Copied signing secret')}
- />
- </div>
- <div>
- <Admonition
- type="warning"
- title="This secret won’t be shown again"
- description="Copy and store it securely now. You will not be able to view or copy it again after closing this dialog."
- />
- </div>
- </div>
- <AlertDialogFooter>
- <AlertDialogAction
- onClick={() => {
- setSigningSecretReveal(null)
- clearPendingSigningSecretReveal(scope)
- }}
- >
- I’ve stored the secret
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
- }
|