| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716 |
- 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<Webhook[]>([]);
- const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([]);
- const [loading, setLoading] = useState(false);
- const [actionLoading, setActionLoading] = useState(false);
- 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 [filterWebhookId, setFilterWebhookId] = useState<number | 'all'>('all');
- const [filterStatus, setFilterStatus] = useState<WebhookDeliveryStatus | 'all'>('all');
- const [filterEvent, setFilterEvent] = useState<WebhookEvent | 'all'>('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<number, WebhookDelivery>();
- 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<number, string>();
- 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 <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={actionLoading}
- 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 />} 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 size="small" icon={<KeyOutlined />} />
- </Popconfirm>
- <Popconfirm title={t('webhooks.deleteConfirm')} onConfirm={() => void deleteWebhook(webhook)}>
- <Button size="small" danger icon={<DeleteOutlined />} />
- </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.name'),
- dataIndex: 'webhookId',
- render: (id: number) => webhookNameById.get(id) || `#${id}`
- },
- {
- 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'}
- 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">
- {!scoped ? (
- <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={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>
- )}
- {!scoped ? (
- <Alert type="info" showIcon message={t('webhooks.domainOverrideHelp')} />
- ) : null}
- <SectionCard
- title={t('webhooks.listTitle')}
- extra={<StatusPill tone="neutral">{webhooks.length}</StatusPill>}
- >
- {webhooks.length ? (
- <Table
- rowKey="id"
- columns={endpointColumns}
- dataSource={webhooks}
- loading={loading}
- scroll={{ x: 1200 }}
- pagination={{ pageSize: 10 }}
- />
- ) : (
- <EmptyState
- description={t('webhooks.empty')}
- action={
- <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
- {t('webhooks.create')}
- </Button>
- }
- />
- )}
- </SectionCard>
- <SectionCard title={t('webhooks.deliveriesTitle')}>
- <Space wrap className="full-width" style={{ marginBottom: 16 }}>
- <Select
- style={{ minWidth: 180 }}
- value={filterWebhookId}
- onChange={setFilterWebhookId}
- options={[
- { value: 'all', label: t('webhooks.allWebhooks') },
- ...webhooks.map((w) => ({ value: w.id, label: w.name }))
- ]}
- placeholder={t('webhooks.deliveriesFilterWebhook')}
- />
- <Select
- style={{ minWidth: 140 }}
- value={filterStatus}
- onChange={setFilterStatus}
- options={[
- { value: 'all', label: t('webhooks.allStatuses') },
- ...DELIVERY_STATUSES.map((status) => ({
- value: status,
- label: deliveryStatusLabel(status)
- }))
- ]}
- placeholder={t('webhooks.deliveriesFilterStatus')}
- />
- <Select
- style={{ minWidth: 140 }}
- value={filterEvent}
- onChange={setFilterEvent}
- options={[
- { value: 'all', label: t('webhooks.allEvents') },
- ...ALL_EVENTS.map((event) => ({ value: event, label: eventLabel(event) }))
- ]}
- placeholder={t('webhooks.deliveriesFilterEvent')}
- />
- </Space>
- {filteredDeliveries.length ? (
- <Table
- rowKey="id"
- columns={deliveryColumns}
- dataSource={filteredDeliveries}
- loading={loading}
- scroll={{ x: 1100 }}
- pagination={{ pageSize: 10 }}
- />
- ) : (
- <EmptyState description={t('webhooks.deliveriesEmpty')} />
- )}
- </SectionCard>
- {!scoped ? (
- <SectionCard title={t('webhooks.docsTitle')}>
- <Space direction="vertical" size={12} className="full-width">
- <Typography.Text>{t('webhooks.docsSignature')}</Typography.Text>
- <Typography.Text>{t('webhooks.docsEvents')}</Typography.Text>
- <Typography.Text type="secondary">{t('webhooks.urlHint')}</Typography.Text>
- <CodeBlock value={buildSignatureSample('whsec_your_secret')} onCopy={copyValue} />
- </Space>
- </SectionCard>
- ) : null}
- <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={actionLoading} onClick={() => void submitForm()}>
- {editing ? t('common.save') : t('webhooks.create')}
- </Button>
- </div>
- }
- >
- <Form form={form} layout="vertical" initialValues={{ enabled: true, events: ALL_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={ALL_EVENTS.map((event) => ({
- value: event,
- label: eventLabel(event)
- }))}
- />
- </Form.Item>
- {!scoped ? (
- <Form.Item name="domainId" label={t('webhooks.domain')} extra={t('webhooks.domainAccount')}>
- <Select
- allowClear
- placeholder={t('webhooks.domainAccount')}
- options={domains.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>
- <Modal
- title={
- secretReveal?.mode === 'rotated'
- ? t('webhooks.secretRotatedTitle')
- : t('webhooks.secretCreatedTitle')
- }
- open={Boolean(secretReveal)}
- onCancel={() => setSecretReveal(null)}
- footer={[
- <Button key="close" onClick={() => setSecretReveal(null)}>
- {t('common.cancel')}
- </Button>,
- <Button
- key="copy"
- type="primary"
- icon={<CopyOutlined />}
- onClick={() => void copyValue(secret)}
- >
- {t('webhooks.copySecret')}
- </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 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'));
- }`;
- }
|