|
|
@@ -0,0 +1,716 @@
|
|
|
+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'));
|
|
|
+}`;
|
|
|
+}
|