'use client' import * as React from 'react' import type { WebhookEndpointListItem } from '@admin/actions/webhooks' import { createWebhook, getWebhookSecret, regenerateWebhookSecret, sendTestWebhook, updateWebhook } from '@admin/actions/webhooks' import { Button } from '@admin/components/ui/button' import { Checkbox } from '@admin/components/ui/checkbox' import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@admin/components/ui/dialog' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@admin/components/ui/form' import { Input } from '@admin/components/ui/input' import { Spinner } from '@admin/components/ui/spinner' import { Switch } from '@admin/components/ui/switch' import { webhookEventSources } from '@admin/data/webhook-events' import { useWebhookSubscriptions } from '@admin/hooks/use-webhooks' import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod/v3' const endpointSchema = z.object({ name: z.string().trim().min(1, 'Name is required').max(200, 'Name is too long'), url: z.string().trim().url('Enter a valid URL'), enabled: z.boolean(), events: z.array(z.string()) }) type EndpointFormValues = z.infer interface WebhookEndpointDialogProps { webhook: WebhookEndpointListItem | null open: boolean onOpenChange: (open: boolean) => void } export function WebhookEndpointDialog({ webhook, open, onOpenChange }: WebhookEndpointDialogProps) { const queryClient = useQueryClient() const isEdit = webhook !== null const { data: subscriptions } = useWebhookSubscriptions() const subscribedEvents = React.useMemo( () => webhook ? (subscriptions ?? []) .filter((entry) => entry.webhookId === webhook.id) .map((entry) => entry.event) : [], [subscriptions, webhook] ) const [secret, setSecret] = React.useState(null) const formSources = webhookEventSources.filter((source) => source.kind === 'form') const contentSources = webhookEventSources.filter((source) => source.kind !== 'form') const form = useForm({ resolver: standardSchemaResolver(endpointSchema), defaultValues: { name: webhook?.name ?? '', url: webhook?.url ?? '', enabled: webhook?.enabled ?? true, events: subscribedEvents } }) const events = form.watch('events') React.useEffect(() => { if (!open) return form.reset({ name: webhook?.name ?? '', url: webhook?.url ?? '', enabled: webhook?.enabled ?? true, events: subscribedEvents }) setSecret(null) }, [form, open, subscribedEvents, webhook]) const saveMutation = useMutation({ mutationFn: async (values: EndpointFormValues) => { const endpointValues = { name: values.name, url: values.url, enabled: values.enabled } const result = webhook ? await updateWebhook(webhook.id, { ...endpointValues, events: values.events }) : await createWebhook({ ...endpointValues, events: values.events }) if (!result.success) { throw new Error(result.error || 'Failed to save this webhook') } return result }, onSuccess: async () => { toast.success(isEdit ? 'Webhook saved' : 'Webhook created') await queryClient.invalidateQueries({ queryKey: ['webhooks'] }) await queryClient.invalidateQueries({ queryKey: ['webhook-subscriptions'] }) onOpenChange(false) }, onError: (error: Error) => { toast.error(error.message || 'Failed to save this webhook') } }) const revealSecretMutation = useMutation({ mutationFn: async () => { if (!webhook) throw new Error('Save this webhook first.') const result = await getWebhookSecret(webhook.id) if (!result.success || !result.secret) { throw new Error(result.error || 'Failed to fetch signing secret') } return result.secret }, onSuccess: (value) => setSecret(value), onError: (error: Error) => { toast.error(error.message || 'Failed to fetch signing secret') } }) const regenerateSecretMutation = useMutation({ mutationFn: async () => { if (!webhook) throw new Error('Save this webhook first.') const result = await regenerateWebhookSecret(webhook.id) if (!result.success || !result.secret) { throw new Error(result.error || 'Failed to regenerate signing secret') } return result.secret }, onSuccess: (value) => { setSecret(value) toast.success('Signing secret regenerated') }, onError: (error: Error) => { toast.error(error.message || 'Failed to regenerate signing secret') } }) const testMutation = useMutation({ mutationFn: async () => { if (!webhook) throw new Error('Save this webhook first.') const result = await sendTestWebhook(webhook.id) if (!result.success) { throw new Error(result.error || 'Test webhook failed') } return result }, onSuccess: async (result) => { toast.success( result.statusCode ? `Test delivered (status ${result.statusCode})` : 'Test delivered' ) await queryClient.invalidateQueries({ queryKey: ['webhook-deliveries'] }) }, onError: (error: Error) => { toast.error(error.message || 'Test webhook failed') } }) const isPending = saveMutation.isPending function toggleEvent(event: string, checked: boolean) { const currentEvents = form.getValues('events') const nextEvents = checked ? currentEvents.includes(event) ? currentEvents : [...currentEvents, event] : currentEvents.filter((entry) => entry !== event) form.setValue('events', nextEvents, { shouldDirty: true, shouldValidate: true }) } return (
saveMutation.mutate(values))} > {isEdit ? `Edit webhook — ${webhook.name}` : 'Add webhook'} Events are delivered as signed JSON POST requests
( Name )} /> ( URL )} /> ( Enabled )} /> {isEdit && (

Signing secret

{secret ?? 'whsec_••••••••••••••••'}

Requests carry x-webhook-id, x-webhook-timestamp, and an HMAC-SHA256 x-webhook-signature of the body.

)} {(formSources.length > 0 || contentSources.length > 0) && ( ( {formSources.length > 0 && (
Events · Forms {formSources.flatMap((source) => source.events.map((event) => ( toggleEvent(event, checked === true) } disabled={isPending} /> {event} )) )}
)} {contentSources.length > 0 && (
Events · Entities {contentSources.flatMap((source) => source.events.map((event) => ( toggleEvent(event, checked === true) } disabled={isPending} /> {event} )) )}
)}
)} /> )} {formSources.length === 0 && contentSources.length === 0 && (

No schemas yet — generate an entity or form schema to subscribe this endpoint to its events.

)}
{isEdit && ( )}
) }