Ver Fonte

feat(webhooks): add global webhooks management UI

Add Webhooks page for endpoints CRUD, secret reveal, deliveries,
replay/test actions, and wire it into the main app view.

AI-Co-Authored-By: Grok
chendeben há 1 mês atrás
pai
commit
0b9c6a3577
2 ficheiros alterados com 720 adições e 0 exclusões
  1. 4 0
      src/frontend/App.tsx
  2. 716 0
      src/pages/Webhooks.tsx

+ 4 - 0
src/frontend/App.tsx

@@ -13,6 +13,7 @@ import PlaceholderPage from '../pages/PlaceholderPage';
 import SendingLogs from '../pages/SendingLogs';
 import Settings from '../pages/Settings';
 import SmtpCredentials from '../pages/SmtpCredentials';
+import Webhooks from '../pages/Webhooks';
 import { I18nProvider, useI18n } from './i18n/react';
 import { buildDnsApplyFeedback } from './domain-model.js';
 import { api } from './services/api';
@@ -561,6 +562,9 @@ function MailHubConsole() {
     if (activeView === 'logs') {
       return <SendingLogs events={data.events} domains={data.domains} onCopy={copy} />;
     }
+    if (activeView === 'webhooks') {
+      return <Webhooks domains={data.domains} onCopy={copy} />;
+    }
     if (activeView === 'admin') {
       return <AdminPage me={data.me} />;
     }

+ 716 - 0
src/pages/Webhooks.tsx

@@ -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'));
+}`;
+}