Webhooks.tsx 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871
  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. Descriptions,
  16. Drawer,
  17. Form,
  18. Input,
  19. Modal,
  20. Popconfirm,
  21. Select,
  22. Skeleton,
  23. Space,
  24. Switch,
  25. Table,
  26. Tag,
  27. Typography
  28. } from 'antd';
  29. import type { ColumnsType } from 'antd/es/table';
  30. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  31. import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
  32. import { CodeBlock } from '../components/common/CodeBlock';
  33. import { EmptyState } from '../components/common/EmptyState';
  34. import { PageHeader } from '../components/common/PageHeader';
  35. import { SectionCard } from '../components/common/SectionCard';
  36. import { StatusPill } from '../components/common/StatusPill';
  37. import type { StatusTone } from '../components/common/StatusPill';
  38. import { useI18n } from '../frontend/i18n/react';
  39. import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
  40. import { api } from '../frontend/services/api';
  41. import type {
  42. Domain,
  43. InboundMailbox,
  44. Webhook,
  45. WebhookDelivery,
  46. WebhookDeliveryStatus,
  47. WebhookEvent,
  48. WebhookPayload
  49. } from '../frontend/types';
  50. const DELIVERY_EVENTS: WebhookEvent[] = ['sent', 'bounced', 'failed', 'opened', 'clicked'];
  51. const ALL_EVENTS: WebhookEvent[] = [...DELIVERY_EVENTS, 'received'];
  52. const DELIVERY_STATUSES: WebhookDeliveryStatus[] = ['pending', 'processing', 'success', 'dead'];
  53. interface WebhooksProps {
  54. /** When set, list/create are scoped to this domain (no global page chrome). */
  55. domainId?: number;
  56. /** When set, only receipt callbacks for this mailbox are shown. */
  57. mailboxId?: number;
  58. domains?: Domain[];
  59. mailboxes?: InboundMailbox[];
  60. onCopy?: (value: string) => void;
  61. }
  62. interface WebhookFormValues {
  63. name: string;
  64. url: string;
  65. events: WebhookEvent[];
  66. domainId?: number | null;
  67. enabled: boolean;
  68. }
  69. interface SecretReveal {
  70. webhook: Webhook;
  71. mode: 'created' | 'rotated';
  72. }
  73. export default function Webhooks({ domainId: domainIdProp, mailboxId: mailboxIdProp, domains = [], mailboxes = [], onCopy }: WebhooksProps) {
  74. const { message } = AntApp.useApp();
  75. const { t } = useI18n();
  76. const location = useLocation();
  77. const navigate = useNavigate();
  78. const [searchParams, setSearchParams] = useSearchParams();
  79. const queryDomainId = Number(searchParams.get('domainId') || 0) || undefined;
  80. const queryMailboxId = Number(searchParams.get('mailboxId') || 0) || undefined;
  81. const requestedWebhookId = positiveInteger(searchParams.get('webhookId'));
  82. const deliveryStatus = deliveryStatusFromParam(searchParams.get('deliveryStatus'));
  83. const deliveryEvent = deliveryEventFromParam(searchParams.get('deliveryEvent'));
  84. const domainId = domainIdProp ?? queryDomainId;
  85. const mailboxId = mailboxIdProp ?? queryMailboxId;
  86. const embedded = domainIdProp != null || mailboxIdProp != null;
  87. const [webhooks, setWebhooks] = useState<Webhook[]>([]);
  88. const [recentDeliveries, setRecentDeliveries] = useState<WebhookDelivery[]>([]);
  89. const [detailDeliveries, setDetailDeliveries] = useState<WebhookDelivery[]>([]);
  90. const [detailLoading, setDetailLoading] = useState(false);
  91. const [detailError, setDetailError] = useState('');
  92. const [availableDomains, setAvailableDomains] = useState<Domain[]>(domains);
  93. const [availableMailboxes, setAvailableMailboxes] = useState<InboundMailbox[]>(mailboxes);
  94. const [loading, setLoading] = useState(true);
  95. const [loadError, setLoadError] = useState('');
  96. const [resourceError, setResourceError] = useState('');
  97. const [recentDeliveriesError, setRecentDeliveriesError] = useState('');
  98. const [actionKey, setActionKey] = useState('');
  99. const [drawerOpen, setDrawerOpen] = useState(false);
  100. const [editing, setEditing] = useState<Webhook | null>(null);
  101. const [secretReveal, setSecretReveal] = useState<SecretReveal | null>(null);
  102. const [form] = Form.useForm<WebhookFormValues>();
  103. const loadRequestId = useRef(0);
  104. const detailRequestId = useRef(0);
  105. const mailboxScoped = mailboxId != null;
  106. const scoped = domainId != null || mailboxScoped;
  107. const selectableEvents: WebhookEvent[] = mailboxScoped ? ['received'] : DELIVERY_EVENTS;
  108. const domainMap = useMemo(() => new Map(availableDomains.map((d) => [d.id, d.domain])), [availableDomains]);
  109. const mailboxMap = useMemo(() => new Map(availableMailboxes.map((m) => [m.id, m.address])), [availableMailboxes]);
  110. const selectedWebhook = useMemo(
  111. () => requestedWebhookId ? webhooks.find((webhook) => webhook.id === requestedWebhookId) || null : null,
  112. [requestedWebhookId, webhooks]
  113. );
  114. const loadData = useCallback(async () => {
  115. const requestId = ++loadRequestId.current;
  116. setLoading(true);
  117. setLoadError('');
  118. setResourceError('');
  119. setRecentDeliveriesError('');
  120. const shouldLoadResources = !domains.length || !mailboxes.length;
  121. const [webhooksResult, deliveriesResult, domainsResult, mailboxesResult] = await Promise.allSettled([
  122. api.webhooks(mailboxScoped ? undefined : (domainId != null ? domainId : undefined), mailboxId),
  123. api.webhookDeliveries({ limit: 100 }),
  124. shouldLoadResources ? api.domains() : Promise.resolve(null),
  125. shouldLoadResources ? api.inboundMailboxes() : Promise.resolve(null)
  126. ]);
  127. if (requestId !== loadRequestId.current) return;
  128. let nextWebhooks: Webhook[] = [];
  129. if (webhooksResult.status === 'fulfilled') {
  130. nextWebhooks = (webhooksResult.value.webhooks || []).filter((webhook) => scoped || webhook.mailboxId == null);
  131. setWebhooks(nextWebhooks);
  132. } else {
  133. setLoadError(webhooksResult.reason instanceof Error ? webhooksResult.reason.message : t('common.error'));
  134. }
  135. if (deliveriesResult.status === 'fulfilled') {
  136. const webhookIds = new Set(nextWebhooks.map((w) => w.id));
  137. const nextDeliveries = (deliveriesResult.value.deliveries || []).filter((d) =>
  138. scoped ? webhookIds.has(d.webhookId) : true
  139. );
  140. setRecentDeliveries(nextDeliveries);
  141. } else {
  142. setRecentDeliveriesError(deliveriesResult.reason instanceof Error ? deliveriesResult.reason.message : t('common.error'));
  143. }
  144. const resourceFailures: string[] = [];
  145. if (domainsResult.status === 'fulfilled') {
  146. if (domainsResult.value) setAvailableDomains(domainsResult.value.domains || []);
  147. } else {
  148. resourceFailures.push(domainsResult.reason instanceof Error ? domainsResult.reason.message : t('common.error'));
  149. }
  150. if (mailboxesResult.status === 'fulfilled') {
  151. if (mailboxesResult.value) setAvailableMailboxes(mailboxesResult.value.mailboxes || []);
  152. } else {
  153. resourceFailures.push(mailboxesResult.reason instanceof Error ? mailboxesResult.reason.message : t('common.error'));
  154. }
  155. setResourceError(resourceFailures.join(';'));
  156. setLoading(false);
  157. }, [domainId, domains.length, mailboxId, mailboxScoped, mailboxes.length, scoped, t]);
  158. useEffect(() => {
  159. if (domains.length) setAvailableDomains(domains);
  160. }, [domains]);
  161. useEffect(() => {
  162. if (mailboxes.length) setAvailableMailboxes(mailboxes);
  163. }, [mailboxes]);
  164. useEffect(() => {
  165. void loadData();
  166. return () => { loadRequestId.current += 1; };
  167. }, [loadData]);
  168. const lastDeliveryByWebhook = useMemo(() => {
  169. const map = new Map<number, WebhookDelivery>();
  170. for (const delivery of recentDeliveries) {
  171. if (!map.has(delivery.webhookId)) map.set(delivery.webhookId, delivery);
  172. }
  173. return map;
  174. }, [recentDeliveries]);
  175. const loadDetailDeliveries = useCallback(async () => {
  176. const requestId = ++detailRequestId.current;
  177. if (!requestedWebhookId) {
  178. setDetailDeliveries([]);
  179. setDetailError('');
  180. setDetailLoading(false);
  181. return;
  182. }
  183. setDetailDeliveries([]);
  184. setDetailError('');
  185. setDetailLoading(true);
  186. try {
  187. const result = await api.webhookDeliveries({
  188. webhookId: requestedWebhookId,
  189. status: deliveryStatus === 'all' ? undefined : deliveryStatus,
  190. eventType: deliveryEvent === 'all' ? undefined : deliveryEvent,
  191. limit: 200
  192. });
  193. if (requestId === detailRequestId.current) {
  194. setDetailDeliveries(result.deliveries || []);
  195. }
  196. } catch (error) {
  197. if (requestId === detailRequestId.current) {
  198. setDetailError(error instanceof Error ? error.message : t('common.error'));
  199. }
  200. } finally {
  201. if (requestId === detailRequestId.current) setDetailLoading(false);
  202. }
  203. }, [deliveryEvent, deliveryStatus, requestedWebhookId, t]);
  204. useEffect(() => {
  205. void loadDetailDeliveries();
  206. }, [loadDetailDeliveries]);
  207. async function copyValue(value: string) {
  208. if (!value) return;
  209. if (onCopy) {
  210. onCopy(value);
  211. return;
  212. }
  213. await navigator.clipboard.writeText(value);
  214. message.success(t('common.copied'));
  215. }
  216. function openCreate() {
  217. setEditing(null);
  218. form.setFieldsValue({
  219. name: '',
  220. url: '',
  221. events: [...selectableEvents],
  222. domainId: mailboxScoped ? undefined : (domainId != null ? domainId : undefined),
  223. enabled: true
  224. });
  225. setDrawerOpen(true);
  226. }
  227. function openEdit(webhook: Webhook) {
  228. setEditing(webhook);
  229. form.setFieldsValue({
  230. name: webhook.name,
  231. url: webhook.url,
  232. events: webhook.events?.length ? [...webhook.events] : [...selectableEvents],
  233. domainId: webhook.domainId ?? undefined,
  234. enabled: webhook.enabled
  235. });
  236. setDrawerOpen(true);
  237. }
  238. function closeDrawer() {
  239. setDrawerOpen(false);
  240. setEditing(null);
  241. form.resetFields();
  242. }
  243. async function submitForm() {
  244. const values = await form.validateFields();
  245. const key = editing ? `save:${editing.id}` : 'create';
  246. setActionKey(key);
  247. try {
  248. const payload: WebhookPayload = {
  249. name: values.name.trim(),
  250. url: values.url.trim(),
  251. events: values.events,
  252. domainId: mailboxScoped ? null : (domainId != null ? domainId : (values.domainId ?? null)),
  253. mailboxId: mailboxScoped ? mailboxId : null,
  254. enabled: values.enabled
  255. };
  256. if (editing) {
  257. await api.updateWebhook(editing.id, payload);
  258. message.success(t('actions.webhookUpdated'));
  259. closeDrawer();
  260. await loadData();
  261. } else {
  262. const result = await api.createWebhook(payload);
  263. message.success(t('actions.webhookCreated'));
  264. closeDrawer();
  265. if (result.webhook?.secret) {
  266. setSecretReveal({ webhook: result.webhook, mode: 'created' });
  267. }
  268. await loadData();
  269. }
  270. } catch (error) {
  271. message.error(error instanceof Error ? error.message : t('common.error'));
  272. } finally {
  273. setActionKey('');
  274. }
  275. }
  276. async function toggleEnabled(webhook: Webhook, enabled: boolean) {
  277. setActionKey(`toggle:${webhook.id}`);
  278. try {
  279. await api.updateWebhook(webhook.id, { enabled });
  280. setWebhooks((current) =>
  281. current.map((item) => (item.id === webhook.id ? { ...item, enabled } : item))
  282. );
  283. } catch (error) {
  284. message.error(error instanceof Error ? error.message : t('common.error'));
  285. } finally {
  286. setActionKey('');
  287. }
  288. }
  289. async function deleteWebhook(webhook: Webhook) {
  290. setActionKey(`delete:${webhook.id}`);
  291. try {
  292. await api.deleteWebhook(webhook.id);
  293. message.success(t('actions.webhookDeleted'));
  294. if (requestedWebhookId === webhook.id) closeDeliveries();
  295. await loadData();
  296. } catch (error) {
  297. message.error(error instanceof Error ? error.message : t('common.error'));
  298. } finally {
  299. setActionKey('');
  300. }
  301. }
  302. async function rotateSecret(webhook: Webhook) {
  303. setActionKey(`rotate:${webhook.id}`);
  304. try {
  305. const result = await api.rotateWebhookSecret(webhook.id);
  306. message.success(t('actions.webhookSecretRotated'));
  307. if (result.webhook?.secret) {
  308. setSecretReveal({ webhook: result.webhook, mode: 'rotated' });
  309. }
  310. await loadData();
  311. } catch (error) {
  312. message.error(error instanceof Error ? error.message : t('common.error'));
  313. } finally {
  314. setActionKey('');
  315. }
  316. }
  317. async function testWebhook(webhook: Webhook) {
  318. setActionKey(`test:${webhook.id}`);
  319. try {
  320. await api.testWebhook(webhook.id);
  321. message.success(t('actions.webhookTestQueued'));
  322. await loadData();
  323. viewDeliveries(webhook);
  324. } catch (error) {
  325. message.error(error instanceof Error ? error.message : t('common.error'));
  326. } finally {
  327. setActionKey('');
  328. }
  329. }
  330. async function replayDelivery(delivery: WebhookDelivery) {
  331. setActionKey(`replay:${delivery.id}`);
  332. try {
  333. await api.replayWebhookDelivery(delivery.id);
  334. message.success(t('actions.webhookDeliveryReplayed'));
  335. await Promise.all([loadData(), loadDetailDeliveries()]);
  336. } catch (error) {
  337. message.error(error instanceof Error ? error.message : t('common.error'));
  338. } finally {
  339. setActionKey('');
  340. }
  341. }
  342. function viewDeliveries(webhook: Webhook) {
  343. const next = new URLSearchParams(searchParams);
  344. next.set('webhookId', String(webhook.id));
  345. next.delete('deliveryStatus');
  346. next.delete('deliveryEvent');
  347. setSearchParams(next, {
  348. state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
  349. });
  350. }
  351. function closeDeliveries() {
  352. const historyState = detailHistoryState(location.state);
  353. if (historyState) {
  354. navigate(-historyState.depth);
  355. return;
  356. }
  357. const next = new URLSearchParams(searchParams);
  358. next.delete('webhookId');
  359. next.delete('deliveryStatus');
  360. next.delete('deliveryEvent');
  361. setSearchParams(next, { replace: true, state: null });
  362. }
  363. function updateDeliveryFilter(key: 'deliveryStatus' | 'deliveryEvent', value: string) {
  364. const next = new URLSearchParams(searchParams);
  365. if (value === 'all') next.delete(key);
  366. else next.set(key, value);
  367. const historyState = detailHistoryState(location.state);
  368. if (!historyState) {
  369. setSearchParams(next, { replace: true });
  370. return;
  371. }
  372. const search = next.toString();
  373. navigate(
  374. { pathname: location.pathname, search: search ? `?${search}` : '' },
  375. { state: detailHistoryLocation(historyState.listPath, historyState.depth + 1) }
  376. );
  377. }
  378. function scopeLabel(webhook: Webhook) {
  379. if (webhook.mailboxId != null) {
  380. const address = mailboxMap.get(webhook.mailboxId);
  381. return address ? `${t('webhooks.scopeMailbox')} · ${address}` : t('webhooks.scopeMailbox');
  382. }
  383. if (webhook.domainId == null) return t('webhooks.scopeAccount');
  384. const name = domainMap.get(webhook.domainId);
  385. return name ? `${t('webhooks.scopeDomain')} · ${name}` : t('webhooks.scopeDomain');
  386. }
  387. function eventLabel(event: string) {
  388. if (event === 'sent') return t('webhooks.eventSent');
  389. if (event === 'bounced') return t('webhooks.eventBounced');
  390. if (event === 'failed') return t('webhooks.eventFailed');
  391. if (event === 'opened') return t('webhooks.eventOpened');
  392. if (event === 'clicked') return t('webhooks.eventClicked');
  393. if (event === 'received') return t('webhooks.eventReceived');
  394. return event;
  395. }
  396. function deliveryStatusLabel(status: string) {
  397. if (status === 'pending') return t('webhooks.statusPending');
  398. if (status === 'processing') return t('webhooks.statusProcessing');
  399. if (status === 'success') return t('webhooks.statusSuccess');
  400. if (status === 'dead') return t('webhooks.statusDead');
  401. return status;
  402. }
  403. function deliveryStatusTone(status: string): StatusTone {
  404. if (status === 'success') return 'success';
  405. if (status === 'pending') return 'info';
  406. if (status === 'processing') return 'warning';
  407. if (status === 'dead') return 'error';
  408. return 'neutral';
  409. }
  410. function lastDeliverySnippet(webhook: Webhook) {
  411. const delivery = lastDeliveryByWebhook.get(webhook.id);
  412. if (!delivery) return <Typography.Text type="secondary">—</Typography.Text>;
  413. const detail = delivery.error
  414. || (delivery.responseStatus != null ? `HTTP ${delivery.responseStatus}` : '')
  415. || deliveryStatusLabel(String(delivery.status));
  416. return (
  417. <Space size={6} wrap>
  418. <StatusPill tone={deliveryStatusTone(String(delivery.status))}>
  419. {deliveryStatusLabel(String(delivery.status))}
  420. </StatusPill>
  421. <Typography.Text type="secondary" ellipsis className="inline-code-value">
  422. {eventLabel(String(delivery.eventType))} · {detail}
  423. </Typography.Text>
  424. </Space>
  425. );
  426. }
  427. const endpointColumns: ColumnsType<Webhook> = [
  428. {
  429. title: t('webhooks.name'),
  430. dataIndex: 'name',
  431. render: (value: string, webhook) => (
  432. <Space direction="vertical" size={0}>
  433. <Typography.Text strong>{value}</Typography.Text>
  434. <Typography.Text type="secondary" code>
  435. {webhook.secretPrefix}…
  436. </Typography.Text>
  437. </Space>
  438. )
  439. },
  440. {
  441. title: t('webhooks.scope'),
  442. render: (_, webhook) => scopeLabel(webhook)
  443. },
  444. {
  445. title: t('webhooks.url'),
  446. dataIndex: 'url',
  447. ellipsis: true,
  448. render: (value: string) => (
  449. <Typography.Text code ellipsis title={value} className="inline-code-value">
  450. {truncateUrl(value)}
  451. </Typography.Text>
  452. )
  453. },
  454. {
  455. title: t('webhooks.events'),
  456. dataIndex: 'events',
  457. render: (events: WebhookEvent[]) => (
  458. <Space size={[4, 4]} wrap>
  459. {(events || []).map((event) => (
  460. <Tag key={event}>{eventLabel(event)}</Tag>
  461. ))}
  462. </Space>
  463. )
  464. },
  465. {
  466. title: t('webhooks.enabled'),
  467. dataIndex: 'enabled',
  468. width: 100,
  469. render: (enabled: boolean, webhook) => (
  470. <Switch
  471. checked={enabled}
  472. loading={actionKey === `toggle:${webhook.id}`}
  473. onChange={(checked) => void toggleEnabled(webhook, checked)}
  474. checkedChildren={t('webhooks.enabled')}
  475. unCheckedChildren={t('webhooks.disabled')}
  476. />
  477. )
  478. },
  479. {
  480. title: t('webhooks.lastAttemptAt'),
  481. render: (_, webhook) => lastDeliverySnippet(webhook)
  482. },
  483. {
  484. title: t('webhooks.actions'),
  485. fixed: 'right',
  486. width: 280,
  487. render: (_, webhook) => (
  488. <Space wrap size={4}>
  489. <Button size="small" icon={<ThunderboltOutlined />} loading={actionKey === `test:${webhook.id}`} onClick={() => void testWebhook(webhook)}>
  490. {t('webhooks.test')}
  491. </Button>
  492. <Button size="small" onClick={() => viewDeliveries(webhook)}>
  493. {t('webhooks.viewDeliveries')}
  494. </Button>
  495. <Button size="small" icon={<EditOutlined />} onClick={() => openEdit(webhook)} />
  496. <Popconfirm title={t('webhooks.rotateConfirm')} onConfirm={() => void rotateSecret(webhook)}>
  497. <Button aria-label={t('webhooks.rotateSecret')} size="small" icon={<KeyOutlined />} loading={actionKey === `rotate:${webhook.id}`} />
  498. </Popconfirm>
  499. <Popconfirm title={t('webhooks.deleteConfirm')} onConfirm={() => void deleteWebhook(webhook)}>
  500. <Button aria-label={t('common.delete')} size="small" danger icon={<DeleteOutlined />} loading={actionKey === `delete:${webhook.id}`} />
  501. </Popconfirm>
  502. </Space>
  503. )
  504. }
  505. ];
  506. const deliveryColumns: ColumnsType<WebhookDelivery> = [
  507. {
  508. title: t('webhooks.createdAt'),
  509. dataIndex: 'createdAt',
  510. width: 170,
  511. render: (value: string) => (value ? new Date(value).toLocaleString() : '—')
  512. },
  513. {
  514. title: t('webhooks.events'),
  515. dataIndex: 'eventType',
  516. render: (value: string) => <Tag>{eventLabel(value)}</Tag>
  517. },
  518. {
  519. title: t('common.status'),
  520. dataIndex: 'status',
  521. render: (value: string) => (
  522. <StatusPill tone={deliveryStatusTone(value)}>{deliveryStatusLabel(value)}</StatusPill>
  523. )
  524. },
  525. {
  526. title: t('webhooks.attemptCount'),
  527. dataIndex: 'attemptCount',
  528. width: 90
  529. },
  530. {
  531. title: t('webhooks.responseStatus'),
  532. dataIndex: 'responseStatus',
  533. width: 100,
  534. render: (value: number | null | undefined) => (value != null ? value : '—')
  535. },
  536. {
  537. title: t('webhooks.error'),
  538. dataIndex: 'error',
  539. ellipsis: true,
  540. render: (value: string, row) => value || row.responseBodyPreview || '—'
  541. },
  542. {
  543. title: t('webhooks.lastAttemptAt'),
  544. dataIndex: 'lastAttemptAt',
  545. width: 170,
  546. render: (value?: string | null) => (value ? new Date(value).toLocaleString() : '—')
  547. },
  548. {
  549. title: t('webhooks.actions'),
  550. fixed: 'right',
  551. width: 110,
  552. render: (_, delivery) => (
  553. <Button
  554. size="small"
  555. disabled={delivery.status === 'processing'}
  556. loading={actionKey === `replay:${delivery.id}`}
  557. onClick={() => void replayDelivery(delivery)}
  558. >
  559. {t('webhooks.replay')}
  560. </Button>
  561. )
  562. }
  563. ];
  564. const secret = secretReveal?.webhook.secret || '';
  565. const sampleVerifier = buildSignatureSample(secret || 'whsec_your_secret');
  566. return (
  567. <Space direction="vertical" size={20} className="full-width">
  568. {!embedded ? (
  569. <PageHeader
  570. title={t('webhooks.title')}
  571. subtitle={t('webhooks.subtitle')}
  572. extra={
  573. <Space>
  574. <Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
  575. {t('common.refresh')}
  576. </Button>
  577. <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
  578. {t('webhooks.create')}
  579. </Button>
  580. </Space>
  581. }
  582. />
  583. ) : (
  584. <Space direction="vertical" size={12} className="full-width">
  585. <Alert type="info" showIcon message={mailboxScoped ? t('webhooks.mailboxReceiptHelp') : t('webhooks.domainOverrideHelp')} />
  586. <Space>
  587. <Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
  588. {t('common.refresh')}
  589. </Button>
  590. <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
  591. {t('webhooks.create')}
  592. </Button>
  593. </Space>
  594. </Space>
  595. )}
  596. {!embedded ? (
  597. <Alert type="info" showIcon message={mailboxScoped ? t('webhooks.mailboxReceiptHelp') : t('webhooks.domainOverrideHelp')} />
  598. ) : null}
  599. <SectionCard
  600. title={t('webhooks.listTitle')}
  601. extra={<StatusPill tone="neutral">{webhooks.length}</StatusPill>}
  602. >
  603. {!loadError && resourceError ? <Alert type="warning" showIcon message={t('webhooks.resourcesUnavailable')} description={resourceError} style={{ marginBottom: 12 }} /> : null}
  604. {!loadError && recentDeliveriesError && webhooks.length ? <Alert type="warning" showIcon message={t('webhooks.recentDeliveriesUnavailable')} description={recentDeliveriesError} style={{ marginBottom: 12 }} /> : null}
  605. {loadError ? (
  606. <Alert
  607. type="error"
  608. showIcon
  609. message={t('webhooks.loadFailed')}
  610. description={loadError}
  611. action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>}
  612. />
  613. ) : loading && !webhooks.length ? (
  614. <Skeleton active paragraph={{ rows: 6 }} />
  615. ) : webhooks.length ? (
  616. <Table
  617. rowKey="id"
  618. columns={endpointColumns}
  619. dataSource={webhooks}
  620. loading={loading}
  621. scroll={{ x: 1200 }}
  622. pagination={{ pageSize: 10 }}
  623. />
  624. ) : (
  625. <EmptyState
  626. description={t('webhooks.empty')}
  627. />
  628. )}
  629. </SectionCard>
  630. <Drawer
  631. title={editing ? t('webhooks.editTitle') : t('webhooks.createTitle')}
  632. width={520}
  633. open={drawerOpen}
  634. onClose={closeDrawer}
  635. destroyOnHidden
  636. footer={
  637. <div className="drawer-footer">
  638. <Button onClick={closeDrawer}>{t('common.cancel')}</Button>
  639. <Button type="primary" loading={actionKey === 'create' || actionKey.startsWith('save:')} onClick={() => void submitForm()}>
  640. {editing ? t('common.save') : t('webhooks.create')}
  641. </Button>
  642. </div>
  643. }
  644. >
  645. <Form form={form} layout="vertical" initialValues={{ enabled: true, events: DELIVERY_EVENTS }}>
  646. <Form.Item
  647. name="name"
  648. label={t('webhooks.name')}
  649. rules={[{ required: true, message: t('webhooks.nameRequired') }]}
  650. >
  651. <Input placeholder={t('webhooks.namePlaceholder')} />
  652. </Form.Item>
  653. <Form.Item
  654. name="url"
  655. label={t('webhooks.url')}
  656. extra={t('webhooks.urlHint')}
  657. rules={[{ required: true, message: t('webhooks.urlRequired') }]}
  658. >
  659. <Input placeholder={t('webhooks.urlPlaceholder')} />
  660. </Form.Item>
  661. <Form.Item
  662. name="events"
  663. label={t('webhooks.events')}
  664. rules={[{ required: true, type: 'array', min: 1, message: t('webhooks.eventsRequired') }]}
  665. >
  666. <Checkbox.Group
  667. options={selectableEvents.map((event) => ({
  668. value: event,
  669. label: eventLabel(event)
  670. }))}
  671. disabled={mailboxScoped}
  672. />
  673. </Form.Item>
  674. {!scoped ? (
  675. <Form.Item name="domainId" label={t('webhooks.domain')} extra={t('webhooks.domainAccount')}>
  676. <Select
  677. allowClear
  678. placeholder={t('webhooks.domainAccount')}
  679. options={availableDomains.map((domain) => ({
  680. value: domain.id,
  681. label: domain.domain
  682. }))}
  683. />
  684. </Form.Item>
  685. ) : null}
  686. <Form.Item name="enabled" label={t('webhooks.enabled')} valuePropName="checked">
  687. <Switch />
  688. </Form.Item>
  689. </Form>
  690. </Drawer>
  691. <Drawer
  692. title={selectedWebhook ? `${selectedWebhook.name} · ${t('webhooks.deliveriesTitle')}` : t('webhooks.deliveriesTitle')}
  693. width={760}
  694. open={Boolean(requestedWebhookId)}
  695. onClose={closeDeliveries}
  696. >
  697. {loading && !selectedWebhook ? (
  698. <div role="status" aria-label={t('webhooks.deliveriesTitle')}>
  699. <Skeleton active paragraph={{ rows: 8 }} />
  700. </div>
  701. ) : selectedWebhook ? (
  702. <Space direction="vertical" size={20} className="full-width">
  703. <Descriptions bordered column={1} size="small">
  704. <Descriptions.Item label={t('webhooks.url')}>
  705. <Typography.Text code copyable={{ onCopy: () => void copyValue(selectedWebhook.url) }} style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{selectedWebhook.url}</Typography.Text>
  706. </Descriptions.Item>
  707. <Descriptions.Item label={t('webhooks.scope')}>{scopeLabel(selectedWebhook)}</Descriptions.Item>
  708. <Descriptions.Item label={t('webhooks.events')}>
  709. <Space wrap>{selectedWebhook.events.map((event) => <Tag key={event}>{eventLabel(event)}</Tag>)}</Space>
  710. </Descriptions.Item>
  711. <Descriptions.Item label={t('webhooks.secretPrefix')}><Typography.Text code>{selectedWebhook.secretPrefix}…</Typography.Text></Descriptions.Item>
  712. <Descriptions.Item label={t('common.status')}>
  713. <StatusPill tone={selectedWebhook.enabled ? 'success' : 'neutral'}>{selectedWebhook.enabled ? t('webhooks.enabled') : t('webhooks.disabled')}</StatusPill>
  714. </Descriptions.Item>
  715. </Descriptions>
  716. <Space wrap>
  717. <Select
  718. aria-label={t('webhooks.deliveriesFilterStatus')}
  719. style={{ minWidth: 160 }}
  720. value={deliveryStatus}
  721. onChange={(value) => updateDeliveryFilter('deliveryStatus', value)}
  722. options={[{ value: 'all', label: t('webhooks.allStatuses') }, ...DELIVERY_STATUSES.map((status) => ({ value: status, label: deliveryStatusLabel(status) }))]}
  723. />
  724. <Select
  725. aria-label={t('webhooks.deliveriesFilterEvent')}
  726. style={{ minWidth: 160 }}
  727. value={deliveryEvent}
  728. onChange={(value) => updateDeliveryFilter('deliveryEvent', value)}
  729. options={[{ value: 'all', label: t('webhooks.allEvents') }, ...ALL_EVENTS.map((event) => ({ value: event, label: eventLabel(event) }))]}
  730. />
  731. </Space>
  732. {detailError ? (
  733. <Alert
  734. type="error"
  735. showIcon
  736. message={detailError}
  737. action={<Button icon={<ReloadOutlined />} onClick={() => void loadDetailDeliveries()}>{t('common.refresh')}</Button>}
  738. />
  739. ) : detailLoading && !detailDeliveries.length ? (
  740. <div role="status" aria-label={t('webhooks.deliveriesTitle')}>
  741. <Skeleton active paragraph={{ rows: 6 }} />
  742. </div>
  743. ) : detailDeliveries.length ? (
  744. <Table rowKey="id" columns={deliveryColumns} dataSource={detailDeliveries} loading={detailLoading} scroll={{ x: 920 }} pagination={{ pageSize: 10 }} />
  745. ) : <EmptyState description={t('webhooks.deliveriesEmpty')} />}
  746. <Alert type="info" showIcon message={t('webhooks.docsSignature')} description={t('webhooks.docsEvents')} />
  747. <CodeBlock value={buildSignatureSample('whsec_your_secret')} onCopy={copyValue} />
  748. </Space>
  749. ) : loadError && requestedWebhookId ? (
  750. <Alert type="error" showIcon message={t('webhooks.loadFailed')} description={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} />
  751. ) : requestedWebhookId ? <EmptyState description={t('common.notFound')} /> : null}
  752. </Drawer>
  753. <Modal
  754. title={
  755. secretReveal?.mode === 'rotated'
  756. ? t('webhooks.secretRotatedTitle')
  757. : t('webhooks.secretCreatedTitle')
  758. }
  759. open={Boolean(secretReveal)}
  760. closable={false}
  761. maskClosable={false}
  762. keyboard={false}
  763. destroyOnHidden
  764. footer={[
  765. <Button
  766. key="copy"
  767. icon={<CopyOutlined />}
  768. onClick={() => void copyValue(secret)}
  769. >
  770. {t('webhooks.copySecret')}
  771. </Button>,
  772. <Button key="done" type="primary" onClick={() => setSecretReveal(null)}>
  773. {t('common.confirm')}
  774. </Button>
  775. ]}
  776. >
  777. <Space direction="vertical" size={16} className="full-width">
  778. <Alert type="error" showIcon message={t('webhooks.secretCreatedWarning')} />
  779. <div>
  780. <Typography.Text type="secondary">{t('webhooks.secret')}</Typography.Text>
  781. <CodeBlock value={secret} onCopy={copyValue} />
  782. </div>
  783. <div>
  784. <Typography.Text type="secondary">{t('webhooks.docsSignature')}</Typography.Text>
  785. <CodeBlock value={sampleVerifier} onCopy={copyValue} />
  786. </div>
  787. </Space>
  788. </Modal>
  789. </Space>
  790. );
  791. }
  792. function truncateUrl(url: string, max = 48) {
  793. if (!url) return '';
  794. if (url.length <= max) return url;
  795. return `${url.slice(0, max - 1)}…`;
  796. }
  797. function positiveInteger(value: string | null) {
  798. const parsed = Number(value);
  799. return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
  800. }
  801. function deliveryStatusFromParam(value: string | null): WebhookDeliveryStatus | 'all' {
  802. return DELIVERY_STATUSES.includes(value as WebhookDeliveryStatus)
  803. ? value as WebhookDeliveryStatus
  804. : 'all';
  805. }
  806. function deliveryEventFromParam(value: string | null): WebhookEvent | 'all' {
  807. return ALL_EVENTS.includes(value as WebhookEvent) ? value as WebhookEvent : 'all';
  808. }
  809. function buildSignatureSample(secret: string) {
  810. return `// Verify X-MailHub-Signature (Node.js)
  811. const crypto = require('crypto');
  812. function verify(rawBody, signatureHeader, secret = ${JSON.stringify(secret)}) {
  813. const parts = Object.fromEntries(
  814. signatureHeader.split(',').map((p) => p.trim().split('='))
  815. );
  816. const signed = \`\${parts.t}.\${rawBody}\`;
  817. const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
  818. return crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
  819. }`;
  820. }