| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871 |
- import {
- CopyOutlined,
- DeleteOutlined,
- EditOutlined,
- KeyOutlined,
- PlusOutlined,
- ReloadOutlined,
- ThunderboltOutlined
- } from '@ant-design/icons';
- import {
- Alert,
- App as AntApp,
- Button,
- Checkbox,
- Descriptions,
- Drawer,
- Form,
- Input,
- Modal,
- Popconfirm,
- Select,
- Skeleton,
- Space,
- Switch,
- Table,
- Tag,
- Typography
- } from 'antd';
- import type { ColumnsType } from 'antd/es/table';
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
- import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
- 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 { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
- import { api } from '../frontend/services/api';
- import type {
- Domain,
- InboundMailbox,
- Webhook,
- WebhookDelivery,
- WebhookDeliveryStatus,
- WebhookEvent,
- WebhookPayload
- } from '../frontend/types';
- const DELIVERY_EVENTS: WebhookEvent[] = ['sent', 'bounced', 'failed', 'opened', 'clicked'];
- const ALL_EVENTS: WebhookEvent[] = [...DELIVERY_EVENTS, 'received'];
- 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;
- /** When set, only receipt callbacks for this mailbox are shown. */
- mailboxId?: number;
- domains?: Domain[];
- mailboxes?: InboundMailbox[];
- 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: domainIdProp, mailboxId: mailboxIdProp, domains = [], mailboxes = [], onCopy }: WebhooksProps) {
- const { message } = AntApp.useApp();
- const { t } = useI18n();
- const location = useLocation();
- const navigate = useNavigate();
- const [searchParams, setSearchParams] = useSearchParams();
- const queryDomainId = Number(searchParams.get('domainId') || 0) || undefined;
- const queryMailboxId = Number(searchParams.get('mailboxId') || 0) || undefined;
- const requestedWebhookId = positiveInteger(searchParams.get('webhookId'));
- const deliveryStatus = deliveryStatusFromParam(searchParams.get('deliveryStatus'));
- const deliveryEvent = deliveryEventFromParam(searchParams.get('deliveryEvent'));
- const domainId = domainIdProp ?? queryDomainId;
- const mailboxId = mailboxIdProp ?? queryMailboxId;
- const embedded = domainIdProp != null || mailboxIdProp != null;
- const [webhooks, setWebhooks] = useState<Webhook[]>([]);
- const [recentDeliveries, setRecentDeliveries] = useState<WebhookDelivery[]>([]);
- const [detailDeliveries, setDetailDeliveries] = useState<WebhookDelivery[]>([]);
- const [detailLoading, setDetailLoading] = useState(false);
- const [detailError, setDetailError] = useState('');
- const [availableDomains, setAvailableDomains] = useState<Domain[]>(domains);
- const [availableMailboxes, setAvailableMailboxes] = useState<InboundMailbox[]>(mailboxes);
- const [loading, setLoading] = useState(true);
- const [loadError, setLoadError] = useState('');
- const [resourceError, setResourceError] = useState('');
- const [recentDeliveriesError, setRecentDeliveriesError] = useState('');
- const [actionKey, setActionKey] = useState('');
- const [drawerOpen, setDrawerOpen] = useState(false);
- const [editing, setEditing] = useState<Webhook | null>(null);
- const [secretReveal, setSecretReveal] = useState<SecretReveal | null>(null);
- const [form] = Form.useForm<WebhookFormValues>();
- const loadRequestId = useRef(0);
- const detailRequestId = useRef(0);
- const mailboxScoped = mailboxId != null;
- const scoped = domainId != null || mailboxScoped;
- const selectableEvents: WebhookEvent[] = mailboxScoped ? ['received'] : DELIVERY_EVENTS;
- const domainMap = useMemo(() => new Map(availableDomains.map((d) => [d.id, d.domain])), [availableDomains]);
- const mailboxMap = useMemo(() => new Map(availableMailboxes.map((m) => [m.id, m.address])), [availableMailboxes]);
- const selectedWebhook = useMemo(
- () => requestedWebhookId ? webhooks.find((webhook) => webhook.id === requestedWebhookId) || null : null,
- [requestedWebhookId, webhooks]
- );
- const loadData = useCallback(async () => {
- const requestId = ++loadRequestId.current;
- setLoading(true);
- setLoadError('');
- setResourceError('');
- setRecentDeliveriesError('');
- const shouldLoadResources = !domains.length || !mailboxes.length;
- const [webhooksResult, deliveriesResult, domainsResult, mailboxesResult] = await Promise.allSettled([
- api.webhooks(mailboxScoped ? undefined : (domainId != null ? domainId : undefined), mailboxId),
- api.webhookDeliveries({ limit: 100 }),
- shouldLoadResources ? api.domains() : Promise.resolve(null),
- shouldLoadResources ? api.inboundMailboxes() : Promise.resolve(null)
- ]);
- if (requestId !== loadRequestId.current) return;
- let nextWebhooks: Webhook[] = [];
- if (webhooksResult.status === 'fulfilled') {
- nextWebhooks = (webhooksResult.value.webhooks || []).filter((webhook) => scoped || webhook.mailboxId == null);
- setWebhooks(nextWebhooks);
- } else {
- setLoadError(webhooksResult.reason instanceof Error ? webhooksResult.reason.message : t('common.error'));
- }
- if (deliveriesResult.status === 'fulfilled') {
- const webhookIds = new Set(nextWebhooks.map((w) => w.id));
- const nextDeliveries = (deliveriesResult.value.deliveries || []).filter((d) =>
- scoped ? webhookIds.has(d.webhookId) : true
- );
- setRecentDeliveries(nextDeliveries);
- } else {
- setRecentDeliveriesError(deliveriesResult.reason instanceof Error ? deliveriesResult.reason.message : t('common.error'));
- }
- const resourceFailures: string[] = [];
- if (domainsResult.status === 'fulfilled') {
- if (domainsResult.value) setAvailableDomains(domainsResult.value.domains || []);
- } else {
- resourceFailures.push(domainsResult.reason instanceof Error ? domainsResult.reason.message : t('common.error'));
- }
- if (mailboxesResult.status === 'fulfilled') {
- if (mailboxesResult.value) setAvailableMailboxes(mailboxesResult.value.mailboxes || []);
- } else {
- resourceFailures.push(mailboxesResult.reason instanceof Error ? mailboxesResult.reason.message : t('common.error'));
- }
- setResourceError(resourceFailures.join(';'));
- setLoading(false);
- }, [domainId, domains.length, mailboxId, mailboxScoped, mailboxes.length, scoped, t]);
- useEffect(() => {
- if (domains.length) setAvailableDomains(domains);
- }, [domains]);
- useEffect(() => {
- if (mailboxes.length) setAvailableMailboxes(mailboxes);
- }, [mailboxes]);
- useEffect(() => {
- void loadData();
- return () => { loadRequestId.current += 1; };
- }, [loadData]);
- const lastDeliveryByWebhook = useMemo(() => {
- const map = new Map<number, WebhookDelivery>();
- for (const delivery of recentDeliveries) {
- if (!map.has(delivery.webhookId)) map.set(delivery.webhookId, delivery);
- }
- return map;
- }, [recentDeliveries]);
- const loadDetailDeliveries = useCallback(async () => {
- const requestId = ++detailRequestId.current;
- if (!requestedWebhookId) {
- setDetailDeliveries([]);
- setDetailError('');
- setDetailLoading(false);
- return;
- }
- setDetailDeliveries([]);
- setDetailError('');
- setDetailLoading(true);
- try {
- const result = await api.webhookDeliveries({
- webhookId: requestedWebhookId,
- status: deliveryStatus === 'all' ? undefined : deliveryStatus,
- eventType: deliveryEvent === 'all' ? undefined : deliveryEvent,
- limit: 200
- });
- if (requestId === detailRequestId.current) {
- setDetailDeliveries(result.deliveries || []);
- }
- } catch (error) {
- if (requestId === detailRequestId.current) {
- setDetailError(error instanceof Error ? error.message : t('common.error'));
- }
- } finally {
- if (requestId === detailRequestId.current) setDetailLoading(false);
- }
- }, [deliveryEvent, deliveryStatus, requestedWebhookId, t]);
- useEffect(() => {
- void loadDetailDeliveries();
- }, [loadDetailDeliveries]);
- 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: [...selectableEvents],
- domainId: mailboxScoped ? undefined : (domainId != null ? 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] : [...selectableEvents],
- 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();
- const key = editing ? `save:${editing.id}` : 'create';
- setActionKey(key);
- try {
- const payload: WebhookPayload = {
- name: values.name.trim(),
- url: values.url.trim(),
- events: values.events,
- domainId: mailboxScoped ? null : (domainId != null ? domainId : (values.domainId ?? null)),
- mailboxId: mailboxScoped ? mailboxId : 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 {
- setActionKey('');
- }
- }
- async function toggleEnabled(webhook: Webhook, enabled: boolean) {
- setActionKey(`toggle:${webhook.id}`);
- 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 {
- setActionKey('');
- }
- }
- async function deleteWebhook(webhook: Webhook) {
- setActionKey(`delete:${webhook.id}`);
- try {
- await api.deleteWebhook(webhook.id);
- message.success(t('actions.webhookDeleted'));
- if (requestedWebhookId === webhook.id) closeDeliveries();
- await loadData();
- } catch (error) {
- message.error(error instanceof Error ? error.message : t('common.error'));
- } finally {
- setActionKey('');
- }
- }
- async function rotateSecret(webhook: Webhook) {
- setActionKey(`rotate:${webhook.id}`);
- 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 {
- setActionKey('');
- }
- }
- async function testWebhook(webhook: Webhook) {
- setActionKey(`test:${webhook.id}`);
- try {
- await api.testWebhook(webhook.id);
- message.success(t('actions.webhookTestQueued'));
- await loadData();
- viewDeliveries(webhook);
- } catch (error) {
- message.error(error instanceof Error ? error.message : t('common.error'));
- } finally {
- setActionKey('');
- }
- }
- async function replayDelivery(delivery: WebhookDelivery) {
- setActionKey(`replay:${delivery.id}`);
- try {
- await api.replayWebhookDelivery(delivery.id);
- message.success(t('actions.webhookDeliveryReplayed'));
- await Promise.all([loadData(), loadDetailDeliveries()]);
- } catch (error) {
- message.error(error instanceof Error ? error.message : t('common.error'));
- } finally {
- setActionKey('');
- }
- }
- function viewDeliveries(webhook: Webhook) {
- const next = new URLSearchParams(searchParams);
- next.set('webhookId', String(webhook.id));
- next.delete('deliveryStatus');
- next.delete('deliveryEvent');
- setSearchParams(next, {
- state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
- });
- }
- function closeDeliveries() {
- const historyState = detailHistoryState(location.state);
- if (historyState) {
- navigate(-historyState.depth);
- return;
- }
- const next = new URLSearchParams(searchParams);
- next.delete('webhookId');
- next.delete('deliveryStatus');
- next.delete('deliveryEvent');
- setSearchParams(next, { replace: true, state: null });
- }
- function updateDeliveryFilter(key: 'deliveryStatus' | 'deliveryEvent', value: string) {
- const next = new URLSearchParams(searchParams);
- if (value === 'all') next.delete(key);
- else next.set(key, value);
- const historyState = detailHistoryState(location.state);
- if (!historyState) {
- setSearchParams(next, { replace: true });
- return;
- }
- const search = next.toString();
- navigate(
- { pathname: location.pathname, search: search ? `?${search}` : '' },
- { state: detailHistoryLocation(historyState.listPath, historyState.depth + 1) }
- );
- }
- function scopeLabel(webhook: Webhook) {
- if (webhook.mailboxId != null) {
- const address = mailboxMap.get(webhook.mailboxId);
- return address ? `${t('webhooks.scopeMailbox')} · ${address}` : t('webhooks.scopeMailbox');
- }
- 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');
- if (event === 'opened') return t('webhooks.eventOpened');
- if (event === 'clicked') return t('webhooks.eventClicked');
- if (event === 'received') return t('webhooks.eventReceived');
- 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 <Typography.Text type="secondary">—</Typography.Text>;
- const detail = delivery.error
- || (delivery.responseStatus != null ? `HTTP ${delivery.responseStatus}` : '')
- || deliveryStatusLabel(String(delivery.status));
- return (
- <Space size={6} wrap>
- <StatusPill tone={deliveryStatusTone(String(delivery.status))}>
- {deliveryStatusLabel(String(delivery.status))}
- </StatusPill>
- <Typography.Text type="secondary" ellipsis className="inline-code-value">
- {eventLabel(String(delivery.eventType))} · {detail}
- </Typography.Text>
- </Space>
- );
- }
- const endpointColumns: ColumnsType<Webhook> = [
- {
- title: t('webhooks.name'),
- dataIndex: 'name',
- render: (value: string, webhook) => (
- <Space direction="vertical" size={0}>
- <Typography.Text strong>{value}</Typography.Text>
- <Typography.Text type="secondary" code>
- {webhook.secretPrefix}…
- </Typography.Text>
- </Space>
- )
- },
- {
- title: t('webhooks.scope'),
- render: (_, webhook) => scopeLabel(webhook)
- },
- {
- title: t('webhooks.url'),
- dataIndex: 'url',
- ellipsis: true,
- render: (value: string) => (
- <Typography.Text code ellipsis title={value} className="inline-code-value">
- {truncateUrl(value)}
- </Typography.Text>
- )
- },
- {
- title: t('webhooks.events'),
- dataIndex: 'events',
- render: (events: WebhookEvent[]) => (
- <Space size={[4, 4]} wrap>
- {(events || []).map((event) => (
- <Tag key={event}>{eventLabel(event)}</Tag>
- ))}
- </Space>
- )
- },
- {
- title: t('webhooks.enabled'),
- dataIndex: 'enabled',
- width: 100,
- render: (enabled: boolean, webhook) => (
- <Switch
- checked={enabled}
- loading={actionKey === `toggle:${webhook.id}`}
- onChange={(checked) => 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) => (
- <Space wrap size={4}>
- <Button size="small" icon={<ThunderboltOutlined />} loading={actionKey === `test:${webhook.id}`} onClick={() => void testWebhook(webhook)}>
- {t('webhooks.test')}
- </Button>
- <Button size="small" onClick={() => viewDeliveries(webhook)}>
- {t('webhooks.viewDeliveries')}
- </Button>
- <Button size="small" icon={<EditOutlined />} onClick={() => openEdit(webhook)} />
- <Popconfirm title={t('webhooks.rotateConfirm')} onConfirm={() => void rotateSecret(webhook)}>
- <Button aria-label={t('webhooks.rotateSecret')} size="small" icon={<KeyOutlined />} loading={actionKey === `rotate:${webhook.id}`} />
- </Popconfirm>
- <Popconfirm title={t('webhooks.deleteConfirm')} onConfirm={() => void deleteWebhook(webhook)}>
- <Button aria-label={t('common.delete')} size="small" danger icon={<DeleteOutlined />} loading={actionKey === `delete:${webhook.id}`} />
- </Popconfirm>
- </Space>
- )
- }
- ];
- const deliveryColumns: ColumnsType<WebhookDelivery> = [
- {
- title: t('webhooks.createdAt'),
- dataIndex: 'createdAt',
- width: 170,
- render: (value: string) => (value ? new Date(value).toLocaleString() : '—')
- },
- {
- title: t('webhooks.events'),
- dataIndex: 'eventType',
- render: (value: string) => <Tag>{eventLabel(value)}</Tag>
- },
- {
- title: t('common.status'),
- dataIndex: 'status',
- render: (value: string) => (
- <StatusPill tone={deliveryStatusTone(value)}>{deliveryStatusLabel(value)}</StatusPill>
- )
- },
- {
- title: t('webhooks.attemptCount'),
- dataIndex: 'attemptCount',
- width: 90
- },
- {
- title: t('webhooks.responseStatus'),
- dataIndex: 'responseStatus',
- width: 100,
- render: (value: number | null | undefined) => (value != null ? value : '—')
- },
- {
- title: t('webhooks.error'),
- dataIndex: 'error',
- ellipsis: true,
- render: (value: string, row) => value || row.responseBodyPreview || '—'
- },
- {
- title: t('webhooks.lastAttemptAt'),
- dataIndex: 'lastAttemptAt',
- width: 170,
- render: (value?: string | null) => (value ? new Date(value).toLocaleString() : '—')
- },
- {
- title: t('webhooks.actions'),
- fixed: 'right',
- width: 110,
- render: (_, delivery) => (
- <Button
- size="small"
- disabled={delivery.status === 'processing'}
- loading={actionKey === `replay:${delivery.id}`}
- onClick={() => void replayDelivery(delivery)}
- >
- {t('webhooks.replay')}
- </Button>
- )
- }
- ];
- const secret = secretReveal?.webhook.secret || '';
- const sampleVerifier = buildSignatureSample(secret || 'whsec_your_secret');
- return (
- <Space direction="vertical" size={20} className="full-width">
- {!embedded ? (
- <PageHeader
- title={t('webhooks.title')}
- subtitle={t('webhooks.subtitle')}
- extra={
- <Space>
- <Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
- {t('common.refresh')}
- </Button>
- <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
- {t('webhooks.create')}
- </Button>
- </Space>
- }
- />
- ) : (
- <Space direction="vertical" size={12} className="full-width">
- <Alert type="info" showIcon message={mailboxScoped ? t('webhooks.mailboxReceiptHelp') : t('webhooks.domainOverrideHelp')} />
- <Space>
- <Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
- {t('common.refresh')}
- </Button>
- <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
- {t('webhooks.create')}
- </Button>
- </Space>
- </Space>
- )}
- {!embedded ? (
- <Alert type="info" showIcon message={mailboxScoped ? t('webhooks.mailboxReceiptHelp') : t('webhooks.domainOverrideHelp')} />
- ) : null}
- <SectionCard
- title={t('webhooks.listTitle')}
- extra={<StatusPill tone="neutral">{webhooks.length}</StatusPill>}
- >
- {!loadError && resourceError ? <Alert type="warning" showIcon message={t('webhooks.resourcesUnavailable')} description={resourceError} style={{ marginBottom: 12 }} /> : null}
- {!loadError && recentDeliveriesError && webhooks.length ? <Alert type="warning" showIcon message={t('webhooks.recentDeliveriesUnavailable')} description={recentDeliveriesError} style={{ marginBottom: 12 }} /> : null}
- {loadError ? (
- <Alert
- type="error"
- showIcon
- message={t('webhooks.loadFailed')}
- description={loadError}
- action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>}
- />
- ) : loading && !webhooks.length ? (
- <Skeleton active paragraph={{ rows: 6 }} />
- ) : webhooks.length ? (
- <Table
- rowKey="id"
- columns={endpointColumns}
- dataSource={webhooks}
- loading={loading}
- scroll={{ x: 1200 }}
- pagination={{ pageSize: 10 }}
- />
- ) : (
- <EmptyState
- description={t('webhooks.empty')}
- />
- )}
- </SectionCard>
- <Drawer
- title={editing ? t('webhooks.editTitle') : t('webhooks.createTitle')}
- width={520}
- open={drawerOpen}
- onClose={closeDrawer}
- destroyOnHidden
- footer={
- <div className="drawer-footer">
- <Button onClick={closeDrawer}>{t('common.cancel')}</Button>
- <Button type="primary" loading={actionKey === 'create' || actionKey.startsWith('save:')} onClick={() => void submitForm()}>
- {editing ? t('common.save') : t('webhooks.create')}
- </Button>
- </div>
- }
- >
- <Form form={form} layout="vertical" initialValues={{ enabled: true, events: DELIVERY_EVENTS }}>
- <Form.Item
- name="name"
- label={t('webhooks.name')}
- rules={[{ required: true, message: t('webhooks.nameRequired') }]}
- >
- <Input placeholder={t('webhooks.namePlaceholder')} />
- </Form.Item>
- <Form.Item
- name="url"
- label={t('webhooks.url')}
- extra={t('webhooks.urlHint')}
- rules={[{ required: true, message: t('webhooks.urlRequired') }]}
- >
- <Input placeholder={t('webhooks.urlPlaceholder')} />
- </Form.Item>
- <Form.Item
- name="events"
- label={t('webhooks.events')}
- rules={[{ required: true, type: 'array', min: 1, message: t('webhooks.eventsRequired') }]}
- >
- <Checkbox.Group
- options={selectableEvents.map((event) => ({
- value: event,
- label: eventLabel(event)
- }))}
- disabled={mailboxScoped}
- />
- </Form.Item>
- {!scoped ? (
- <Form.Item name="domainId" label={t('webhooks.domain')} extra={t('webhooks.domainAccount')}>
- <Select
- allowClear
- placeholder={t('webhooks.domainAccount')}
- options={availableDomains.map((domain) => ({
- value: domain.id,
- label: domain.domain
- }))}
- />
- </Form.Item>
- ) : null}
- <Form.Item name="enabled" label={t('webhooks.enabled')} valuePropName="checked">
- <Switch />
- </Form.Item>
- </Form>
- </Drawer>
- <Drawer
- title={selectedWebhook ? `${selectedWebhook.name} · ${t('webhooks.deliveriesTitle')}` : t('webhooks.deliveriesTitle')}
- width={760}
- open={Boolean(requestedWebhookId)}
- onClose={closeDeliveries}
- >
- {loading && !selectedWebhook ? (
- <div role="status" aria-label={t('webhooks.deliveriesTitle')}>
- <Skeleton active paragraph={{ rows: 8 }} />
- </div>
- ) : selectedWebhook ? (
- <Space direction="vertical" size={20} className="full-width">
- <Descriptions bordered column={1} size="small">
- <Descriptions.Item label={t('webhooks.url')}>
- <Typography.Text code copyable={{ onCopy: () => void copyValue(selectedWebhook.url) }} style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{selectedWebhook.url}</Typography.Text>
- </Descriptions.Item>
- <Descriptions.Item label={t('webhooks.scope')}>{scopeLabel(selectedWebhook)}</Descriptions.Item>
- <Descriptions.Item label={t('webhooks.events')}>
- <Space wrap>{selectedWebhook.events.map((event) => <Tag key={event}>{eventLabel(event)}</Tag>)}</Space>
- </Descriptions.Item>
- <Descriptions.Item label={t('webhooks.secretPrefix')}><Typography.Text code>{selectedWebhook.secretPrefix}…</Typography.Text></Descriptions.Item>
- <Descriptions.Item label={t('common.status')}>
- <StatusPill tone={selectedWebhook.enabled ? 'success' : 'neutral'}>{selectedWebhook.enabled ? t('webhooks.enabled') : t('webhooks.disabled')}</StatusPill>
- </Descriptions.Item>
- </Descriptions>
- <Space wrap>
- <Select
- aria-label={t('webhooks.deliveriesFilterStatus')}
- style={{ minWidth: 160 }}
- value={deliveryStatus}
- onChange={(value) => updateDeliveryFilter('deliveryStatus', value)}
- options={[{ value: 'all', label: t('webhooks.allStatuses') }, ...DELIVERY_STATUSES.map((status) => ({ value: status, label: deliveryStatusLabel(status) }))]}
- />
- <Select
- aria-label={t('webhooks.deliveriesFilterEvent')}
- style={{ minWidth: 160 }}
- value={deliveryEvent}
- onChange={(value) => updateDeliveryFilter('deliveryEvent', value)}
- options={[{ value: 'all', label: t('webhooks.allEvents') }, ...ALL_EVENTS.map((event) => ({ value: event, label: eventLabel(event) }))]}
- />
- </Space>
- {detailError ? (
- <Alert
- type="error"
- showIcon
- message={detailError}
- action={<Button icon={<ReloadOutlined />} onClick={() => void loadDetailDeliveries()}>{t('common.refresh')}</Button>}
- />
- ) : detailLoading && !detailDeliveries.length ? (
- <div role="status" aria-label={t('webhooks.deliveriesTitle')}>
- <Skeleton active paragraph={{ rows: 6 }} />
- </div>
- ) : detailDeliveries.length ? (
- <Table rowKey="id" columns={deliveryColumns} dataSource={detailDeliveries} loading={detailLoading} scroll={{ x: 920 }} pagination={{ pageSize: 10 }} />
- ) : <EmptyState description={t('webhooks.deliveriesEmpty')} />}
- <Alert type="info" showIcon message={t('webhooks.docsSignature')} description={t('webhooks.docsEvents')} />
- <CodeBlock value={buildSignatureSample('whsec_your_secret')} onCopy={copyValue} />
- </Space>
- ) : loadError && requestedWebhookId ? (
- <Alert type="error" showIcon message={t('webhooks.loadFailed')} description={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} />
- ) : requestedWebhookId ? <EmptyState description={t('common.notFound')} /> : null}
- </Drawer>
- <Modal
- title={
- secretReveal?.mode === 'rotated'
- ? t('webhooks.secretRotatedTitle')
- : t('webhooks.secretCreatedTitle')
- }
- open={Boolean(secretReveal)}
- closable={false}
- maskClosable={false}
- keyboard={false}
- destroyOnHidden
- footer={[
- <Button
- key="copy"
- icon={<CopyOutlined />}
- onClick={() => void copyValue(secret)}
- >
- {t('webhooks.copySecret')}
- </Button>,
- <Button key="done" type="primary" onClick={() => setSecretReveal(null)}>
- {t('common.confirm')}
- </Button>
- ]}
- >
- <Space direction="vertical" size={16} className="full-width">
- <Alert type="error" showIcon message={t('webhooks.secretCreatedWarning')} />
- <div>
- <Typography.Text type="secondary">{t('webhooks.secret')}</Typography.Text>
- <CodeBlock value={secret} onCopy={copyValue} />
- </div>
- <div>
- <Typography.Text type="secondary">{t('webhooks.docsSignature')}</Typography.Text>
- <CodeBlock value={sampleVerifier} onCopy={copyValue} />
- </div>
- </Space>
- </Modal>
- </Space>
- );
- }
- function truncateUrl(url: string, max = 48) {
- if (!url) return '';
- if (url.length <= max) return url;
- return `${url.slice(0, max - 1)}…`;
- }
- function positiveInteger(value: string | null) {
- const parsed = Number(value);
- return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
- }
- function deliveryStatusFromParam(value: string | null): WebhookDeliveryStatus | 'all' {
- return DELIVERY_STATUSES.includes(value as WebhookDeliveryStatus)
- ? value as WebhookDeliveryStatus
- : 'all';
- }
- function deliveryEventFromParam(value: string | null): WebhookEvent | 'all' {
- return ALL_EVENTS.includes(value as WebhookEvent) ? value as WebhookEvent : 'all';
- }
- function buildSignatureSample(secret: string) {
- return `// Verify X-MailHub-Signature (Node.js)
- const crypto = require('crypto');
- function verify(rawBody, signatureHeader, secret = ${JSON.stringify(secret)}) {
- const parts = Object.fromEntries(
- signatureHeader.split(',').map((p) => p.trim().split('='))
- );
- const signed = \`\${parts.t}.\${rawBody}\`;
- const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
- return crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
- }`;
- }
|