import { CopyOutlined, DeleteOutlined, EditOutlined, KeyOutlined, PlusOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { Alert, App as AntApp, Button, Checkbox, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { CodeBlock } from '../components/common/CodeBlock'; import { EmptyState } from '../components/common/EmptyState'; import { PageHeader } from '../components/common/PageHeader'; import { SectionCard } from '../components/common/SectionCard'; import { StatusPill } from '../components/common/StatusPill'; import type { StatusTone } from '../components/common/StatusPill'; import { useI18n } from '../frontend/i18n/react'; import { api } from '../frontend/services/api'; import type { Domain, Webhook, WebhookDelivery, WebhookDeliveryStatus, WebhookEvent, WebhookPayload } from '../frontend/types'; const ALL_EVENTS: WebhookEvent[] = ['sent', 'bounced', 'failed']; const DELIVERY_STATUSES: WebhookDeliveryStatus[] = ['pending', 'processing', 'success', 'dead']; interface WebhooksProps { /** When set, list/create are scoped to this domain (no global page chrome). */ domainId?: number; domains?: Domain[]; onCopy?: (value: string) => void; } interface WebhookFormValues { name: string; url: string; events: WebhookEvent[]; domainId?: number | null; enabled: boolean; } interface SecretReveal { webhook: Webhook; mode: 'created' | 'rotated'; } export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksProps) { const { message } = AntApp.useApp(); const { t } = useI18n(); const [webhooks, setWebhooks] = useState([]); const [deliveries, setDeliveries] = useState([]); const [loading, setLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); const [secretReveal, setSecretReveal] = useState(null); const [form] = Form.useForm(); const [filterWebhookId, setFilterWebhookId] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); const [filterEvent, setFilterEvent] = useState('all'); const scoped = domainId != null; const domainMap = useMemo(() => new Map(domains.map((d) => [d.id, d.domain])), [domains]); const loadData = useCallback(async () => { setLoading(true); try { const [webhooksResult, deliveriesResult] = await Promise.all([ api.webhooks(scoped ? domainId : undefined), api.webhookDeliveries({ limit: 100 }) ]); const nextWebhooks = webhooksResult.webhooks || []; setWebhooks(nextWebhooks); const webhookIds = new Set(nextWebhooks.map((w) => w.id)); const nextDeliveries = (deliveriesResult.deliveries || []).filter((d) => scoped ? webhookIds.has(d.webhookId) : true ); setDeliveries(nextDeliveries); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setLoading(false); } }, [domainId, message, scoped, t]); useEffect(() => { void loadData(); }, [loadData]); const lastDeliveryByWebhook = useMemo(() => { const map = new Map(); for (const delivery of deliveries) { if (!map.has(delivery.webhookId)) map.set(delivery.webhookId, delivery); } return map; }, [deliveries]); const filteredDeliveries = useMemo(() => { return deliveries.filter((delivery) => { if (filterWebhookId !== 'all' && delivery.webhookId !== filterWebhookId) return false; if (filterStatus !== 'all' && delivery.status !== filterStatus) return false; if (filterEvent !== 'all' && delivery.eventType !== filterEvent) return false; return true; }); }, [deliveries, filterEvent, filterStatus, filterWebhookId]); const webhookNameById = useMemo(() => { const map = new Map(); for (const webhook of webhooks) map.set(webhook.id, webhook.name); return map; }, [webhooks]); async function copyValue(value: string) { if (!value) return; if (onCopy) { onCopy(value); return; } await navigator.clipboard.writeText(value); message.success(t('common.copied')); } function openCreate() { setEditing(null); form.setFieldsValue({ name: '', url: '', events: [...ALL_EVENTS], domainId: scoped ? domainId : undefined, enabled: true }); setDrawerOpen(true); } function openEdit(webhook: Webhook) { setEditing(webhook); form.setFieldsValue({ name: webhook.name, url: webhook.url, events: webhook.events?.length ? [...webhook.events] : [...ALL_EVENTS], domainId: webhook.domainId ?? undefined, enabled: webhook.enabled }); setDrawerOpen(true); } function closeDrawer() { setDrawerOpen(false); setEditing(null); form.resetFields(); } async function submitForm() { const values = await form.validateFields(); setActionLoading(true); try { const payload: WebhookPayload = { name: values.name.trim(), url: values.url.trim(), events: values.events, domainId: scoped ? domainId : (values.domainId ?? null), enabled: values.enabled }; if (editing) { await api.updateWebhook(editing.id, payload); message.success(t('actions.webhookUpdated')); closeDrawer(); await loadData(); } else { const result = await api.createWebhook(payload); message.success(t('actions.webhookCreated')); closeDrawer(); if (result.webhook?.secret) { setSecretReveal({ webhook: result.webhook, mode: 'created' }); } await loadData(); } } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionLoading(false); } } async function toggleEnabled(webhook: Webhook, enabled: boolean) { setActionLoading(true); try { await api.updateWebhook(webhook.id, { enabled }); setWebhooks((current) => current.map((item) => (item.id === webhook.id ? { ...item, enabled } : item)) ); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionLoading(false); } } async function deleteWebhook(webhook: Webhook) { setActionLoading(true); try { await api.deleteWebhook(webhook.id); message.success(t('actions.webhookDeleted')); if (filterWebhookId === webhook.id) setFilterWebhookId('all'); await loadData(); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionLoading(false); } } async function rotateSecret(webhook: Webhook) { setActionLoading(true); try { const result = await api.rotateWebhookSecret(webhook.id); message.success(t('actions.webhookSecretRotated')); if (result.webhook?.secret) { setSecretReveal({ webhook: result.webhook, mode: 'rotated' }); } await loadData(); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionLoading(false); } } async function testWebhook(webhook: Webhook) { setActionLoading(true); try { await api.testWebhook(webhook.id); message.success(t('actions.webhookTestQueued')); setFilterWebhookId(webhook.id); await loadData(); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionLoading(false); } } async function replayDelivery(delivery: WebhookDelivery) { setActionLoading(true); try { await api.replayWebhookDelivery(delivery.id); message.success(t('actions.webhookDeliveryReplayed')); await loadData(); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionLoading(false); } } function viewDeliveries(webhook: Webhook) { setFilterWebhookId(webhook.id); setFilterStatus('all'); setFilterEvent('all'); } function scopeLabel(webhook: Webhook) { if (webhook.domainId == null) return t('webhooks.scopeAccount'); const name = domainMap.get(webhook.domainId); return name ? `${t('webhooks.scopeDomain')} · ${name}` : t('webhooks.scopeDomain'); } function eventLabel(event: string) { if (event === 'sent') return t('webhooks.eventSent'); if (event === 'bounced') return t('webhooks.eventBounced'); if (event === 'failed') return t('webhooks.eventFailed'); return event; } function deliveryStatusLabel(status: string) { if (status === 'pending') return t('webhooks.statusPending'); if (status === 'processing') return t('webhooks.statusProcessing'); if (status === 'success') return t('webhooks.statusSuccess'); if (status === 'dead') return t('webhooks.statusDead'); return status; } function deliveryStatusTone(status: string): StatusTone { if (status === 'success') return 'success'; if (status === 'pending') return 'info'; if (status === 'processing') return 'warning'; if (status === 'dead') return 'error'; return 'neutral'; } function lastDeliverySnippet(webhook: Webhook) { const delivery = lastDeliveryByWebhook.get(webhook.id); if (!delivery) return ; const detail = delivery.error || (delivery.responseStatus != null ? `HTTP ${delivery.responseStatus}` : '') || deliveryStatusLabel(String(delivery.status)); return ( {deliveryStatusLabel(String(delivery.status))} {eventLabel(String(delivery.eventType))} · {detail} ); } const endpointColumns: ColumnsType = [ { title: t('webhooks.name'), dataIndex: 'name', render: (value: string, webhook) => ( {value} {webhook.secretPrefix}… ) }, { title: t('webhooks.scope'), render: (_, webhook) => scopeLabel(webhook) }, { title: t('webhooks.url'), dataIndex: 'url', ellipsis: true, render: (value: string) => ( {truncateUrl(value)} ) }, { title: t('webhooks.events'), dataIndex: 'events', render: (events: WebhookEvent[]) => ( {(events || []).map((event) => ( {eventLabel(event)} ))} ) }, { title: t('webhooks.enabled'), dataIndex: 'enabled', width: 100, render: (enabled: boolean, webhook) => ( void toggleEnabled(webhook, checked)} checkedChildren={t('webhooks.enabled')} unCheckedChildren={t('webhooks.disabled')} /> ) }, { title: t('webhooks.lastAttemptAt'), render: (_, webhook) => lastDeliverySnippet(webhook) }, { title: t('webhooks.actions'), fixed: 'right', width: 280, render: (_, webhook) => ( ) } ]; const secret = secretReveal?.webhook.secret || ''; const sampleVerifier = buildSignatureSample(secret || 'whsec_your_secret'); return ( {!scoped ? ( } /> ) : ( )} {!scoped ? ( ) : null} {webhooks.length}} > {webhooks.length ? ( ) : ( } onClick={openCreate}> {t('webhooks.create')} } /> )} ({ value: status, label: deliveryStatusLabel(status) })) ]} placeholder={t('webhooks.deliveriesFilterStatus')} />
) : ( )} {!scoped ? ( {t('webhooks.docsSignature')} {t('webhooks.docsEvents')} {t('webhooks.urlHint')} ) : null} } >
({ value: event, label: eventLabel(event) }))} /> {!scoped ? (