PlatformWebhooksPage.tsx 19 KB

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