Webhooks.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. import {
  2. CopyOutlined,
  3. DeleteOutlined,
  4. EditOutlined,
  5. KeyOutlined,
  6. PlusOutlined,
  7. ReloadOutlined,
  8. ThunderboltOutlined
  9. } from '@ant-design/icons';
  10. import {
  11. Alert,
  12. App as AntApp,
  13. Button,
  14. Checkbox,
  15. Drawer,
  16. Form,
  17. Input,
  18. Modal,
  19. Popconfirm,
  20. Select,
  21. Space,
  22. Switch,
  23. Table,
  24. Tag,
  25. Typography
  26. } from 'antd';
  27. import type { ColumnsType } from 'antd/es/table';
  28. import { useCallback, useEffect, useMemo, useState } from 'react';
  29. import { CodeBlock } from '../components/common/CodeBlock';
  30. import { EmptyState } from '../components/common/EmptyState';
  31. import { PageHeader } from '../components/common/PageHeader';
  32. import { SectionCard } from '../components/common/SectionCard';
  33. import { StatusPill } from '../components/common/StatusPill';
  34. import type { StatusTone } from '../components/common/StatusPill';
  35. import { useI18n } from '../frontend/i18n/react';
  36. import { api } from '../frontend/services/api';
  37. import type {
  38. Domain,
  39. Webhook,
  40. WebhookDelivery,
  41. WebhookDeliveryStatus,
  42. WebhookEvent,
  43. WebhookPayload
  44. } from '../frontend/types';
  45. const ALL_EVENTS: WebhookEvent[] = ['sent', 'bounced', 'failed'];
  46. const DELIVERY_STATUSES: WebhookDeliveryStatus[] = ['pending', 'processing', 'success', 'dead'];
  47. interface WebhooksProps {
  48. /** When set, list/create are scoped to this domain (no global page chrome). */
  49. domainId?: number;
  50. domains?: Domain[];
  51. onCopy?: (value: string) => void;
  52. }
  53. interface WebhookFormValues {
  54. name: string;
  55. url: string;
  56. events: WebhookEvent[];
  57. domainId?: number | null;
  58. enabled: boolean;
  59. }
  60. interface SecretReveal {
  61. webhook: Webhook;
  62. mode: 'created' | 'rotated';
  63. }
  64. export default function Webhooks({ domainId, domains = [], onCopy }: WebhooksProps) {
  65. const { message } = AntApp.useApp();
  66. const { t } = useI18n();
  67. const [webhooks, setWebhooks] = useState<Webhook[]>([]);
  68. const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([]);
  69. const [loading, setLoading] = useState(false);
  70. const [actionLoading, setActionLoading] = useState(false);
  71. const [drawerOpen, setDrawerOpen] = useState(false);
  72. const [editing, setEditing] = useState<Webhook | null>(null);
  73. const [secretReveal, setSecretReveal] = useState<SecretReveal | null>(null);
  74. const [form] = Form.useForm<WebhookFormValues>();
  75. const [filterWebhookId, setFilterWebhookId] = useState<number | 'all'>('all');
  76. const [filterStatus, setFilterStatus] = useState<WebhookDeliveryStatus | 'all'>('all');
  77. const [filterEvent, setFilterEvent] = useState<WebhookEvent | 'all'>('all');
  78. const scoped = domainId != null;
  79. const domainMap = useMemo(() => new Map(domains.map((d) => [d.id, d.domain])), [domains]);
  80. const loadData = useCallback(async () => {
  81. setLoading(true);
  82. try {
  83. const [webhooksResult, deliveriesResult] = await Promise.all([
  84. api.webhooks(scoped ? domainId : undefined),
  85. api.webhookDeliveries({ limit: 100 })
  86. ]);
  87. const nextWebhooks = webhooksResult.webhooks || [];
  88. setWebhooks(nextWebhooks);
  89. const webhookIds = new Set(nextWebhooks.map((w) => w.id));
  90. const nextDeliveries = (deliveriesResult.deliveries || []).filter((d) =>
  91. scoped ? webhookIds.has(d.webhookId) : true
  92. );
  93. setDeliveries(nextDeliveries);
  94. } catch (error) {
  95. message.error(error instanceof Error ? error.message : t('common.error'));
  96. } finally {
  97. setLoading(false);
  98. }
  99. }, [domainId, message, scoped, t]);
  100. useEffect(() => {
  101. void loadData();
  102. }, [loadData]);
  103. const lastDeliveryByWebhook = useMemo(() => {
  104. const map = new Map<number, WebhookDelivery>();
  105. for (const delivery of deliveries) {
  106. if (!map.has(delivery.webhookId)) map.set(delivery.webhookId, delivery);
  107. }
  108. return map;
  109. }, [deliveries]);
  110. const filteredDeliveries = useMemo(() => {
  111. return deliveries.filter((delivery) => {
  112. if (filterWebhookId !== 'all' && delivery.webhookId !== filterWebhookId) return false;
  113. if (filterStatus !== 'all' && delivery.status !== filterStatus) return false;
  114. if (filterEvent !== 'all' && delivery.eventType !== filterEvent) return false;
  115. return true;
  116. });
  117. }, [deliveries, filterEvent, filterStatus, filterWebhookId]);
  118. const webhookNameById = useMemo(() => {
  119. const map = new Map<number, string>();
  120. for (const webhook of webhooks) map.set(webhook.id, webhook.name);
  121. return map;
  122. }, [webhooks]);
  123. async function copyValue(value: string) {
  124. if (!value) return;
  125. if (onCopy) {
  126. onCopy(value);
  127. return;
  128. }
  129. await navigator.clipboard.writeText(value);
  130. message.success(t('common.copied'));
  131. }
  132. function openCreate() {
  133. setEditing(null);
  134. form.setFieldsValue({
  135. name: '',
  136. url: '',
  137. events: [...ALL_EVENTS],
  138. domainId: scoped ? domainId : undefined,
  139. enabled: true
  140. });
  141. setDrawerOpen(true);
  142. }
  143. function openEdit(webhook: Webhook) {
  144. setEditing(webhook);
  145. form.setFieldsValue({
  146. name: webhook.name,
  147. url: webhook.url,
  148. events: webhook.events?.length ? [...webhook.events] : [...ALL_EVENTS],
  149. domainId: webhook.domainId ?? undefined,
  150. enabled: webhook.enabled
  151. });
  152. setDrawerOpen(true);
  153. }
  154. function closeDrawer() {
  155. setDrawerOpen(false);
  156. setEditing(null);
  157. form.resetFields();
  158. }
  159. async function submitForm() {
  160. const values = await form.validateFields();
  161. setActionLoading(true);
  162. try {
  163. const payload: WebhookPayload = {
  164. name: values.name.trim(),
  165. url: values.url.trim(),
  166. events: values.events,
  167. domainId: scoped ? domainId : (values.domainId ?? null),
  168. enabled: values.enabled
  169. };
  170. if (editing) {
  171. await api.updateWebhook(editing.id, payload);
  172. message.success(t('actions.webhookUpdated'));
  173. closeDrawer();
  174. await loadData();
  175. } else {
  176. const result = await api.createWebhook(payload);
  177. message.success(t('actions.webhookCreated'));
  178. closeDrawer();
  179. if (result.webhook?.secret) {
  180. setSecretReveal({ webhook: result.webhook, mode: 'created' });
  181. }
  182. await loadData();
  183. }
  184. } catch (error) {
  185. message.error(error instanceof Error ? error.message : t('common.error'));
  186. } finally {
  187. setActionLoading(false);
  188. }
  189. }
  190. async function toggleEnabled(webhook: Webhook, enabled: boolean) {
  191. setActionLoading(true);
  192. try {
  193. await api.updateWebhook(webhook.id, { enabled });
  194. setWebhooks((current) =>
  195. current.map((item) => (item.id === webhook.id ? { ...item, enabled } : item))
  196. );
  197. } catch (error) {
  198. message.error(error instanceof Error ? error.message : t('common.error'));
  199. } finally {
  200. setActionLoading(false);
  201. }
  202. }
  203. async function deleteWebhook(webhook: Webhook) {
  204. setActionLoading(true);
  205. try {
  206. await api.deleteWebhook(webhook.id);
  207. message.success(t('actions.webhookDeleted'));
  208. if (filterWebhookId === webhook.id) setFilterWebhookId('all');
  209. await loadData();
  210. } catch (error) {
  211. message.error(error instanceof Error ? error.message : t('common.error'));
  212. } finally {
  213. setActionLoading(false);
  214. }
  215. }
  216. async function rotateSecret(webhook: Webhook) {
  217. setActionLoading(true);
  218. try {
  219. const result = await api.rotateWebhookSecret(webhook.id);
  220. message.success(t('actions.webhookSecretRotated'));
  221. if (result.webhook?.secret) {
  222. setSecretReveal({ webhook: result.webhook, mode: 'rotated' });
  223. }
  224. await loadData();
  225. } catch (error) {
  226. message.error(error instanceof Error ? error.message : t('common.error'));
  227. } finally {
  228. setActionLoading(false);
  229. }
  230. }
  231. async function testWebhook(webhook: Webhook) {
  232. setActionLoading(true);
  233. try {
  234. await api.testWebhook(webhook.id);
  235. message.success(t('actions.webhookTestQueued'));
  236. setFilterWebhookId(webhook.id);
  237. await loadData();
  238. } catch (error) {
  239. message.error(error instanceof Error ? error.message : t('common.error'));
  240. } finally {
  241. setActionLoading(false);
  242. }
  243. }
  244. async function replayDelivery(delivery: WebhookDelivery) {
  245. setActionLoading(true);
  246. try {
  247. await api.replayWebhookDelivery(delivery.id);
  248. message.success(t('actions.webhookDeliveryReplayed'));
  249. await loadData();
  250. } catch (error) {
  251. message.error(error instanceof Error ? error.message : t('common.error'));
  252. } finally {
  253. setActionLoading(false);
  254. }
  255. }
  256. function viewDeliveries(webhook: Webhook) {
  257. setFilterWebhookId(webhook.id);
  258. setFilterStatus('all');
  259. setFilterEvent('all');
  260. }
  261. function scopeLabel(webhook: Webhook) {
  262. if (webhook.domainId == null) return t('webhooks.scopeAccount');
  263. const name = domainMap.get(webhook.domainId);
  264. return name ? `${t('webhooks.scopeDomain')} · ${name}` : t('webhooks.scopeDomain');
  265. }
  266. function eventLabel(event: string) {
  267. if (event === 'sent') return t('webhooks.eventSent');
  268. if (event === 'bounced') return t('webhooks.eventBounced');
  269. if (event === 'failed') return t('webhooks.eventFailed');
  270. return event;
  271. }
  272. function deliveryStatusLabel(status: string) {
  273. if (status === 'pending') return t('webhooks.statusPending');
  274. if (status === 'processing') return t('webhooks.statusProcessing');
  275. if (status === 'success') return t('webhooks.statusSuccess');
  276. if (status === 'dead') return t('webhooks.statusDead');
  277. return status;
  278. }
  279. function deliveryStatusTone(status: string): StatusTone {
  280. if (status === 'success') return 'success';
  281. if (status === 'pending') return 'info';
  282. if (status === 'processing') return 'warning';
  283. if (status === 'dead') return 'error';
  284. return 'neutral';
  285. }
  286. function lastDeliverySnippet(webhook: Webhook) {
  287. const delivery = lastDeliveryByWebhook.get(webhook.id);
  288. if (!delivery) return <Typography.Text type="secondary">—</Typography.Text>;
  289. const detail = delivery.error
  290. || (delivery.responseStatus != null ? `HTTP ${delivery.responseStatus}` : '')
  291. || deliveryStatusLabel(String(delivery.status));
  292. return (
  293. <Space size={6} wrap>
  294. <StatusPill tone={deliveryStatusTone(String(delivery.status))}>
  295. {deliveryStatusLabel(String(delivery.status))}
  296. </StatusPill>
  297. <Typography.Text type="secondary" ellipsis className="inline-code-value">
  298. {eventLabel(String(delivery.eventType))} · {detail}
  299. </Typography.Text>
  300. </Space>
  301. );
  302. }
  303. const endpointColumns: ColumnsType<Webhook> = [
  304. {
  305. title: t('webhooks.name'),
  306. dataIndex: 'name',
  307. render: (value: string, webhook) => (
  308. <Space direction="vertical" size={0}>
  309. <Typography.Text strong>{value}</Typography.Text>
  310. <Typography.Text type="secondary" code>
  311. {webhook.secretPrefix}…
  312. </Typography.Text>
  313. </Space>
  314. )
  315. },
  316. {
  317. title: t('webhooks.scope'),
  318. render: (_, webhook) => scopeLabel(webhook)
  319. },
  320. {
  321. title: t('webhooks.url'),
  322. dataIndex: 'url',
  323. ellipsis: true,
  324. render: (value: string) => (
  325. <Typography.Text code ellipsis title={value} className="inline-code-value">
  326. {truncateUrl(value)}
  327. </Typography.Text>
  328. )
  329. },
  330. {
  331. title: t('webhooks.events'),
  332. dataIndex: 'events',
  333. render: (events: WebhookEvent[]) => (
  334. <Space size={[4, 4]} wrap>
  335. {(events || []).map((event) => (
  336. <Tag key={event}>{eventLabel(event)}</Tag>
  337. ))}
  338. </Space>
  339. )
  340. },
  341. {
  342. title: t('webhooks.enabled'),
  343. dataIndex: 'enabled',
  344. width: 100,
  345. render: (enabled: boolean, webhook) => (
  346. <Switch
  347. checked={enabled}
  348. loading={actionLoading}
  349. onChange={(checked) => void toggleEnabled(webhook, checked)}
  350. checkedChildren={t('webhooks.enabled')}
  351. unCheckedChildren={t('webhooks.disabled')}
  352. />
  353. )
  354. },
  355. {
  356. title: t('webhooks.lastAttemptAt'),
  357. render: (_, webhook) => lastDeliverySnippet(webhook)
  358. },
  359. {
  360. title: t('webhooks.actions'),
  361. fixed: 'right',
  362. width: 280,
  363. render: (_, webhook) => (
  364. <Space wrap size={4}>
  365. <Button size="small" icon={<ThunderboltOutlined />} onClick={() => void testWebhook(webhook)}>
  366. {t('webhooks.test')}
  367. </Button>
  368. <Button size="small" onClick={() => viewDeliveries(webhook)}>
  369. {t('webhooks.viewDeliveries')}
  370. </Button>
  371. <Button size="small" icon={<EditOutlined />} onClick={() => openEdit(webhook)} />
  372. <Popconfirm title={t('webhooks.rotateConfirm')} onConfirm={() => void rotateSecret(webhook)}>
  373. <Button size="small" icon={<KeyOutlined />} />
  374. </Popconfirm>
  375. <Popconfirm title={t('webhooks.deleteConfirm')} onConfirm={() => void deleteWebhook(webhook)}>
  376. <Button size="small" danger icon={<DeleteOutlined />} />
  377. </Popconfirm>
  378. </Space>
  379. )
  380. }
  381. ];
  382. const deliveryColumns: ColumnsType<WebhookDelivery> = [
  383. {
  384. title: t('webhooks.createdAt'),
  385. dataIndex: 'createdAt',
  386. width: 170,
  387. render: (value: string) => (value ? new Date(value).toLocaleString() : '—')
  388. },
  389. {
  390. title: t('webhooks.name'),
  391. dataIndex: 'webhookId',
  392. render: (id: number) => webhookNameById.get(id) || `#${id}`
  393. },
  394. {
  395. title: t('webhooks.events'),
  396. dataIndex: 'eventType',
  397. render: (value: string) => <Tag>{eventLabel(value)}</Tag>
  398. },
  399. {
  400. title: t('common.status'),
  401. dataIndex: 'status',
  402. render: (value: string) => (
  403. <StatusPill tone={deliveryStatusTone(value)}>{deliveryStatusLabel(value)}</StatusPill>
  404. )
  405. },
  406. {
  407. title: t('webhooks.attemptCount'),
  408. dataIndex: 'attemptCount',
  409. width: 90
  410. },
  411. {
  412. title: t('webhooks.responseStatus'),
  413. dataIndex: 'responseStatus',
  414. width: 100,
  415. render: (value: number | null | undefined) => (value != null ? value : '—')
  416. },
  417. {
  418. title: t('webhooks.error'),
  419. dataIndex: 'error',
  420. ellipsis: true,
  421. render: (value: string, row) => value || row.responseBodyPreview || '—'
  422. },
  423. {
  424. title: t('webhooks.lastAttemptAt'),
  425. dataIndex: 'lastAttemptAt',
  426. width: 170,
  427. render: (value?: string | null) => (value ? new Date(value).toLocaleString() : '—')
  428. },
  429. {
  430. title: t('webhooks.actions'),
  431. fixed: 'right',
  432. width: 110,
  433. render: (_, delivery) => (
  434. <Button
  435. size="small"
  436. disabled={delivery.status === 'processing'}
  437. onClick={() => void replayDelivery(delivery)}
  438. >
  439. {t('webhooks.replay')}
  440. </Button>
  441. )
  442. }
  443. ];
  444. const secret = secretReveal?.webhook.secret || '';
  445. const sampleVerifier = buildSignatureSample(secret || 'whsec_your_secret');
  446. return (
  447. <Space direction="vertical" size={20} className="full-width">
  448. {!scoped ? (
  449. <PageHeader
  450. title={t('webhooks.title')}
  451. subtitle={t('webhooks.subtitle')}
  452. extra={
  453. <Space>
  454. <Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
  455. {t('common.refresh')}
  456. </Button>
  457. <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
  458. {t('webhooks.create')}
  459. </Button>
  460. </Space>
  461. }
  462. />
  463. ) : (
  464. <Space direction="vertical" size={12} className="full-width">
  465. <Alert type="info" showIcon message={t('webhooks.domainOverrideHelp')} />
  466. <Space>
  467. <Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
  468. {t('common.refresh')}
  469. </Button>
  470. <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
  471. {t('webhooks.create')}
  472. </Button>
  473. </Space>
  474. </Space>
  475. )}
  476. {!scoped ? (
  477. <Alert type="info" showIcon message={t('webhooks.domainOverrideHelp')} />
  478. ) : null}
  479. <SectionCard
  480. title={t('webhooks.listTitle')}
  481. extra={<StatusPill tone="neutral">{webhooks.length}</StatusPill>}
  482. >
  483. {webhooks.length ? (
  484. <Table
  485. rowKey="id"
  486. columns={endpointColumns}
  487. dataSource={webhooks}
  488. loading={loading}
  489. scroll={{ x: 1200 }}
  490. pagination={{ pageSize: 10 }}
  491. />
  492. ) : (
  493. <EmptyState
  494. description={t('webhooks.empty')}
  495. action={
  496. <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
  497. {t('webhooks.create')}
  498. </Button>
  499. }
  500. />
  501. )}
  502. </SectionCard>
  503. <SectionCard title={t('webhooks.deliveriesTitle')}>
  504. <Space wrap className="full-width" style={{ marginBottom: 16 }}>
  505. <Select
  506. style={{ minWidth: 180 }}
  507. value={filterWebhookId}
  508. onChange={setFilterWebhookId}
  509. options={[
  510. { value: 'all', label: t('webhooks.allWebhooks') },
  511. ...webhooks.map((w) => ({ value: w.id, label: w.name }))
  512. ]}
  513. placeholder={t('webhooks.deliveriesFilterWebhook')}
  514. />
  515. <Select
  516. style={{ minWidth: 140 }}
  517. value={filterStatus}
  518. onChange={setFilterStatus}
  519. options={[
  520. { value: 'all', label: t('webhooks.allStatuses') },
  521. ...DELIVERY_STATUSES.map((status) => ({
  522. value: status,
  523. label: deliveryStatusLabel(status)
  524. }))
  525. ]}
  526. placeholder={t('webhooks.deliveriesFilterStatus')}
  527. />
  528. <Select
  529. style={{ minWidth: 140 }}
  530. value={filterEvent}
  531. onChange={setFilterEvent}
  532. options={[
  533. { value: 'all', label: t('webhooks.allEvents') },
  534. ...ALL_EVENTS.map((event) => ({ value: event, label: eventLabel(event) }))
  535. ]}
  536. placeholder={t('webhooks.deliveriesFilterEvent')}
  537. />
  538. </Space>
  539. {filteredDeliveries.length ? (
  540. <Table
  541. rowKey="id"
  542. columns={deliveryColumns}
  543. dataSource={filteredDeliveries}
  544. loading={loading}
  545. scroll={{ x: 1100 }}
  546. pagination={{ pageSize: 10 }}
  547. />
  548. ) : (
  549. <EmptyState description={t('webhooks.deliveriesEmpty')} />
  550. )}
  551. </SectionCard>
  552. {!scoped ? (
  553. <SectionCard title={t('webhooks.docsTitle')}>
  554. <Space direction="vertical" size={12} className="full-width">
  555. <Typography.Text>{t('webhooks.docsSignature')}</Typography.Text>
  556. <Typography.Text>{t('webhooks.docsEvents')}</Typography.Text>
  557. <Typography.Text type="secondary">{t('webhooks.urlHint')}</Typography.Text>
  558. <CodeBlock value={buildSignatureSample('whsec_your_secret')} onCopy={copyValue} />
  559. </Space>
  560. </SectionCard>
  561. ) : null}
  562. <Drawer
  563. title={editing ? t('webhooks.editTitle') : t('webhooks.createTitle')}
  564. width={520}
  565. open={drawerOpen}
  566. onClose={closeDrawer}
  567. destroyOnHidden
  568. footer={
  569. <div className="drawer-footer">
  570. <Button onClick={closeDrawer}>{t('common.cancel')}</Button>
  571. <Button type="primary" loading={actionLoading} onClick={() => void submitForm()}>
  572. {editing ? t('common.save') : t('webhooks.create')}
  573. </Button>
  574. </div>
  575. }
  576. >
  577. <Form form={form} layout="vertical" initialValues={{ enabled: true, events: ALL_EVENTS }}>
  578. <Form.Item
  579. name="name"
  580. label={t('webhooks.name')}
  581. rules={[{ required: true, message: t('webhooks.nameRequired') }]}
  582. >
  583. <Input placeholder={t('webhooks.namePlaceholder')} />
  584. </Form.Item>
  585. <Form.Item
  586. name="url"
  587. label={t('webhooks.url')}
  588. extra={t('webhooks.urlHint')}
  589. rules={[{ required: true, message: t('webhooks.urlRequired') }]}
  590. >
  591. <Input placeholder={t('webhooks.urlPlaceholder')} />
  592. </Form.Item>
  593. <Form.Item
  594. name="events"
  595. label={t('webhooks.events')}
  596. rules={[{ required: true, type: 'array', min: 1, message: t('webhooks.eventsRequired') }]}
  597. >
  598. <Checkbox.Group
  599. options={ALL_EVENTS.map((event) => ({
  600. value: event,
  601. label: eventLabel(event)
  602. }))}
  603. />
  604. </Form.Item>
  605. {!scoped ? (
  606. <Form.Item name="domainId" label={t('webhooks.domain')} extra={t('webhooks.domainAccount')}>
  607. <Select
  608. allowClear
  609. placeholder={t('webhooks.domainAccount')}
  610. options={domains.map((domain) => ({
  611. value: domain.id,
  612. label: domain.domain
  613. }))}
  614. />
  615. </Form.Item>
  616. ) : null}
  617. <Form.Item name="enabled" label={t('webhooks.enabled')} valuePropName="checked">
  618. <Switch />
  619. </Form.Item>
  620. </Form>
  621. </Drawer>
  622. <Modal
  623. title={
  624. secretReveal?.mode === 'rotated'
  625. ? t('webhooks.secretRotatedTitle')
  626. : t('webhooks.secretCreatedTitle')
  627. }
  628. open={Boolean(secretReveal)}
  629. onCancel={() => setSecretReveal(null)}
  630. footer={[
  631. <Button key="close" onClick={() => setSecretReveal(null)}>
  632. {t('common.cancel')}
  633. </Button>,
  634. <Button
  635. key="copy"
  636. type="primary"
  637. icon={<CopyOutlined />}
  638. onClick={() => void copyValue(secret)}
  639. >
  640. {t('webhooks.copySecret')}
  641. </Button>
  642. ]}
  643. >
  644. <Space direction="vertical" size={16} className="full-width">
  645. <Alert type="error" showIcon message={t('webhooks.secretCreatedWarning')} />
  646. <div>
  647. <Typography.Text type="secondary">{t('webhooks.secret')}</Typography.Text>
  648. <CodeBlock value={secret} onCopy={copyValue} />
  649. </div>
  650. <div>
  651. <Typography.Text type="secondary">{t('webhooks.docsSignature')}</Typography.Text>
  652. <CodeBlock value={sampleVerifier} onCopy={copyValue} />
  653. </div>
  654. </Space>
  655. </Modal>
  656. </Space>
  657. );
  658. }
  659. function truncateUrl(url: string, max = 48) {
  660. if (!url) return '';
  661. if (url.length <= max) return url;
  662. return `${url.slice(0, max - 1)}…`;
  663. }
  664. function buildSignatureSample(secret: string) {
  665. return `// Verify X-MailHub-Signature (Node.js)
  666. const crypto = require('crypto');
  667. function verify(rawBody, signatureHeader, secret = ${JSON.stringify(secret)}) {
  668. const parts = Object.fromEntries(
  669. signatureHeader.split(',').map((p) => p.trim().split('='))
  670. );
  671. const signed = \`\${parts.t}.\${rawBody}\`;
  672. const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
  673. return crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
  674. }`;
  675. }