PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / resources / js / pages / Webhooks.tsx

Webhooks.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.10, at resources/js/pages/Webhooks.tsx

3,263 lines 122.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useMemo, useState } from "react";
2 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 import {
4 Webhook,
5 Plus,
6 Trash2,
7 Pencil,
8 Send,
9 Copy,
10 Check,
11 Loader2,
12 ExternalLink,
13 RefreshCw,
14 AlertTriangle,
15 CheckCircle2,
16 XCircle,
17 Eye,
18 RotateCcw,
19 ShieldCheck,
20 Crown,
21 Clock,
22 Zap,
23 ChevronDown,
24 Filter,
25 } from "lucide-react";
26 import { __, sprintf } from "../lib/i18n";
27 import { PageHeader } from "../components/common/PageHeader";
28 import {
29 Card,
30 CardContent,
31 CardDescription,
32 CardHeader,
33 CardTitle,
34 } from "../components/ui/card";
35 import { Alert } from "../components/ui/alert";
36 import { Button } from "../components/ui/button";
37 import { Input } from "../components/ui/input";
38 import { Label } from "../components/ui/label";
39 import { Select } from "../components/ui/select";
40 import { Badge } from "../components/ui/badge";
41 import { Modal } from "../components/ui/modal";
42 import { ModulePageSkeleton } from "../components/ui/module-skeleton";
43 import { Switch } from "../components/ui/switch";
44 import { Tooltip } from "../components/ui/tooltip";
45 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
46 import { Pagination } from "../components/shared/Pagination";
47 import { useToast } from "../components/ui/toast";
48 import { Table as SharedTable } from "../components/shared/Table";
49 import {
50 webhooksApi,
51 type ListenStatus,
52 type WebhookDeliveryRow,
53 type WebhookEndpoint,
54 type WebhookEndpointWriteInput,
55 type WebhookEvent,
56 type WebhooksMeta,
57 } from "../api/webhooks-api";
58
59 /**
60 * Yatra → Webhooks — Agency-tier outbound integration hub.
61 *
62 * Two tabs:
63 * 1. Endpoints — CRUD against `wp_yatra_webhook_endpoints`
64 * 2. Deliveries — paginated audit log with full payload inspection
65 * (the "listen mode" view)
66 *
67 * Critical UX patterns:
68 * - The signing secret is shown ONCE on create + regenerate, in a
69 * copy-only modal. Subsequent reads only see the last-4 hint.
70 * Mirrors Stripe / GitHub / WP REST best practice.
71 * - HTTPS-only is enforced server-side (WP_DEBUG allows HTTP for
72 * local dev). The form labels this clearly.
73 * - Auto-disabled endpoints (after 10 permanent failures) show a
74 * red banner with a re-enable affordance.
75 *
76 * White-label-safe: no product names in user-facing copy; textdomain
77 * `yatra` for translation parity with the rest of the admin React.
78 */
79 /* -------------------------------------------------------------------------- */
80 /* Shared key/value editor — used for both custom HTTP headers AND the */
81 /* additional payload fields editor on the endpoint form. */
82 /* -------------------------------------------------------------------------- */
83
84 interface KvRow {
85 key: string;
86 value: string;
87 }
88
89 /** Object → list of rows. Stable order = insertion order (Object.entries). */
90 function objectToRows(obj: Record<string, string>): KvRow[] {
91 return Object.entries(obj || {}).map(([k, v]) => ({ key: k, value: v }));
92 }
93
94 /** Rows → object. Blank rows skipped; later rows clobber earlier ones with
95 * the same key (server-side validation rejects truly invalid keys). */
96 function rowsToObject(rows: KvRow[]): Record<string, string> {
97 const out: Record<string, string> = {};
98 for (const r of rows) {
99 const k = r.key.trim();
100 const v = r.value;
101 if (k === "") continue;
102 out[k] = v;
103 }
104 return out;
105 }
106
107 const KeyValueEditor: React.FC<{
108 rows: KvRow[];
109 onChange: (rows: KvRow[]) => void;
110 keyPlaceholder: string;
111 valuePlaceholder: string;
112 /** Used to namespace input id attributes for a11y. */
113 idPrefix: string;
114 }> = ({ rows, onChange, keyPlaceholder, valuePlaceholder, idPrefix }) => {
115 const setRow = (idx: number, patch: Partial<KvRow>) => {
116 const next = rows.slice();
117 next[idx] = { ...next[idx], ...patch };
118 onChange(next);
119 };
120 const removeRow = (idx: number) => {
121 onChange(rows.filter((_, i) => i !== idx));
122 };
123 const addRow = () => onChange([...rows, { key: "", value: "" }]);
124
125 return (
126 <div className="space-y-2">
127 {rows.length === 0 ? (
128 <p className="text-xs text-gray-500 dark:text-gray-400">
129 {__("None configured.", "yatra")}
130 </p>
131 ) : (
132 rows.map((row, idx) => (
133 <div key={idx} className="flex gap-2 items-start">
134 <Input
135 id={`${idPrefix}-key-${idx}`}
136 aria-label={keyPlaceholder}
137 value={row.key}
138 onChange={(e) => setRow(idx, { key: e.target.value })}
139 placeholder={keyPlaceholder}
140 className="font-mono text-sm flex-1"
141 />
142 <Input
143 id={`${idPrefix}-value-${idx}`}
144 aria-label={valuePlaceholder}
145 value={row.value}
146 onChange={(e) => setRow(idx, { value: e.target.value })}
147 placeholder={valuePlaceholder}
148 className="font-mono text-sm flex-[2]"
149 />
150 <Button
151 type="button"
152 variant="outline"
153 size="sm"
154 onClick={() => removeRow(idx)}
155 aria-label={__("Remove row", "yatra")}
156 className="flex-shrink-0"
157 >
158 <Trash2 className="h-3.5 w-3.5" />
159 </Button>
160 </div>
161 ))
162 )}
163 <Button type="button" variant="outline" size="sm" onClick={addRow}>
164 <Plus className="mr-1.5 h-3.5 w-3.5" />
165 {__("Add row", "yatra")}
166 </Button>
167 </div>
168 );
169 };
170
171 type WebhookTab = "endpoints" | "deliveries" | "buried";
172
173 function getInitialTab(): WebhookTab {
174 if (typeof window === "undefined") return "endpoints";
175 const tab = new URLSearchParams(window.location.search).get("tab");
176 if (tab === "deliveries" || tab === "logs") return "deliveries";
177 if (tab === "buried" || tab === "dead-letter" || tab === "dlq")
178 return "buried";
179 return "endpoints";
180 }
181
182 const Webhooks: React.FC = () => {
183 const [activeTab, setActiveTab] = useState<WebhookTab>(() => getInitialTab());
184
185 const switchTab = (next: WebhookTab) => {
186 setActiveTab(next);
187 if (typeof window !== "undefined") {
188 const url = new URL(window.location.href);
189 url.searchParams.set("tab", next);
190 window.history.replaceState({}, "", url.toString());
191 }
192 };
193
194 const { data: meta, isLoading: metaLoading } = useQuery({
195 queryKey: ["webhooks-meta"],
196 queryFn: () => webhooksApi.getMeta(),
197 });
198
199 if (metaLoading) {
200 return <ModulePageSkeleton variant="tabs" />;
201 }
202 if (!meta) return null;
203
204 // Hard tier gate — Agency-only.
205 if (!meta.is_agency_active) {
206 return (
207 <div className="space-y-6">
208 <PageHeader
209 description={__(
210 "Push every Yatra event to your CRM, accounting tool, Zapier, Make, Slack, or any HTTPS endpoint.",
211 "yatra",
212 )}
213 />
214 <UpgradeCard meta={meta} />
215 </div>
216 );
217 }
218
219 if (!meta.is_module_enabled) {
220 return (
221 <div className="space-y-6">
222 <PageHeader
223 description={__(
224 "Push every Yatra event to your CRM, accounting tool, Zapier, Make, Slack, or any HTTPS endpoint.",
225 "yatra",
226 )}
227 />
228 <ModulePrompt />
229 </div>
230 );
231 }
232
233 const tabs: Array<{ key: WebhookTab; label: string; icon: any }> = [
234 { key: "endpoints", label: __("Endpoints", "yatra"), icon: Webhook },
235 { key: "deliveries", label: __("Deliveries", "yatra"), icon: Clock },
236 // "Buried" surfaces every delivery that exhausted retries +
237 // landed in permanent_failure. Bulk-replay lives here so operators
238 // triage from the summary instead of paging through deliveries.
239 { key: "buried", label: __("Buried", "yatra"), icon: AlertTriangle },
240 ];
241
242 return (
243 <div className="space-y-6">
244 <PageHeader
245 description={__(
246 "HMAC-signed outbound events. HTTPS endpoints only, encrypted secrets, automatic retry with exponential backoff, full delivery log with payload inspection + replay.",
247 "yatra",
248 )}
249 />
250
251 <Card>
252 <div className="border-b border-gray-200 dark:border-gray-700">
253 <nav className="flex flex-wrap gap-1 px-4">
254 {tabs.map((tab) => {
255 const Icon = tab.icon;
256 const active = activeTab === tab.key;
257 return (
258 <button
259 key={tab.key}
260 type="button"
261 onClick={() => switchTab(tab.key)}
262 className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
263 active
264 ? "border-blue-500 text-blue-600 dark:text-blue-400"
265 : "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
266 }`}
267 >
268 <Icon className="w-4 h-4" />
269 {tab.label}
270 </button>
271 );
272 })}
273 </nav>
274 </div>
275
276 <CardContent className="p-6">
277 {activeTab === "endpoints" && <EndpointsTab />}
278 {activeTab === "deliveries" && <DeliveriesTab />}
279 {activeTab === "buried" && <BuriedTab />}
280 </CardContent>
281 </Card>
282 </div>
283 );
284 };
285
286 /* -------------------------------------------------------------------------- */
287 /* Upgrade / module-disabled cards */
288 /* -------------------------------------------------------------------------- */
289
290 const UpgradeCard: React.FC<{ meta: WebhooksMeta }> = ({ meta }) => (
291 <Card className="max-w-3xl">
292 <CardHeader>
293 <div className="flex items-start gap-3">
294 <div className="rounded-md bg-purple-100 p-2 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400">
295 <Crown className="h-5 w-5" />
296 </div>
297 <div className="flex-1">
298 <CardTitle>{__("Webhooks", "yatra")}</CardTitle>
299 <CardDescription className="mt-1">
300 {__(
301 "Available on the Scale plan. The integration backbone for agencies wiring Yatra into a custom tech stack — sync bookings to CRMs, post revenue events to accounting tools, trigger Zapier workflows, ping Slack on VIP bookings.",
302 "yatra",
303 )}
304 </CardDescription>
305 </div>
306 </div>
307 </CardHeader>
308 <CardContent className="space-y-4">
309 <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
310 {[
311 [
312 __("Encrypted per-endpoint secrets", "yatra"),
313 __("Libsodium-backed at-rest encryption.", "yatra"),
314 ],
315 [
316 __("HMAC-SHA256 signed payloads", "yatra"),
317 __("Stripe-style signature header.", "yatra"),
318 ],
319 [
320 __("Automatic retry with backoff", "yatra"),
321 __("5 attempts with jittered exponential delay.", "yatra"),
322 ],
323 [
324 __("Full delivery log + replay", "yatra"),
325 __("Every payload preserved for 90 days.", "yatra"),
326 ],
327 ].map(([title, sub]) => (
328 <div
329 key={title}
330 className="rounded-md border border-gray-200 dark:border-gray-700 p-3"
331 >
332 <div className="flex items-start gap-2">
333 <CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
334 <div>
335 <div className="text-sm font-medium text-gray-900 dark:text-white">
336 {title}
337 </div>
338 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
339 {sub}
340 </div>
341 </div>
342 </div>
343 </div>
344 ))}
345 </div>
346 <div className="flex gap-2 pt-2">
347 <Button asChild>
348 <a href={meta.upgrade_url} target="_blank" rel="noopener noreferrer">
349 {__("Upgrade to Scale", "yatra")}
350 <ExternalLink className="ml-1.5 h-4 w-4" />
351 </a>
352 </Button>
353 {meta.docs_url && (
354 <Button variant="outline" asChild>
355 <a href={meta.docs_url} target="_blank" rel="noopener noreferrer">
356 {__("Read the docs", "yatra")}
357 </a>
358 </Button>
359 )}
360 </div>
361 </CardContent>
362 </Card>
363 );
364
365 const ModulePrompt: React.FC = () => (
366 <Card className="max-w-3xl">
367 <CardHeader>
368 <CardTitle className="flex items-center gap-2">
369 <Webhook className="h-5 w-5 text-blue-500" />
370 {__("Enable the Webhooks module", "yatra")}
371 </CardTitle>
372 <CardDescription>
373 {__(
374 'Toggle "Webhooks" on under Yatra → Modules to start configuring endpoints.',
375 "yatra",
376 )}
377 </CardDescription>
378 </CardHeader>
379 </Card>
380 );
381
382 /* -------------------------------------------------------------------------- */
383 /* Endpoints tab */
384 /* -------------------------------------------------------------------------- */
385
386 const EndpointsTab: React.FC = () => {
387 const queryClient = useQueryClient();
388 const { showToast } = useToast();
389 const [editing, setEditing] = useState<WebhookEndpoint | "new" | null>(null);
390 const [pendingDelete, setPendingDelete] = useState<WebhookEndpoint | null>(
391 null,
392 );
393 // The plaintext secret only ever lives in memory — we show it once
394 // in a copy-only modal then drop it. Stripe-style.
395 const [revealSecret, setRevealSecret] = useState<{
396 secret: string;
397 endpointName: string;
398 } | null>(null);
399 // After a Ping, open the delivery inspector immediately — operators
400 // shouldn't have to tab-switch to find out whether the test worked.
401 const [pingInspectId, setPingInspectId] = useState<number | null>(null);
402 // mTLS client-cert dialog state. Holds the endpoint we're editing
403 // the cert for; null = dialog closed.
404 const [mtlsEndpoint, setMtlsEndpoint] = useState<WebhookEndpoint | null>(
405 null,
406 );
407
408 const { data: endpointsData, isLoading } = useQuery({
409 queryKey: ["webhooks-endpoints"],
410 queryFn: () => webhooksApi.listEndpoints(),
411 });
412 const { data: eventsData } = useQuery({
413 queryKey: ["webhooks-events"],
414 queryFn: () => webhooksApi.listEvents(),
415 });
416
417 const deleteEndpoint = useMutation({
418 mutationFn: (id: number) => webhooksApi.deleteEndpoint(id),
419 onSuccess: () => {
420 queryClient.invalidateQueries({ queryKey: ["webhooks-endpoints"] });
421 showToast(__("Endpoint deleted.", "yatra"), "success");
422 setPendingDelete(null);
423 },
424 onError: (e: any) => {
425 showToast(extractError(e), "error");
426 setPendingDelete(null);
427 },
428 });
429
430 const pingEndpoint = useMutation({
431 mutationFn: (id: number) => webhooksApi.pingEndpoint(id),
432 onSuccess: (res) => {
433 queryClient.invalidateQueries({ queryKey: ["webhooks-deliveries"] });
434 // Pop the inspector for the new row so the operator sees the
435 // dispatch progress in real time. The inspector itself polls
436 // until the row reaches a terminal status.
437 setPingInspectId(res.row_id);
438 },
439 onError: (e: any) => showToast(extractError(e), "error"),
440 });
441
442 const regenerateSecret = useMutation({
443 mutationFn: (id: number) => webhooksApi.regenerateSecret(id),
444 onSuccess: (res, id) => {
445 queryClient.invalidateQueries({ queryKey: ["webhooks-endpoints"] });
446 const endpoint = endpointsData?.data.find((e) => e.id === id);
447 setRevealSecret({
448 secret: res.secret,
449 endpointName: endpoint?.name || __("this endpoint", "yatra"),
450 });
451 },
452 onError: (e: any) => showToast(extractError(e), "error"),
453 });
454
455 if (editing !== null) {
456 return (
457 <EndpointEditForm
458 existing={editing === "new" ? null : editing}
459 events={eventsData?.data ?? []}
460 onClose={() => setEditing(null)}
461 onCreated={(secret, name) => {
462 setEditing(null);
463 setRevealSecret({ secret, endpointName: name });
464 }}
465 />
466 );
467 }
468
469 const endpoints = endpointsData?.data ?? [];
470
471 return (
472 <div className="space-y-4">
473 <div className="flex items-start justify-between gap-3 flex-wrap">
474 <div className="max-w-2xl">
475 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
476 {__("HTTP endpoints", "yatra")}
477 </h3>
478 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
479 {__(
480 "Each endpoint receives a signed POST when one of its subscribed events fires. The signing secret is shown once on create — store it in your receiver's config.",
481 "yatra",
482 )}
483 </p>
484 </div>
485 <Button onClick={() => setEditing("new")}>
486 <Plus className="mr-1.5 h-4 w-4" />
487 {__("Add endpoint", "yatra")}
488 </Button>
489 </div>
490
491 {/* Canonical Yatra table — same shell as Activities / Destinations.
492 * Name is a clickable link that opens the edit view; secondary
493 * actions live in the per-row 3-dot menu. SharedTable handles
494 * loading / empty / error states for us. */}
495 <Card className="overflow-visible">
496 <CardContent className="p-0 overflow-visible">
497 <SharedTable
498 data={endpoints}
499 columns={[
500 {
501 key: "name",
502 label: __("Endpoint", "yatra"),
503 render: (ep: WebhookEndpoint) => (
504 <div className="flex items-center gap-3">
505 <div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
506 <Webhook className="w-5 h-5 text-blue-600 dark:text-blue-400" />
507 </div>
508 <div className="min-w-0">
509 <button
510 type="button"
511 onClick={() => setEditing(ep)}
512 className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors cursor-pointer text-left"
513 >
514 {ep.name}
515 </button>
516 <div
517 className="text-xs font-mono text-gray-500 dark:text-gray-400 mt-0.5 truncate max-w-md"
518 title={ep.url}
519 >
520 {ep.url}
521 </div>
522 {ep.consecutive_failures >= 5 && (
523 <div className="mt-1 inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
524 <AlertTriangle className="h-3 w-3" />
525 {ep.consecutive_failures}{" "}
526 {__("consecutive failures", "yatra")}
527 </div>
528 )}
529 </div>
530 </div>
531 ),
532 },
533 {
534 key: "status",
535 label: __("Status", "yatra"),
536 render: (ep: WebhookEndpoint) =>
537 ep.is_active ? (
538 <Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
539 <CheckCircle2 className="mr-1 h-3 w-3" />
540 {__("Active", "yatra")}
541 </Badge>
542 ) : (
543 <Badge className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
544 <XCircle className="mr-1 h-3 w-3" />
545 {__("Inactive", "yatra")}
546 </Badge>
547 ),
548 },
549 {
550 key: "event",
551 label: __("Event", "yatra"),
552 render: (ep: WebhookEndpoint) => (
553 <>
554 {ep.event ? (
555 <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
556 <Zap className="h-3 w-3" />
557 <code className="font-mono">{ep.event}</code>
558 </span>
559 ) : (
560 <span className="text-xs text-gray-400">—</span>
561 )}
562 {ep.selected_fields && ep.selected_fields.length > 0 && (
563 <div className="mt-1 inline-flex items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400">
564 <Filter className="h-3 w-3" />
565 {ep.selected_fields.length} {__("fields", "yatra")}
566 </div>
567 )}
568 {!ep.log_deliveries && (
569 <div className="mt-1 inline-flex items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400">
570 {__("Logging off", "yatra")}
571 </div>
572 )}
573 </>
574 ),
575 },
576 {
577 key: "health",
578 label: __("Health", "yatra"),
579 render: (ep: WebhookEndpoint) => (
580 <HealthCell health={ep.health} />
581 ),
582 },
583 {
584 key: "last_delivered_at",
585 label: __("Last delivery", "yatra"),
586 render: (ep: WebhookEndpoint) =>
587 ep.last_delivered_at ? (
588 <div className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
589 <div>
590 {new Date(ep.last_delivered_at).toLocaleString()}
591 </div>
592 <div className="mt-0.5">{ep.last_status || "—"}</div>
593 </div>
594 ) : (
595 <span className="text-xs text-gray-400">—</span>
596 ),
597 },
598 ]}
599 actions={[
600 {
601 key: "edit",
602 label: __("Edit", "yatra"),
603 icon: <Pencil className="w-4 h-4" />,
604 onClick: (ep: WebhookEndpoint) => setEditing(ep),
605 },
606 {
607 key: "ping",
608 label: __("Send test ping", "yatra"),
609 icon: <Send className="w-4 h-4" />,
610 onClick: (ep: WebhookEndpoint) => pingEndpoint.mutate(ep.id),
611 condition: (ep: WebhookEndpoint) => ep.is_active,
612 },
613 {
614 key: "regenerate",
615 label: __("Regenerate signing secret", "yatra"),
616 icon: <RefreshCw className="w-4 h-4" />,
617 onClick: (ep: WebhookEndpoint) =>
618 regenerateSecret.mutate(ep.id),
619 },
620 {
621 key: "mtls",
622 label: __("Client certificate (mTLS)", "yatra"),
623 icon: <ShieldCheck className="w-4 h-4" />,
624 onClick: (ep: WebhookEndpoint) => setMtlsEndpoint(ep),
625 },
626 {
627 key: "delete",
628 label: __("Delete endpoint", "yatra"),
629 icon: <Trash2 className="w-4 h-4" />,
630 onClick: (ep: WebhookEndpoint) => setPendingDelete(ep),
631 variant: "destructive",
632 },
633 ]}
634 isLoading={isLoading}
635 emptyText={__("No endpoints configured yet", "yatra")}
636 emptyDescription={__(
637 "Add your first endpoint to start receiving events. Common targets: Zapier catch hooks, Make scenarios, internal CRM ingest URLs, Slack webhook URLs.",
638 "yatra",
639 )}
640 onCreateClick={() => setEditing("new")}
641 />
642 </CardContent>
643 </Card>
644
645 <ConfirmationDialog
646 isOpen={pendingDelete !== null}
647 onClose={() => {
648 if (!deleteEndpoint.isPending) setPendingDelete(null);
649 }}
650 onConfirm={() => {
651 if (pendingDelete) deleteEndpoint.mutate(pendingDelete.id);
652 }}
653 title={__("Delete endpoint?", "yatra")}
654 description={
655 pendingDelete
656 ? __(
657 'Delete "{name}"? Its delivery log will also be removed. This cannot be undone.',
658 "yatra",
659 ).replace("{name}", pendingDelete.name)
660 : ""
661 }
662 confirmText={__("Delete endpoint", "yatra")}
663 cancelText={__("Cancel", "yatra")}
664 variant="danger"
665 isLoading={deleteEndpoint.isPending}
666 />
667
668 <SecretRevealDialog
669 secret={revealSecret?.secret ?? null}
670 endpointName={revealSecret?.endpointName ?? ""}
671 onClose={() => setRevealSecret(null)}
672 />
673
674 {/* Live ping inspector — polls until the row reaches a terminal
675 * status so the operator sees the test result without leaving
676 * the Endpoints tab. */}
677 {pingInspectId !== null && (
678 <DeliveryInspectDialog
679 id={pingInspectId}
680 onClose={() => setPingInspectId(null)}
681 />
682 )}
683
684 <MtlsDialog
685 endpoint={mtlsEndpoint}
686 onClose={() => setMtlsEndpoint(null)}
687 />
688 </div>
689 );
690 };
691
692 /* -------------------------------------------------------------------------- */
693 /* Client-side mirror of PayloadFilter::apply() — used by the "Output */
694 /* preview" panel so operators can see EXACTLY what their receiver will get */
695 /* before saving. Behavior MUST stay in lockstep with the PHP service: */
696 /* - envelope keys (id/type/api_version/occurred_at) are always preserved */
697 /* - data block is filtered to selected dot-paths */
698 /* - picking a parent path ships the entire subtree */
699 /* - empty selection OR ["*"] = send everything */
700 /* -------------------------------------------------------------------------- */
701
702 const ENVELOPE_KEYS = ["id", "type", "api_version", "occurred_at"] as const;
703
704 function applyFieldFilter(
705 payload: Record<string, unknown> | null,
706 selected: string[],
707 ): Record<string, unknown> | null {
708 if (payload === null) return null;
709 // No filter / wildcard → return as-is.
710 if (selected.length === 0 || selected.includes("*")) return payload;
711
712 // Normalize paths: strip leading "data." so paths are always
713 // relative to the data block, same as the server.
714 const paths = Array.from(
715 new Set(
716 selected
717 .map((p) => p.trim())
718 .filter((p) => p !== "")
719 .map((p) => (p.startsWith("data.") ? p.slice(5) : p))
720 .filter((p) => p !== ""),
721 ),
722 );
723
724 const out: Record<string, unknown> = {};
725 // Envelope always copies through.
726 for (const k of ENVELOPE_KEYS) {
727 if (k in payload) out[k] = payload[k];
728 }
729
730 const data = payload.data;
731 if (data === null || typeof data !== "object" || Array.isArray(data)) {
732 out.data = data;
733 return out;
734 }
735
736 const filteredData: Record<string, unknown> = {};
737 for (const path of paths) {
738 extractInto(
739 data as Record<string, unknown>,
740 path.split("."),
741 0,
742 filteredData,
743 );
744 }
745 out.data = filteredData;
746 return out;
747 }
748
749 function extractInto(
750 tree: Record<string, unknown>,
751 parts: string[],
752 depth: number,
753 out: Record<string, unknown>,
754 ): void {
755 if (depth >= parts.length) return;
756 const key = parts[depth];
757 if (!(key in tree)) return;
758
759 const isLeaf = depth === parts.length - 1;
760 if (isLeaf) {
761 out[key] = tree[key];
762 return;
763 }
764
765 const child = tree[key];
766 if (typeof child !== "object" || child === null || Array.isArray(child)) {
767 // Path goes deeper but tree has a scalar/array here — bail out.
768 return;
769 }
770 let nested = out[key];
771 if (typeof nested !== "object" || nested === null || Array.isArray(nested)) {
772 nested = {};
773 out[key] = nested;
774 }
775 extractInto(
776 child as Record<string, unknown>,
777 parts,
778 depth + 1,
779 nested as Record<string, unknown>,
780 );
781 }
782
783 /* -------------------------------------------------------------------------- */
784 /* Endpoint health snapshot — colored success-rate badge + delivered/failed */
785 /* ratio. Computed server-side over the last 100 terminal deliveries so it */
786 /* reflects current behaviour, not lifetime stats. */
787 /* -------------------------------------------------------------------------- */
788
789 const HealthCell: React.FC<{
790 health: WebhookEndpoint["health"];
791 }> = ({ health }) => {
792 if (!health || health.total === 0) {
793 return (
794 <span className="text-xs text-gray-400">{__("No data", "yatra")}</span>
795 );
796 }
797 const rate = health.success_rate ?? 0;
798 const cls =
799 rate >= 95
800 ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300"
801 : rate >= 80
802 ? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
803 : "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
804 return (
805 <div>
806 <span
807 className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${cls}`}
808 title={`${health.delivered} delivered / ${health.failed} failed (last ${health.total})`}
809 >
810 {rate}%
811 </span>
812 <div className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
813 {health.delivered}/{health.total}
814 </div>
815 </div>
816 );
817 };
818
819 /* -------------------------------------------------------------------------- */
820 /* Field selector — Pabbly-style checklist of dot-paths inside the event's */
821 /* payload, sourced from the sample payload endpoint. Empty selection + */
822 /* "Send all" toggle = send the whole data block; otherwise only the picked */
823 /* paths flow through (the canonical envelope is always preserved by */
824 /* PayloadFilter::apply on the server). */
825 /* -------------------------------------------------------------------------- */
826
827 const FieldSelectorCard: React.FC<{
828 eventKey: string;
829 eventName: string;
830 paths: Array<{ path: string; sample: unknown }>;
831 isLoading: boolean;
832 sendAll: boolean;
833 onChangeSendAll: (next: boolean) => void;
834 selectedFields: string[];
835 onToggle: (path: string) => void;
836 onSelectAll: () => void;
837 onClear: () => void;
838 samplePayload: Record<string, unknown> | null;
839 sampleSource: "captured" | "delivery_log" | null;
840 sampleCapturedAt: number | null;
841 }> = ({
842 eventKey,
843 eventName,
844 paths,
845 isLoading,
846 sendAll,
847 onChangeSendAll,
848 selectedFields,
849 onToggle,
850 onSelectAll,
851 onClear,
852 samplePayload,
853 sampleSource,
854 sampleCapturedAt,
855 }) => {
856 const queryClient = useQueryClient();
857 const { showToast } = useToast();
858 const [showRawPayload, setShowRawPayload] = useState(false);
859 // Two-tab payload viewer: "raw" = real input data we captured;
860 // "output" = what your receiver actually gets after field filtering
861 // is applied. Defaults to "output" because that's the question
862 // operators are most often trying to answer.
863 const [payloadView, setPayloadView] = useState<"output" | "raw">("output");
864
865 // Pabbly-style listen mode — polls every 3s while armed.
866 const listenQuery = useQuery({
867 queryKey: ["webhooks-listen", eventKey],
868 queryFn: () => webhooksApi.getListenStatus(eventKey),
869 enabled: eventKey !== "",
870 refetchInterval: (q) =>
871 (q.state.data as ListenStatus | undefined)?.armed ? 3000 : false,
872 });
873 const armed = listenQuery.data?.armed ?? false;
874 const expiresAt = listenQuery.data?.expires_at ?? null;
875
876 const armMutation = useMutation({
877 mutationFn: () => webhooksApi.startListen(eventKey),
878 onSuccess: () => {
879 queryClient.invalidateQueries({
880 queryKey: ["webhooks-listen", eventKey],
881 });
882 },
883 onError: (e: any) => showToast(extractError(e), "error"),
884 });
885 const disarmMutation = useMutation({
886 mutationFn: (forget: boolean) => webhooksApi.stopListen(eventKey, forget),
887 onSuccess: () => {
888 queryClient.invalidateQueries({
889 queryKey: ["webhooks-listen", eventKey],
890 });
891 queryClient.invalidateQueries({
892 queryKey: ["webhooks-event-sample", eventKey],
893 });
894 },
895 onError: (e: any) => showToast(extractError(e), "error"),
896 });
897
898 // When a sample is captured live, refresh the sample query so the
899 // dot-path list updates without a manual reload.
900 React.useEffect(() => {
901 if (listenQuery.data?.captured) {
902 queryClient.invalidateQueries({
903 queryKey: ["webhooks-event-sample", eventKey],
904 });
905 }
906 // eslint-disable-next-line react-hooks/exhaustive-deps
907 }, [listenQuery.data?.captured?.captured_at, eventKey, queryClient]);
908
909 const formatSample = (val: unknown): string => {
910 if (val === null || val === undefined) return "null";
911 if (typeof val === "string") return val === "" ? '""' : val;
912 if (typeof val === "number" || typeof val === "boolean") return String(val);
913 if (Array.isArray(val) || typeof val === "object")
914 return JSON.stringify(val);
915 return String(val);
916 };
917
918 // Countdown timer for armed capture.
919 const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
920 React.useEffect(() => {
921 if (!armed) return;
922 const t = window.setInterval(
923 () => setNow(Math.floor(Date.now() / 1000)),
924 1000,
925 );
926 return () => window.clearInterval(t);
927 }, [armed]);
928 const remaining = armed && expiresAt ? Math.max(0, expiresAt - now) : 0;
929 const mm = Math.floor(remaining / 60)
930 .toString()
931 .padStart(2, "0");
932 const ss = (remaining % 60).toString().padStart(2, "0");
933
934 const sourceLabel: Record<"captured" | "delivery_log", string> = {
935 captured: __("Captured live", "yatra"),
936 delivery_log: __("From last delivery", "yatra"),
937 };
938
939 const hasSample = paths.length > 0 && samplePayload !== null;
940
941 return (
942 <Card>
943 <CardHeader>
944 <div className="flex items-start justify-between gap-3 flex-wrap">
945 <div>
946 <CardTitle className="flex items-center gap-2">
947 <Filter className="h-4 w-4 text-blue-500" />
948 {__("Payload fields to send", "yatra")}
949 </CardTitle>
950 <CardDescription className="space-y-1">
951 <span className="block">
952 {__(
953 "Pick which fields from this event's data block should be forwarded. The envelope (id, type, api_version, occurred_at) is always sent so receivers can route the event.",
954 "yatra",
955 )}
956 </span>
957 <span className="block text-xs text-gray-500 dark:text-gray-400">
958 {__(
959 "Fields ship in the JSON body of the POST under their original keys — nested structure is preserved (e.g. customer.email arrives as data.customer.email, not flattened).",
960 "yatra",
961 )}
962 </span>
963 </CardDescription>
964 </div>
965 {hasSample && (
966 <Button
967 type="button"
968 variant="outline"
969 size="sm"
970 onClick={() => setShowRawPayload(!showRawPayload)}
971 >
972 <Eye className="mr-1.5 h-3.5 w-3.5" />
973 {showRawPayload
974 ? __("Hide payload preview", "yatra")
975 : __("Preview payload", "yatra")}
976 </Button>
977 )}
978 </div>
979 </CardHeader>
980 <CardContent className="space-y-4">
981 {/* Listen control strip — uses Yatra's Alert primitive for
982 * visual consistency with the rest of the admin. The
983 * variant (info / success / warning) reflects the listen
984 * state, and the action buttons live on the right. */}
985 <Alert
986 variant={armed ? "info" : hasSample ? "success" : "warning"}
987 title={
988 armed
989 ? `${__("Listening for", "yatra")} ${eventName}…`
990 : hasSample
991 ? sampleSource
992 ? sourceLabel[sampleSource]
993 : __("Sample ready", "yatra")
994 : __("No sample captured yet", "yatra")
995 }
996 >
997 <div className="flex items-start justify-between gap-3 flex-wrap">
998 <div className="min-w-0 flex-1 space-y-1">
999 {armed ? (
1000 <>
1001 <p>
1002 {__(
1003 "Trigger this event in Yatra (create a booking, submit an enquiry, etc.). The real payload will appear here automatically.",
1004 "yatra",
1005 )}
1006 </p>
1007 <p className="font-mono text-xs">
1008 {__("Expires in", "yatra")} {mm}:{ss}
1009 </p>
1010 </>
1011 ) : hasSample ? (
1012 <>
1013 <p>
1014 {__(
1015 "Pick from the real fields below — these are the exact paths your receiver will see.",
1016 "yatra",
1017 )}
1018 </p>
1019 {sampleCapturedAt && (
1020 <p className="text-xs opacity-80">
1021 {__("Captured", "yatra")}{" "}
1022 {new Date(sampleCapturedAt * 1000).toLocaleString()}
1023 </p>
1024 )}
1025 </>
1026 ) : (
1027 <p>
1028 {__(
1029 'Click "Listen for sample" then perform the action that triggers this event in Yatra. We\'ll show you the real payload — no guesswork.',
1030 "yatra",
1031 )}
1032 </p>
1033 )}
1034 </div>
1035 <div className="flex flex-col gap-2 flex-shrink-0">
1036 {armed ? (
1037 <Button
1038 type="button"
1039 variant="outline"
1040 size="sm"
1041 onClick={() => disarmMutation.mutate(false)}
1042 disabled={disarmMutation.isPending}
1043 >
1044 <XCircle className="mr-1.5 h-3.5 w-3.5" />
1045 {__("Stop listening", "yatra")}
1046 </Button>
1047 ) : (
1048 <Button
1049 type="button"
1050 size="sm"
1051 onClick={() => armMutation.mutate()}
1052 disabled={armMutation.isPending}
1053 >
1054 {armMutation.isPending ? (
1055 <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
1056 ) : (
1057 <Eye className="mr-1.5 h-3.5 w-3.5" />
1058 )}
1059 {hasSample
1060 ? __("Re-capture sample", "yatra")
1061 : __("Listen for sample", "yatra")}
1062 </Button>
1063 )}
1064 {hasSample && !armed && (
1065 <Tooltip content={__("Discard the captured sample", "yatra")}>
1066 <Button
1067 type="button"
1068 variant="outline"
1069 size="sm"
1070 onClick={() => disarmMutation.mutate(true)}
1071 disabled={disarmMutation.isPending}
1072 >
1073 <Trash2 className="mr-1.5 h-3.5 w-3.5" />
1074 {__("Clear", "yatra")}
1075 </Button>
1076 </Tooltip>
1077 )}
1078 </div>
1079 </div>
1080 </Alert>
1081
1082 {/* Forwarding mode — Send everything OR pick fields. */}
1083 <label
1084 htmlFor={`webhook-send-all-${eventKey}`}
1085 className={`flex items-start gap-3 p-3 rounded-md border cursor-pointer transition-colors ${
1086 sendAll
1087 ? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20"
1088 : "border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50"
1089 }`}
1090 >
1091 <input
1092 id={`webhook-send-all-${eventKey}`}
1093 type="radio"
1094 name="webhook-field-mode"
1095 checked={sendAll}
1096 onChange={() => onChangeSendAll(true)}
1097 className="h-4 w-4 mt-0.5 flex-shrink-0"
1098 />
1099 <div className="min-w-0">
1100 <div className="text-sm font-medium text-gray-900 dark:text-white">
1101 {__("Send the full payload", "yatra")}
1102 </div>
1103 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1104 {__(
1105 "POSTs the entire envelope + data block — every field under its original key. Recommended when your receiver picks what it needs.",
1106 "yatra",
1107 )}
1108 </div>
1109 </div>
1110 </label>
1111
1112 <label
1113 htmlFor={`webhook-select-${eventKey}`}
1114 className={`flex items-start gap-3 p-3 rounded-md border cursor-pointer transition-colors ${
1115 !sendAll
1116 ? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20"
1117 : "border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50"
1118 } ${!hasSample ? "opacity-50 pointer-events-none" : ""}`}
1119 >
1120 <input
1121 id={`webhook-select-${eventKey}`}
1122 type="radio"
1123 name="webhook-field-mode"
1124 checked={!sendAll}
1125 onChange={() => onChangeSendAll(false)}
1126 disabled={!hasSample}
1127 className="h-4 w-4 mt-0.5 flex-shrink-0"
1128 />
1129 <div className="min-w-0">
1130 <div className="text-sm font-medium text-gray-900 dark:text-white">
1131 {__("Send only selected fields", "yatra")}
1132 {!hasSample && (
1133 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
1134 ({__("capture a sample first", "yatra")})
1135 </span>
1136 )}
1137 </div>
1138 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1139 {__(
1140 "Only the dot-paths you tick will ship in data{…}. Nested structure is preserved (picking customer.email keeps it nested, not flattened). Useful to strip PII or fields a downstream system bills you for.",
1141 "yatra",
1142 )}
1143 </div>
1144 </div>
1145 </label>
1146
1147 {!sendAll && hasSample && (
1148 <div className="space-y-2 pt-2 border-t border-gray-200 dark:border-gray-700">
1149 <p className="text-[11px] text-gray-500 dark:text-gray-400">
1150 {__(
1151 "Each row is a dot-path inside data{}. The value shown is from the real captured sample — your receiver will get that same key, at that same nesting.",
1152 "yatra",
1153 )}
1154 </p>
1155 <div className="flex items-center justify-between gap-2 flex-wrap">
1156 <div className="text-xs text-gray-500 dark:text-gray-400">
1157 {selectedFields.length} / {paths.length}{" "}
1158 {__("fields selected", "yatra")}
1159 </div>
1160 <div className="flex gap-1">
1161 <Button
1162 type="button"
1163 variant="outline"
1164 size="sm"
1165 onClick={onSelectAll}
1166 disabled={paths.length === 0}
1167 >
1168 {__("Select all", "yatra")}
1169 </Button>
1170 <Button
1171 type="button"
1172 variant="outline"
1173 size="sm"
1174 onClick={onClear}
1175 disabled={selectedFields.length === 0}
1176 >
1177 {__("Clear", "yatra")}
1178 </Button>
1179 </div>
1180 </div>
1181
1182 {isLoading ? (
1183 <div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-96 overflow-y-auto pr-1 animate-pulse">
1184 {Array.from({ length: 8 }).map((_, i) => (
1185 <div
1186 key={i}
1187 className="h-9 rounded bg-gray-200 dark:bg-gray-700"
1188 />
1189 ))}
1190 </div>
1191 ) : (
1192 <div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-96 overflow-y-auto pr-1">
1193 {paths.map((row) => {
1194 const checked = selectedFields.includes(row.path);
1195 return (
1196 <label
1197 key={row.path}
1198 htmlFor={`field-${row.path}`}
1199 className={`flex items-start gap-2 p-2.5 rounded-md border cursor-pointer transition-colors ${
1200 checked
1201 ? "border-blue-500 bg-blue-50/60 dark:border-blue-400 dark:bg-blue-900/20"
1202 : "border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50"
1203 }`}
1204 >
1205 <input
1206 id={`field-${row.path}`}
1207 type="checkbox"
1208 checked={checked}
1209 onChange={() => onToggle(row.path)}
1210 className="h-4 w-4 mt-0.5 rounded border-gray-300 flex-shrink-0"
1211 />
1212 <div className="min-w-0 flex-1">
1213 <code className="text-xs font-mono text-gray-900 dark:text-white break-all">
1214 {row.path}
1215 </code>
1216 <div className="text-[11px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">
1217 {formatSample(row.sample)}
1218 </div>
1219 </div>
1220 </label>
1221 );
1222 })}
1223 </div>
1224 )}
1225 </div>
1226 )}
1227
1228 {showRawPayload && samplePayload && (
1229 <div className="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2">
1230 {/* Tab strip — "Output" first because that's what operators
1231 * most need to verify: "what will my receiver actually get?" */}
1232 <div className="flex items-center justify-between gap-2 flex-wrap">
1233 <div className="inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden">
1234 <button
1235 type="button"
1236 onClick={() => setPayloadView("output")}
1237 className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1238 payloadView === "output"
1239 ? "bg-blue-600 text-white"
1240 : "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
1241 }`}
1242 >
1243 {__("Output (what your receiver gets)", "yatra")}
1244 </button>
1245 <button
1246 type="button"
1247 onClick={() => setPayloadView("raw")}
1248 className={`px-3 py-1.5 text-xs font-medium border-l border-gray-200 dark:border-gray-700 transition-colors ${
1249 payloadView === "raw"
1250 ? "bg-blue-600 text-white"
1251 : "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
1252 }`}
1253 >
1254 {__("Input (full event data)", "yatra")}
1255 </button>
1256 </div>
1257 <CopyButton
1258 text={JSON.stringify(
1259 payloadView === "output"
1260 ? applyFieldFilter(
1261 samplePayload,
1262 sendAll ? [] : selectedFields,
1263 )
1264 : samplePayload,
1265 null,
1266 2,
1267 )}
1268 />
1269 </div>
1270
1271 <pre className="text-xs font-mono bg-gray-900 text-gray-100 dark:bg-black rounded-md p-3 max-h-80 overflow-auto whitespace-pre-wrap">
1272 {JSON.stringify(
1273 payloadView === "output"
1274 ? applyFieldFilter(
1275 samplePayload,
1276 sendAll ? [] : selectedFields,
1277 )
1278 : samplePayload,
1279 null,
1280 2,
1281 )}
1282 </pre>
1283
1284 {payloadView === "output" ? (
1285 <div className="rounded-md border border-blue-200 dark:border-blue-800 bg-blue-50/60 dark:bg-blue-900/20 p-2.5">
1286 <p className="text-[11px] text-blue-800 dark:text-blue-300">
1287 {sendAll || selectedFields.length === 0
1288 ? __(
1289 "Send full payload mode — every field above ships in the POST body, exactly as shown.",
1290 "yatra",
1291 )
1292 : __(
1293 "These are the exact JSON bytes that will be POSTed to your receiver. Keys stay nested under their original paths.",
1294 "yatra",
1295 )}
1296 </p>
1297 </div>
1298 ) : (
1299 <p className="text-[11px] text-gray-500 dark:text-gray-400">
1300 {sampleSource === "captured"
1301 ? __("Captured live from a real event firing.", "yatra")
1302 : __(
1303 "Pulled from the most recent successful delivery for this event.",
1304 "yatra",
1305 )}
1306 </p>
1307 )}
1308 </div>
1309 )}
1310 </CardContent>
1311 </Card>
1312 );
1313 };
1314
1315 /* -------------------------------------------------------------------------- */
1316 /* Endpoint create / edit form */
1317 /* -------------------------------------------------------------------------- */
1318
1319 const EndpointEditForm: React.FC<{
1320 existing: WebhookEndpoint | null;
1321 events: WebhookEvent[];
1322 onClose: () => void;
1323 onCreated: (secret: string, endpointName: string) => void;
1324 }> = ({ existing, events, onClose, onCreated }) => {
1325 const queryClient = useQueryClient();
1326 const { showToast } = useToast();
1327 const isCreate = existing === null;
1328
1329 const [name, setName] = useState(existing?.name ?? "");
1330 const [url, setUrl] = useState(existing?.url ?? "");
1331 const [description, setDescription] = useState(existing?.description ?? "");
1332 // ONE event per endpoint (mirrors Email Automation's 1:1 model).
1333 // The selected_fields filter applies to this event's payload shape,
1334 // so binding multiple events here would make field selection ambiguous.
1335 const [eventKey, setEventKey] = useState<string>(existing?.event ?? "");
1336 const [showEventDropdown, setShowEventDropdown] = useState(false);
1337 // Outbound HTTP verb. POST default matches the universal webhook
1338 // convention; operators with non-POST receivers (PUT for upsert,
1339 // PATCH for partial, DELETE for object-removed mirrors, GET for
1340 // legacy polling-callback patterns) pick from the dropdown.
1341 const [httpMethod, setHttpMethod] = useState<WebhookEndpoint["http_method"]>(
1342 existing?.http_method ?? "POST",
1343 );
1344 const [isActive, setIsActive] = useState(existing?.is_active ?? true);
1345 const [logDeliveries, setLogDeliveries] = useState(
1346 existing?.log_deliveries ?? true,
1347 );
1348
1349 // Pabbly-style field filter. Empty = "send everything". Populated =
1350 // "send only these dot-paths" (envelope always preserved server-side).
1351 const [selectedFields, setSelectedFields] = useState<string[]>(
1352 existing?.selected_fields ?? [],
1353 );
1354 // "Send all data" is the default for new endpoints — toggling it off
1355 // reveals the dot-path checklist sourced from the event's sample payload.
1356 const [sendAllData, setSendAllData] = useState<boolean>(
1357 (existing?.selected_fields?.length ?? 0) === 0,
1358 );
1359
1360 // Convert the server's map → list of {key, value} rows for the
1361 // KeyValueEditor (easier to manage incremental edits + ordering).
1362 // Empty trailing row is auto-managed by the editor itself.
1363 const [headerRows, setHeaderRows] = useState<KvRow[]>(() =>
1364 objectToRows(existing?.headers ?? {}),
1365 );
1366 const [extraRows, setExtraRows] = useState<KvRow[]>(() =>
1367 objectToRows(existing?.additional_payload_fields ?? {}),
1368 );
1369 const [useCustomSecret, setUseCustomSecret] = useState(false);
1370 const [customSecret, setCustomSecret] = useState("");
1371
1372 // Headers + extras are sent as plain key→value maps; the
1373 // KeyValueEditor strips blank rows so we don't transmit junk.
1374 const buildPayload = (): WebhookEndpointWriteInput => {
1375 const payload: WebhookEndpointWriteInput = {
1376 name,
1377 url,
1378 description,
1379 event: eventKey,
1380 http_method: httpMethod,
1381 headers: rowsToObject(headerRows),
1382 additional_payload_fields: rowsToObject(extraRows),
1383 // Empty list = "send everything"; the server keeps null in DB.
1384 selected_fields: sendAllData ? [] : selectedFields,
1385 is_active: isActive,
1386 log_deliveries: logDeliveries,
1387 };
1388 if (useCustomSecret && customSecret.trim() !== "") {
1389 payload.custom_secret = customSecret.trim();
1390 }
1391 return payload;
1392 };
1393
1394 const createMutation = useMutation({
1395 mutationFn: () => webhooksApi.createEndpoint(buildPayload()),
1396 onSuccess: (res) => {
1397 queryClient.invalidateQueries({ queryKey: ["webhooks-endpoints"] });
1398 onCreated(res.secret, res.data.name);
1399 },
1400 onError: (e: any) => showToast(extractError(e), "error"),
1401 });
1402
1403 const updateMutation = useMutation({
1404 mutationFn: () => webhooksApi.updateEndpoint(existing!.id, buildPayload()),
1405 onSuccess: () => {
1406 queryClient.invalidateQueries({ queryKey: ["webhooks-endpoints"] });
1407 showToast(__("Endpoint updated.", "yatra"), "success");
1408 onClose();
1409 },
1410 onError: (e: any) => showToast(extractError(e), "error"),
1411 });
1412
1413 // Look up the currently selected event row for the dropdown label +
1414 // description box. memo'd so we don't re-scan the list on every render.
1415 const selectedEvent = useMemo(
1416 () => events.find((e) => e.key === eventKey) ?? null,
1417 [events, eventKey],
1418 );
1419
1420 // Fetch the latest REAL payload for the picked event (captured or
1421 // pulled from the delivery log). When there's no sample yet, the
1422 // form prompts the operator to start a Listen session.
1423 const sampleQuery = useQuery({
1424 queryKey: ["webhooks-event-sample", eventKey],
1425 queryFn: () => webhooksApi.getEventSample(eventKey),
1426 enabled: eventKey !== "",
1427 staleTime: 30_000,
1428 });
1429 const samplePaths = sampleQuery.data?.paths ?? [];
1430 const samplePayload = sampleQuery.data?.payload ?? null;
1431 const sampleSource = sampleQuery.data?.source ?? null;
1432 const sampleCapturedAt = sampleQuery.data?.captured_at ?? null;
1433
1434 const toggleField = (path: string) => {
1435 setSelectedFields((prev) =>
1436 prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path],
1437 );
1438 };
1439 const selectAllFields = () =>
1440 setSelectedFields(samplePaths.map((p) => p.path));
1441 const clearAllFields = () => setSelectedFields([]);
1442
1443 const customSecretValid =
1444 !useCustomSecret || customSecret.trim().length >= 24;
1445 // UX trap: if the operator picked "Send only selected fields" but
1446 // ticked zero, the server would silently treat it as "send
1447 // everything" — counter-intuitive. Block save until they either
1448 // pick at least one field or switch back to "Send full payload".
1449 const fieldSelectionValid = sendAllData || selectedFields.length > 0;
1450 const canSave =
1451 name.trim() !== "" &&
1452 url.trim() !== "" &&
1453 eventKey !== "" &&
1454 customSecretValid &&
1455 fieldSelectionValid &&
1456 !createMutation.isPending &&
1457 !updateMutation.isPending;
1458
1459 return (
1460 <div className="space-y-4">
1461 <div className="flex items-center justify-between gap-3 flex-wrap">
1462 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
1463 {isCreate
1464 ? __("Add endpoint", "yatra")
1465 : __("Edit endpoint", "yatra")}
1466 </h3>
1467 <Button variant="outline" onClick={onClose}>
1468 {__("Cancel", "yatra")}
1469 </Button>
1470 </div>
1471
1472 <Card>
1473 <CardHeader>
1474 <CardTitle>{__("Destination", "yatra")}</CardTitle>
1475 <CardDescription>
1476 {__(
1477 "HTTPS-only. We sign every request with HMAC-SHA256 — your receiver verifies the X-Yatra-Signature header against your stored secret.",
1478 "yatra",
1479 )}
1480 </CardDescription>
1481 </CardHeader>
1482 <CardContent className="space-y-4">
1483 <div>
1484 <Label htmlFor="webhook-name">{__("Name", "yatra")}</Label>
1485 <Input
1486 id="webhook-name"
1487 value={name}
1488 onChange={(e) => setName(e.target.value)}
1489 placeholder={__("Zapier — booking sync", "yatra")}
1490 className="mt-1"
1491 />
1492 </div>
1493 <div>
1494 <Label htmlFor="webhook-url">{__("URL", "yatra")}</Label>
1495 <Input
1496 id="webhook-url"
1497 type="url"
1498 value={url}
1499 onChange={(e) => setUrl(e.target.value)}
1500 placeholder="https://hooks.zapier.com/hooks/catch/12345/abcdef/"
1501 className="mt-1 font-mono text-sm"
1502 />
1503 {/* Inline scheme warning — caught at typing time so the
1504 * operator notices before saving. Plain http:// is rejected
1505 * server-side unless WP_DEBUG is on (dev-only). */}
1506 {url.trim().startsWith("http://") ? (
1507 <div className="mt-1 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-400">
1508 <AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
1509 <span>
1510 {__(
1511 "Insecure URL — http:// is only accepted in WP_DEBUG mode for local development. Production receivers must use https://.",
1512 "yatra",
1513 )}
1514 </span>
1515 </div>
1516 ) : (
1517 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1518 {__("Must use HTTPS in production.", "yatra")}
1519 </p>
1520 )}
1521 </div>
1522 <div>
1523 <Label htmlFor="webhook-description">
1524 {__("Description", "yatra")}{" "}
1525 <span className="text-xs font-normal text-gray-500">
1526 ({__("optional", "yatra")})
1527 </span>
1528 </Label>
1529 <Input
1530 id="webhook-description"
1531 value={description}
1532 onChange={(e) => setDescription(e.target.value)}
1533 placeholder={__("Internal CRM ingest — sales team", "yatra")}
1534 className="mt-1"
1535 />
1536 </div>
1537 <div>
1538 <Label htmlFor="webhook-method">{__("HTTP method", "yatra")}</Label>
1539 <Select
1540 id="webhook-method"
1541 value={httpMethod}
1542 onChange={(e) =>
1543 setHttpMethod(e.target.value as WebhookEndpoint["http_method"])
1544 }
1545 className="mt-1"
1546 >
1547 <option value="POST">
1548 POST {__("(default — recommended)", "yatra")}
1549 </option>
1550 <option value="PUT">PUT</option>
1551 <option value="PATCH">PATCH</option>
1552 <option value="DELETE">DELETE</option>
1553 <option value="GET">GET</option>
1554 </Select>
1555 {httpMethod === "GET" ? (
1556 <div className="mt-1 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-400">
1557 <AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
1558 <span>
1559 {__(
1560 "GET deliveries send the payload as a ?payload=<json> query parameter (URL-encoded). The HMAC signature is then computed over an empty body — receivers should reconstruct the signed string as `timestamp + '.' + ''`. Only use GET when the receiver explicitly requires it.",
1561 "yatra",
1562 )}
1563 </span>
1564 </div>
1565 ) : (
1566 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1567 {__(
1568 "POST is the universal webhook convention. PUT / PATCH / DELETE are for receivers that explicitly require them (upsert, partial update, object-removal mirrors).",
1569 "yatra",
1570 )}
1571 </p>
1572 )}
1573 </div>
1574 <div className="flex items-center justify-between gap-3 rounded-md border border-gray-200 dark:border-gray-700 p-3">
1575 <Label
1576 htmlFor="webhook-is-active"
1577 className="font-normal cursor-pointer"
1578 >
1579 <span className="block text-sm font-medium text-gray-900 dark:text-white">
1580 {__("Active", "yatra")}
1581 </span>
1582 <span className="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1583 {__(
1584 "Start delivering events as soon as this is saved.",
1585 "yatra",
1586 )}
1587 </span>
1588 </Label>
1589 <Switch
1590 checked={isActive}
1591 onCheckedChange={setIsActive}
1592 className="flex-shrink-0"
1593 />
1594 </div>
1595
1596 {/* log_deliveries — high-volume endpoints turn off body
1597 * persistence so the delivery log doesn't bloat. Status,
1598 * duration, attempts still recorded so health metrics
1599 * + retry semantics stay intact. */}
1600 <div className="flex items-center justify-between gap-3 rounded-md border border-gray-200 dark:border-gray-700 p-3">
1601 <Label
1602 htmlFor="webhook-log-deliveries"
1603 className="font-normal cursor-pointer"
1604 >
1605 <span className="block text-sm font-medium text-gray-900 dark:text-white">
1606 {__("Log delivery payloads", "yatra")}
1607 </span>
1608 <span className="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1609 {logDeliveries
1610 ? __(
1611 "Stores the full POST body + receiver response on every attempt (90-day retention). Recommended for debugging.",
1612 "yatra",
1613 )
1614 : __(
1615 "Only metadata (status, duration, attempts) is stored. Saves disk on high-volume endpoints, but the inspector won't show payload or response bodies.",
1616 "yatra",
1617 )}
1618 </span>
1619 </Label>
1620 <Switch
1621 checked={logDeliveries}
1622 onCheckedChange={setLogDeliveries}
1623 className="flex-shrink-0"
1624 />
1625 </div>
1626 </CardContent>
1627 </Card>
1628
1629 {/* Trigger event — single select, matches the Email Automation
1630 * picker UX exactly (Zap icon button, indigo description box). */}
1631 <Card>
1632 <CardHeader>
1633 <CardTitle>{__("Trigger event", "yatra")}</CardTitle>
1634 <CardDescription>
1635 {__(
1636 "Pick the single event that triggers this endpoint. The field selector below will then show you the exact payload shape so you can choose which data to forward.",
1637 "yatra",
1638 )}
1639 </CardDescription>
1640 </CardHeader>
1641 <CardContent>
1642 <div className="relative">
1643 <button
1644 type="button"
1645 onClick={() => setShowEventDropdown(!showEventDropdown)}
1646 className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-left flex items-center justify-between hover:border-gray-400 dark:hover:border-gray-500 transition-colors"
1647 >
1648 {selectedEvent ? (
1649 <div className="flex items-center gap-2 min-w-0">
1650 <Zap className="w-4 h-4 text-blue-500 flex-shrink-0" />
1651 <span className="text-sm font-medium text-gray-900 dark:text-white truncate">
1652 {selectedEvent.name}
1653 </span>
1654 <code className="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded font-mono">
1655 {selectedEvent.key}
1656 </code>
1657 </div>
1658 ) : (
1659 <div className="flex items-center gap-2">
1660 <Zap className="w-4 h-4 text-gray-400" />
1661 <span className="text-sm text-gray-500 dark:text-gray-400">
1662 {__("Choose an event…", "yatra")}
1663 </span>
1664 </div>
1665 )}
1666 <ChevronDown
1667 className={`w-4 h-4 text-gray-400 transition-transform ${
1668 showEventDropdown ? "rotate-180" : ""
1669 }`}
1670 />
1671 </button>
1672
1673 {showEventDropdown && (
1674 <div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-80 overflow-y-auto">
1675 <div className="p-2 border-b border-gray-200 dark:border-gray-700">
1676 <p className="text-xs text-gray-500 dark:text-gray-400 px-2">
1677 {__(
1678 "Select the Yatra event that should fire this webhook",
1679 "yatra",
1680 )}
1681 </p>
1682 </div>
1683 <div className="py-1">
1684 {events.map((event) => (
1685 <button
1686 key={event.key}
1687 type="button"
1688 onClick={() => {
1689 setEventKey(event.key);
1690 setShowEventDropdown(false);
1691 // Reset field selection when the event changes —
1692 // selected paths for the OLD event are irrelevant
1693 // to the new event's payload shape.
1694 if (event.key !== eventKey) {
1695 setSelectedFields([]);
1696 setSendAllData(true);
1697 }
1698 }}
1699 className={`w-full px-3 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
1700 eventKey === event.key
1701 ? "bg-indigo-50 dark:bg-indigo-900/20"
1702 : ""
1703 }`}
1704 >
1705 <div className="flex items-start gap-2">
1706 <Zap
1707 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
1708 eventKey === event.key
1709 ? "text-blue-500"
1710 : "text-gray-400"
1711 }`}
1712 />
1713 <div className="flex-1 min-w-0">
1714 <div className="flex items-center gap-2">
1715 <span
1716 className={`text-sm font-medium ${
1717 eventKey === event.key
1718 ? "text-blue-800 dark:text-blue-400"
1719 : "text-gray-900 dark:text-white"
1720 }`}
1721 >
1722 {event.name}
1723 </span>
1724 <code className="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">
1725 {event.key}
1726 </code>
1727 </div>
1728 {event.description && (
1729 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
1730 {event.description}
1731 </p>
1732 )}
1733 </div>
1734 {eventKey === event.key && (
1735 <Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
1736 )}
1737 </div>
1738 </button>
1739 ))}
1740 </div>
1741 </div>
1742 )}
1743
1744 {showEventDropdown && (
1745 <div
1746 className="fixed inset-0 z-40"
1747 onClick={() => setShowEventDropdown(false)}
1748 />
1749 )}
1750 </div>
1751
1752 {selectedEvent?.description && (
1753 <div className="mt-3 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
1754 <p className="text-sm text-indigo-800 dark:text-indigo-300">
1755 {selectedEvent.description}
1756 </p>
1757 </div>
1758 )}
1759 </CardContent>
1760 </Card>
1761
1762 {/* Pabbly-style field selector — only shown once an event is
1763 * picked, because the dot-path list depends on the event's
1764 * payload shape. */}
1765 {eventKey !== "" && (
1766 <FieldSelectorCard
1767 eventKey={eventKey}
1768 eventName={selectedEvent?.name ?? eventKey}
1769 paths={samplePaths}
1770 isLoading={sampleQuery.isLoading}
1771 sendAll={sendAllData}
1772 onChangeSendAll={(next) => {
1773 setSendAllData(next);
1774 if (next) setSelectedFields([]);
1775 }}
1776 selectedFields={selectedFields}
1777 onToggle={toggleField}
1778 onSelectAll={selectAllFields}
1779 onClear={clearAllFields}
1780 samplePayload={samplePayload}
1781 sampleSource={sampleSource}
1782 sampleCapturedAt={sampleCapturedAt}
1783 />
1784 )}
1785
1786 <Card>
1787 <CardHeader>
1788 <CardTitle>{__("Custom HTTP headers", "yatra")}</CardTitle>
1789 <CardDescription>
1790 {__(
1791 "Sent alongside Yatra's signed headers on every request. Useful for receivers that require Bearer / Basic auth on TOP of the HMAC signature, or for tagging requests for routing. Reserved headers (X-Yatra-*, Content-Length, Host, Cookie) are blocked.",
1792 "yatra",
1793 )}
1794 </CardDescription>
1795 </CardHeader>
1796 <CardContent>
1797 <KeyValueEditor
1798 rows={headerRows}
1799 onChange={setHeaderRows}
1800 keyPlaceholder={__("Header name (e.g. Authorization)", "yatra")}
1801 valuePlaceholder={__("Header value (e.g. Bearer xyz)", "yatra")}
1802 idPrefix="webhook-header"
1803 />
1804 </CardContent>
1805 </Card>
1806
1807 <Card>
1808 <CardHeader>
1809 <CardTitle>{__("Additional payload fields", "yatra")}</CardTitle>
1810 <CardDescription>
1811 {__(
1812 "Static fields merged into every payload's \"data\" block. Use this to tag events with operator-specific metadata (tenant_id, environment, region, source_app) without writing a server-side filter. Operator fields never overwrite Yatra's canonical entity fields.",
1813 "yatra",
1814 )}
1815 </CardDescription>
1816 </CardHeader>
1817 <CardContent>
1818 <KeyValueEditor
1819 rows={extraRows}
1820 onChange={setExtraRows}
1821 keyPlaceholder={__("Field key (e.g. tenant_id)", "yatra")}
1822 valuePlaceholder={__("Static value (e.g. acme-prod)", "yatra")}
1823 idPrefix="webhook-extra"
1824 />
1825 </CardContent>
1826 </Card>
1827
1828 {/* Custom signing secret — only on create (rotate via the
1829 * Regenerate icon on the list view for existing endpoints, OR
1830 * by toggling this on while editing). */}
1831 <Card>
1832 <CardHeader>
1833 <CardTitle className="flex items-center gap-2">
1834 <ShieldCheck className="h-4 w-4 text-green-500" />
1835 {__("Signing secret", "yatra")}
1836 </CardTitle>
1837 <CardDescription>
1838 {isCreate
1839 ? __(
1840 "By default Yatra generates a strong random 32-byte signing secret. If your receiver already has a secret configured (e.g. you're migrating from another system), paste it below — minimum 24 characters.",
1841 "yatra",
1842 )
1843 : __(
1844 "Leave unchecked to keep the existing secret. Check to rotate to a new one — receivers using the old secret will start failing signature verification until you update them.",
1845 "yatra",
1846 )}
1847 </CardDescription>
1848 </CardHeader>
1849 <CardContent className="space-y-3">
1850 <div className="flex items-center justify-between gap-3 rounded-md border border-gray-200 dark:border-gray-700 p-3">
1851 <Label
1852 htmlFor="webhook-use-custom-secret"
1853 className="font-normal cursor-pointer"
1854 >
1855 <span className="block text-sm font-medium text-gray-900 dark:text-white">
1856 {isCreate
1857 ? __("Use my own signing secret", "yatra")
1858 : __("Rotate to a new signing secret", "yatra")}
1859 </span>
1860 <span className="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1861 {isCreate
1862 ? __(
1863 "Provide a 24+ character secret instead of letting Yatra auto-generate one.",
1864 "yatra",
1865 )
1866 : __(
1867 "Replaces the current secret. Receivers using the old one will start failing signature verification.",
1868 "yatra",
1869 )}
1870 </span>
1871 </Label>
1872 <Switch
1873 checked={useCustomSecret}
1874 onCheckedChange={(next) => {
1875 setUseCustomSecret(next);
1876 if (!next) setCustomSecret("");
1877 }}
1878 className="flex-shrink-0"
1879 />
1880 </div>
1881 {useCustomSecret && (
1882 <div>
1883 <Input
1884 id="webhook-custom-secret"
1885 type="password"
1886 value={customSecret}
1887 onChange={(e) => setCustomSecret(e.target.value)}
1888 placeholder={__(
1889 "Paste your signing secret (min 24 chars)",
1890 "yatra",
1891 )}
1892 className="font-mono text-sm"
1893 aria-describedby="webhook-custom-secret-help"
1894 />
1895 <p
1896 id="webhook-custom-secret-help"
1897 className="mt-1 text-xs text-gray-500 dark:text-gray-400"
1898 >
1899 {customSecret.length > 0 && customSecret.length < 24 ? (
1900 <span className="text-amber-600 dark:text-amber-400">
1901 {__("Too short — need at least 24 characters.", "yatra")} (
1902 {customSecret.length}/24)
1903 </span>
1904 ) : (
1905 __(
1906 "Stored encrypted at rest. Treat it like a password.",
1907 "yatra",
1908 )
1909 )}
1910 </p>
1911 </div>
1912 )}
1913 </CardContent>
1914 </Card>
1915
1916 <div className="flex justify-end gap-2">
1917 <Button variant="outline" onClick={onClose}>
1918 {__("Cancel", "yatra")}
1919 </Button>
1920 <Button
1921 disabled={!canSave}
1922 onClick={() =>
1923 isCreate ? createMutation.mutate() : updateMutation.mutate()
1924 }
1925 >
1926 {createMutation.isPending || updateMutation.isPending ? (
1927 <>
1928 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
1929 {__("Saving…", "yatra")}
1930 </>
1931 ) : isCreate ? (
1932 __("Create endpoint", "yatra")
1933 ) : (
1934 __("Save changes", "yatra")
1935 )}
1936 </Button>
1937 </div>
1938 </div>
1939 );
1940 };
1941
1942 /* -------------------------------------------------------------------------- */
1943 /* Secret-reveal copy-once dialog */
1944 /* -------------------------------------------------------------------------- */
1945
1946 const SecretRevealDialog: React.FC<{
1947 secret: string | null;
1948 endpointName: string;
1949 onClose: () => void;
1950 }> = ({ secret, endpointName, onClose }) => {
1951 const [copied, setCopied] = useState(false);
1952 const [snippetLang, setSnippetLang] = useState<"php" | "node" | "python">(
1953 "php",
1954 );
1955
1956 const doCopy = async () => {
1957 if (!secret) return;
1958 try {
1959 await navigator.clipboard.writeText(secret);
1960 setCopied(true);
1961 window.setTimeout(() => setCopied(false), 2000);
1962 } catch (_e) {
1963 // No-op — operator can select-all manually.
1964 }
1965 };
1966
1967 if (!secret) return null;
1968
1969 // Receiver verification snippets — operator pastes into their handler.
1970 // Stripe-style "t=<unix>,v1=<hex>" signature header verification.
1971 const snippets: Record<"php" | "node" | "python", string> = {
1972 php: `// PHP receiver verification
1973 $signature = $_SERVER['HTTP_X_YATRA_SIGNATURE'] ?? '';
1974 $body = file_get_contents('php://input');
1975 $parts = [];
1976 foreach (explode(',', $signature) as $kv) {
1977 [$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
1978 $parts[$k] = $v;
1979 }
1980 $ts = (int) ($parts['t'] ?? 0);
1981 $sig = $parts['v1'] ?? '';
1982 // Reject replays > 5 min old
1983 if (abs(time() - $ts) > 300) { http_response_code(400); exit; }
1984 $expected = hash_hmac('sha256', $ts . '.' . $body, '${secret}');
1985 if (!hash_equals($expected, $sig)) { http_response_code(401); exit; }
1986 // Signature valid — process json_decode($body, true)`,
1987 node: `// Node.js (Express) receiver verification
1988 import crypto from 'crypto';
1989 app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
1990 const sig = req.header('x-yatra-signature') || '';
1991 const parts = Object.fromEntries(
1992 sig.split(',').map(kv => kv.split('=', 2))
1993 );
1994 const ts = parseInt(parts.t || '0', 10);
1995 // Reject replays > 5 min old
1996 if (Math.abs(Date.now() / 1000 - ts) > 300) return res.status(400).end();
1997 const expected = crypto
1998 .createHmac('sha256', '${secret}')
1999 .update(\`\${ts}.\${req.body}\`)
2000 .digest('hex');
2001 if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ''))) {
2002 return res.status(401).end();
2003 }
2004 const event = JSON.parse(req.body);
2005 // …handle event
2006 });`,
2007 python: `# Python (Flask) receiver verification
2008 import hmac, hashlib, time
2009 from flask import request, abort
2010
2011 @app.route('/webhook', methods=['POST'])
2012 def webhook():
2013 sig = request.headers.get('X-Yatra-Signature', '')
2014 parts = dict(kv.split('=', 1) for kv in sig.split(',') if '=' in kv)
2015 ts = int(parts.get('t', '0'))
2016 if abs(time.time() - ts) > 300:
2017 abort(400) # replay protection
2018 expected = hmac.new(
2019 b'${secret}',
2020 f"{ts}.{request.data.decode()}".encode(),
2021 hashlib.sha256
2022 ).hexdigest()
2023 if not hmac.compare_digest(expected, parts.get('v1', '')):
2024 abort(401)
2025 event = request.get_json()
2026 # …handle event`,
2027 };
2028
2029 return (
2030 <Modal
2031 isOpen
2032 onClose={onClose}
2033 title={
2034 <div className="flex items-center gap-2">
2035 <ShieldCheck className="w-5 h-5 text-green-500" />
2036 {__("Signing secret", "yatra")}
2037 </div>
2038 }
2039 size="lg"
2040 hideFooter={false}
2041 footer={
2042 <div className="flex justify-end gap-2">
2043 <Button onClick={onClose}>{__("I've saved it", "yatra")}</Button>
2044 </div>
2045 }
2046 >
2047 <div className="space-y-4">
2048 <p className="text-sm text-gray-700 dark:text-gray-200">
2049 {__("Below is the signing secret for ", "yatra")}
2050 <strong>{endpointName}</strong>.{" "}
2051 {__(
2052 "It will not be shown again — copy it now and store it in your receiver's configuration. If you lose it, you'll need to regenerate (which invalidates the old one).",
2053 "yatra",
2054 )}
2055 </p>
2056 <div className="flex gap-2">
2057 <Input
2058 value={secret}
2059 readOnly
2060 onFocus={(e) => e.currentTarget.select()}
2061 className="font-mono text-sm"
2062 aria-label={__("Signing secret value", "yatra")}
2063 />
2064 <Button onClick={doCopy} variant="outline">
2065 {copied ? (
2066 <>
2067 <Check className="mr-1.5 h-4 w-4" />
2068 {__("Copied", "yatra")}
2069 </>
2070 ) : (
2071 <>
2072 <Copy className="mr-1.5 h-4 w-4" />
2073 {__("Copy", "yatra")}
2074 </>
2075 )}
2076 </Button>
2077 </div>
2078
2079 {/* Receiver verification snippet — Stripe / GitHub UX:
2080 * reduces "how do I verify the signature?" support tickets
2081 * by handing the operator working code. */}
2082 <div>
2083 <div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
2084 <Label className="text-sm font-semibold">
2085 {__("Verify the signature on your receiver", "yatra")}
2086 </Label>
2087 <div className="flex gap-1">
2088 {(["php", "node", "python"] as const).map((lang) => (
2089 <button
2090 key={lang}
2091 type="button"
2092 onClick={() => setSnippetLang(lang)}
2093 className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
2094 snippetLang === lang
2095 ? "bg-blue-600 text-white"
2096 : "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
2097 }`}
2098 >
2099 {lang.toUpperCase()}
2100 </button>
2101 ))}
2102 <CopyButton text={snippets[snippetLang]} />
2103 </div>
2104 </div>
2105 <pre className="bg-gray-900 text-gray-100 dark:bg-black rounded-md p-3 text-[11px] font-mono overflow-auto max-h-64 whitespace-pre-wrap">
2106 {snippets[snippetLang]}
2107 </pre>
2108 </div>
2109
2110 <div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-md p-3">
2111 <div className="flex gap-2 text-xs text-amber-700 dark:text-amber-400">
2112 <AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
2113 <div>
2114 {__(
2115 "Treat this secret like a password. Anyone with it can forge requests that appear to come from Yatra.",
2116 "yatra",
2117 )}
2118 </div>
2119 </div>
2120 </div>
2121 </div>
2122 </Modal>
2123 );
2124 };
2125
2126 /* -------------------------------------------------------------------------- */
2127 /* Deliveries tab — "listen mode" / payload inspector */
2128 /* -------------------------------------------------------------------------- */
2129
2130 const DeliveriesTab: React.FC = () => {
2131 const [page, setPage] = useState(1);
2132 const [statusFilter, setStatusFilter] = useState<string>("");
2133 const [endpointFilter, setEndpointFilter] = useState<number>(0);
2134 const [eventFilter, setEventFilter] = useState<string>("");
2135 const [inspectId, setInspectId] = useState<number | null>(null);
2136 const perPage = 25;
2137
2138 React.useEffect(() => {
2139 setPage(1);
2140 }, [statusFilter, endpointFilter, eventFilter]);
2141
2142 const { data, isLoading } = useQuery({
2143 queryKey: [
2144 "webhooks-deliveries",
2145 page,
2146 perPage,
2147 statusFilter,
2148 endpointFilter,
2149 eventFilter,
2150 ],
2151 queryFn: () =>
2152 webhooksApi.listDeliveries({
2153 page,
2154 per_page: perPage,
2155 ...(statusFilter ? { status: statusFilter } : {}),
2156 ...(endpointFilter ? { endpoint_id: endpointFilter } : {}),
2157 ...(eventFilter ? { event_key: eventFilter } : {}),
2158 }),
2159 refetchInterval: 10000,
2160 placeholderData: (prev) => prev,
2161 });
2162 const { data: endpointsData } = useQuery({
2163 queryKey: ["webhooks-endpoints"],
2164 queryFn: () => webhooksApi.listEndpoints(),
2165 });
2166 const { data: eventsData } = useQuery({
2167 queryKey: ["webhooks-events"],
2168 queryFn: () => webhooksApi.listEvents(),
2169 });
2170
2171 const queryClient = useQueryClient();
2172 const { showToast } = useToast();
2173 const replayMutation = useMutation({
2174 mutationFn: (id: number) => webhooksApi.replayDelivery(id),
2175 onSuccess: () => {
2176 queryClient.invalidateQueries({ queryKey: ["webhooks-deliveries"] });
2177 showToast(__("Delivery re-queued.", "yatra"), "success");
2178 },
2179 onError: (e: any) => showToast(extractError(e), "error"),
2180 });
2181
2182 const rows = data?.data ?? [];
2183 const total = data?.total ?? 0;
2184 const totalPages = Math.max(1, Math.ceil(total / perPage));
2185 const hasFilters = !!(statusFilter || endpointFilter || eventFilter);
2186
2187 return (
2188 <div className="space-y-4">
2189 <div>
2190 <h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
2191 <Clock className="w-5 h-5 text-blue-500" />
2192 {__("Delivery log", "yatra")}
2193 </h3>
2194 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
2195 {__(
2196 "Every outbound POST attempt with status, response, and the full payload that was sent. Click any row to inspect the JSON your receiver got.",
2197 "yatra",
2198 )}
2199 </p>
2200 </div>
2201
2202 <Card>
2203 <CardContent className="p-3">
2204 <div className="flex flex-col lg:flex-row lg:items-center gap-2">
2205 <Select
2206 value={String(endpointFilter)}
2207 onChange={(e) => setEndpointFilter(Number(e.target.value))}
2208 aria-label={__("Filter by endpoint", "yatra")}
2209 className="w-full lg:w-64 lg:flex-none"
2210 >
2211 <option value="0">{__("All endpoints", "yatra")}</option>
2212 {(endpointsData?.data ?? []).map((ep) => (
2213 <option key={ep.id} value={ep.id}>
2214 {ep.name}
2215 </option>
2216 ))}
2217 </Select>
2218 <Select
2219 value={eventFilter}
2220 onChange={(e) => setEventFilter(e.target.value)}
2221 aria-label={__("Filter by event", "yatra")}
2222 className="w-full lg:w-56 lg:flex-none"
2223 >
2224 <option value="">{__("All events", "yatra")}</option>
2225 {(eventsData?.data ?? []).map((ev) => (
2226 <option key={ev.key} value={ev.key}>
2227 {ev.name} ({ev.key})
2228 </option>
2229 ))}
2230 </Select>
2231 <Select
2232 value={statusFilter}
2233 onChange={(e) => setStatusFilter(e.target.value)}
2234 aria-label={__("Filter by status", "yatra")}
2235 className="w-full lg:w-48 lg:flex-none"
2236 >
2237 <option value="">{__("All statuses", "yatra")}</option>
2238 <option value="queued">{__("Queued", "yatra")}</option>
2239 <option value="delivering">{__("Delivering", "yatra")}</option>
2240 <option value="delivered">{__("Delivered", "yatra")}</option>
2241 <option value="failed">{__("Failed (retrying)", "yatra")}</option>
2242 <option value="permanent_failure">
2243 {__("Permanent failure", "yatra")}
2244 </option>
2245 </Select>
2246 {hasFilters && (
2247 <Button
2248 variant="outline"
2249 onClick={() => {
2250 setStatusFilter("");
2251 setEndpointFilter(0);
2252 setEventFilter("");
2253 }}
2254 className="w-full lg:w-auto"
2255 >
2256 {__("Reset", "yatra")}
2257 </Button>
2258 )}
2259 </div>
2260 </CardContent>
2261 </Card>
2262
2263 <Card className="overflow-visible">
2264 <CardContent className="p-0 overflow-visible">
2265 <SharedTable
2266 data={rows}
2267 columns={[
2268 {
2269 key: "event_key",
2270 label: __("Event", "yatra"),
2271 render: (d: WebhookDeliveryRow) => {
2272 const ep = endpointsData?.data.find(
2273 (e) => e.id === d.endpoint_id,
2274 );
2275 return (
2276 <div className="min-w-0">
2277 {/* Clicking the event/endpoint name opens the inspector
2278 * the same way clicking an activity name opens its edit. */}
2279 <button
2280 type="button"
2281 onClick={() => setInspectId(d.id)}
2282 className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors cursor-pointer text-left"
2283 >
2284 <span className="font-mono text-xs">{d.event_key}</span>
2285 </button>
2286 <div
2287 className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate max-w-md"
2288 title={ep?.url || ""}
2289 >
2290 → {ep?.name ?? `#${d.endpoint_id}`}
2291 </div>
2292 </div>
2293 );
2294 },
2295 },
2296 {
2297 key: "status",
2298 label: __("Status", "yatra"),
2299 render: (d: WebhookDeliveryRow) => (
2300 <StatusBadge status={d.status} httpStatus={d.http_status} />
2301 ),
2302 },
2303 {
2304 key: "attempts",
2305 label: __("Attempts", "yatra"),
2306 render: (d: WebhookDeliveryRow) => (
2307 <span className="text-sm text-gray-600 dark:text-gray-300">
2308 {d.attempts}
2309 </span>
2310 ),
2311 },
2312 {
2313 key: "duration_ms",
2314 label: __("Duration", "yatra"),
2315 render: (d: WebhookDeliveryRow) =>
2316 d.duration_ms !== null ? (
2317 <span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
2318 {d.duration_ms} ms
2319 </span>
2320 ) : (
2321 <span className="text-xs text-gray-400">—</span>
2322 ),
2323 },
2324 {
2325 key: "created_at",
2326 label: __("Created", "yatra"),
2327 render: (d: WebhookDeliveryRow) => (
2328 <span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
2329 {d.created_at
2330 ? new Date(d.created_at).toLocaleString()
2331 : "—"}
2332 </span>
2333 ),
2334 },
2335 ]}
2336 actions={[
2337 {
2338 key: "inspect",
2339 label: __("Inspect payload + response", "yatra"),
2340 icon: <Eye className="w-4 h-4" />,
2341 onClick: (d: WebhookDeliveryRow) => setInspectId(d.id),
2342 },
2343 {
2344 key: "replay",
2345 label: __("Replay delivery", "yatra"),
2346 icon: <RotateCcw className="w-4 h-4" />,
2347 onClick: (d: WebhookDeliveryRow) => replayMutation.mutate(d.id),
2348 condition: (d: WebhookDeliveryRow) =>
2349 d.status === "failed" || d.status === "permanent_failure",
2350 },
2351 ]}
2352 isLoading={isLoading}
2353 emptyText={
2354 total === 0 && !hasFilters
2355 ? __("No deliveries yet", "yatra")
2356 : __("No matches", "yatra")
2357 }
2358 emptyDescription={
2359 total === 0 && !hasFilters
2360 ? __(
2361 "Deliveries appear here once an event fires. Use the Ping button on an endpoint to fire a test event.",
2362 "yatra",
2363 )
2364 : __("Try adjusting your filters to see more results.", "yatra")
2365 }
2366 />
2367 {rows.length > 0 && totalPages > 1 && (
2368 <div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
2369 <Pagination
2370 currentPage={page}
2371 totalPages={totalPages}
2372 totalItems={total}
2373 itemsPerPage={perPage}
2374 onPageChange={setPage}
2375 itemName={__("deliveries", "yatra")}
2376 />
2377 </div>
2378 )}
2379 </CardContent>
2380 </Card>
2381
2382 <DeliveryInspectDialog
2383 id={inspectId}
2384 onClose={() => setInspectId(null)}
2385 />
2386 </div>
2387 );
2388 };
2389
2390 /* -------------------------------------------------------------------------- */
2391 /* mTLS dialog — upload / inspect / remove client cert per endpoint */
2392 /* */
2393 /* Three states: */
2394 /* - configured=false: empty form (paste cert + key + optional passphrase) */
2395 /* - configured=true: fingerprint + expiry hint + "Replace" / "Remove" */
2396 /* - mid-mutation: spinner */
2397 /* */
2398 /* The cert+key never leave the server roundtrip — we paste, POST, then */
2399 /* drop the strings from React state. Read-back only returns the hint. */
2400 /* -------------------------------------------------------------------------- */
2401
2402 const MtlsDialog: React.FC<{
2403 endpoint: WebhookEndpoint | null;
2404 onClose: () => void;
2405 }> = ({ endpoint, onClose }) => {
2406 const queryClient = useQueryClient();
2407 const { showToast } = useToast();
2408 const [cert, setCert] = useState("");
2409 const [keyPem, setKeyPem] = useState("");
2410 const [passphrase, setPassphrase] = useState("");
2411 const [mode, setMode] = useState<"view" | "edit">("view");
2412
2413 const enabled = endpoint !== null;
2414 const { data, isLoading } = useQuery({
2415 queryKey: ["webhook-mtls", endpoint?.id],
2416 queryFn: () => webhooksApi.getMtlsHint(endpoint!.id),
2417 enabled,
2418 });
2419 const hint = data?.data;
2420
2421 // Reset form whenever the dialog opens for a new endpoint.
2422 React.useEffect(() => {
2423 if (endpoint) {
2424 setCert("");
2425 setKeyPem("");
2426 setPassphrase("");
2427 setMode(hint?.configured ? "view" : "edit");
2428 }
2429 // eslint-disable-next-line react-hooks/exhaustive-deps
2430 }, [endpoint?.id, hint?.configured]);
2431
2432 const save = useMutation({
2433 mutationFn: () =>
2434 webhooksApi.setMtls(endpoint!.id, { cert, key: keyPem, passphrase }),
2435 onSuccess: (r) => {
2436 showToast(r.message, "success");
2437 setCert("");
2438 setKeyPem("");
2439 setPassphrase("");
2440 setMode("view");
2441 void queryClient.invalidateQueries({
2442 queryKey: ["webhook-mtls", endpoint!.id],
2443 });
2444 },
2445 onError: (e: any) => showToast(extractError(e), "error"),
2446 });
2447
2448 const clear = useMutation({
2449 mutationFn: () => webhooksApi.clearMtls(endpoint!.id),
2450 onSuccess: (r) => {
2451 showToast(r.message, "success");
2452 void queryClient.invalidateQueries({
2453 queryKey: ["webhook-mtls", endpoint!.id],
2454 });
2455 onClose();
2456 },
2457 onError: (e: any) => showToast(extractError(e), "error"),
2458 });
2459
2460 if (!endpoint) return null;
2461
2462 return (
2463 <Modal
2464 isOpen
2465 onClose={onClose}
2466 title={__("Client certificate (mTLS)", "yatra")}
2467 size="xl"
2468 >
2469 <div className="space-y-4">
2470 <Alert variant="info">
2471 {__(
2472 "Optional. Some receivers require Yatra to authenticate at the TLS handshake with a client certificate, in addition to the HMAC signature on the body. Paste your PEM-encoded cert and private key below. The pair is encrypted at rest and used only at delivery time.",
2473 "yatra",
2474 )}
2475 </Alert>
2476
2477 {isLoading ? (
2478 <div className="flex justify-center py-6">
2479 <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
2480 </div>
2481 ) : mode === "view" && hint?.configured ? (
2482 <div className="rounded-md border border-gray-200 dark:border-gray-700 p-4 space-y-3">
2483 <div className="flex items-center gap-2 text-sm">
2484 <CheckCircle2 className="h-4 w-4 text-green-500" />
2485 <span className="font-medium text-gray-900 dark:text-white">
2486 {__("Client certificate configured", "yatra")}
2487 </span>
2488 </div>
2489 <div className="space-y-1 text-xs">
2490 <div className="flex gap-2">
2491 <span className="text-gray-500 w-24 flex-shrink-0">
2492 {__("Fingerprint", "yatra")}
2493 </span>
2494 <code className="font-mono break-all text-gray-700 dark:text-gray-300">
2495 {hint.fingerprint || __("(unavailable)", "yatra")}
2496 </code>
2497 </div>
2498 <div className="flex gap-2">
2499 <span className="text-gray-500 w-24 flex-shrink-0">
2500 {__("Expires", "yatra")}
2501 </span>
2502 <span className="text-gray-700 dark:text-gray-300">
2503 {hint.expires_at
2504 ? new Date(hint.expires_at).toLocaleString()
2505 : __("(unknown)", "yatra")}
2506 </span>
2507 </div>
2508 </div>
2509 <div className="flex gap-2 pt-1">
2510 <Button variant="outline" onClick={() => setMode("edit")}>
2511 {__("Replace certificate", "yatra")}
2512 </Button>
2513 <Button
2514 variant="destructive"
2515 onClick={() => clear.mutate()}
2516 disabled={clear.isPending}
2517 >
2518 {clear.isPending ? (
2519 <Loader2 className="h-4 w-4 mr-1 animate-spin" />
2520 ) : (
2521 <Trash2 className="h-4 w-4 mr-1" />
2522 )}
2523 {__("Remove certificate", "yatra")}
2524 </Button>
2525 </div>
2526 </div>
2527 ) : (
2528 <div className="space-y-3">
2529 <div>
2530 <Label htmlFor="mtls-cert">
2531 {__("Certificate (PEM)", "yatra")}
2532 </Label>
2533 <textarea
2534 id="mtls-cert"
2535 className="mt-1 w-full font-mono text-xs px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 min-h-[140px]"
2536 value={cert}
2537 onChange={(e) => setCert(e.target.value)}
2538 placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
2539 />
2540 </div>
2541 <div>
2542 <Label htmlFor="mtls-key">
2543 {__("Private key (PEM)", "yatra")}
2544 </Label>
2545 <textarea
2546 id="mtls-key"
2547 className="mt-1 w-full font-mono text-xs px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 min-h-[140px]"
2548 value={keyPem}
2549 onChange={(e) => setKeyPem(e.target.value)}
2550 placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
2551 />
2552 </div>
2553 <div>
2554 <Label htmlFor="mtls-pass">
2555 {__("Key passphrase (optional)", "yatra")}
2556 </Label>
2557 <Input
2558 id="mtls-pass"
2559 type="password"
2560 value={passphrase}
2561 onChange={(e) => setPassphrase(e.target.value)}
2562 placeholder={__(
2563 "Leave blank if the key is unencrypted",
2564 "yatra",
2565 )}
2566 />
2567 </div>
2568 <div className="flex justify-end gap-2 pt-2">
2569 {hint?.configured && (
2570 <Button variant="outline" onClick={() => setMode("view")}>
2571 {__("Cancel", "yatra")}
2572 </Button>
2573 )}
2574 <Button
2575 onClick={() => save.mutate()}
2576 disabled={!cert || !keyPem || save.isPending}
2577 >
2578 {save.isPending && (
2579 <Loader2 className="h-4 w-4 mr-1 animate-spin" />
2580 )}
2581 {__("Save certificate", "yatra")}
2582 </Button>
2583 </div>
2584 <p className="text-xs text-gray-500">
2585 {__(
2586 "The certificate and key are validated together before storage — if they don't match, the save is rejected. Stored encrypted with libsodium / OpenSSL AES-256-GCM.",
2587 "yatra",
2588 )}
2589 </p>
2590 </div>
2591 )}
2592 </div>
2593 </Modal>
2594 );
2595 };
2596
2597 /* -------------------------------------------------------------------------- */
2598 /* Buried (dead-letter) tab */
2599 /* */
2600 /* Read+write surface for every delivery in `permanent_failure`. The */
2601 /* backend returns a pre-aggregated summary (top 10 by endpoint / event / */
2602 /* error fingerprint) so the operator triages "what's broken right now" */
2603 /* before deciding what to replay. Three replay paths: */
2604 /* 1. By endpoint: "re-fire everything buried for endpoint X" */
2605 /* 2. By error: "re-fire everything matching this error pattern" */
2606 /* 3. By explicit ids: pick rows from the recent list */
2607 /* */
2608 /* All three converge on POST /webhooks/deliveries/bulk-replay — the */
2609 /* server caps at 200 ids / 500 filtered, skips inactive endpoints, and */
2610 /* returns per-category counts the UI surfaces as a toast. */
2611 /* -------------------------------------------------------------------------- */
2612
2613 const BuriedTab: React.FC = () => {
2614 const queryClient = useQueryClient();
2615 const { showToast } = useToast();
2616 const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
2617 const [inspectId, setInspectId] = useState<number | null>(null);
2618
2619 const { data, isLoading, refetch, isFetching } = useQuery({
2620 queryKey: ["webhook-dead-letter"],
2621 queryFn: () => webhooksApi.getDeadLetterSummary(),
2622 refetchOnWindowFocus: false,
2623 });
2624
2625 // Endpoint id → name lookup so summary cards can show real names
2626 // instead of bare ids.
2627 const { data: endpoints } = useQuery({
2628 queryKey: ["webhook-endpoints"],
2629 queryFn: () => webhooksApi.listEndpoints(),
2630 });
2631 const endpointNameById = useMemo(() => {
2632 const map = new Map<number, string>();
2633 endpoints?.data?.forEach((e) => map.set(e.id, e.name));
2634 return map;
2635 }, [endpoints]);
2636
2637 const bulkReplay = useMutation({
2638 mutationFn: (
2639 input: Parameters<typeof webhooksApi.bulkReplayDeliveries>[0],
2640 ) => webhooksApi.bulkReplayDeliveries(input),
2641 onSuccess: (r) => {
2642 showToast(r.message, "success");
2643 setSelectedIds(new Set());
2644 void queryClient.invalidateQueries({ queryKey: ["webhook-dead-letter"] });
2645 void queryClient.invalidateQueries({ queryKey: ["webhook-deliveries"] });
2646 },
2647 onError: (e) => showToast(extractError(e), "error"),
2648 });
2649
2650 if (isLoading) {
2651 return (
2652 <div className="flex justify-center py-16">
2653 <Loader2 className="h-8 w-8 animate-spin text-gray-400" />
2654 </div>
2655 );
2656 }
2657 const summary = data?.data;
2658 const total = summary?.total ?? 0;
2659
2660 if (total === 0) {
2661 return (
2662 <div className="text-center py-16">
2663 <CheckCircle2 className="w-12 h-12 text-green-500 mx-auto" />
2664 <h3 className="mt-4 text-base font-semibold text-gray-900 dark:text-white">
2665 {__("Nothing buried", "yatra")}
2666 </h3>
2667 <p className="mt-1 text-sm text-gray-500 dark:text-gray-400 max-w-md mx-auto">
2668 {__(
2669 "Every delivery either succeeded or is still mid-retry. Permanent failures will show up here so you can replay or investigate.",
2670 "yatra",
2671 )}
2672 </p>
2673 <Button variant="outline" className="mt-4" onClick={() => refetch()}>
2674 <RefreshCw
2675 className={`h-4 w-4 mr-1 ${isFetching ? "animate-spin" : ""}`}
2676 />
2677 {__("Refresh", "yatra")}
2678 </Button>
2679 </div>
2680 );
2681 }
2682
2683 return (
2684 <div className="space-y-6">
2685 {/* Header strip — total count + global refresh */}
2686 <div className="flex items-start gap-3 rounded-md border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/30 p-4">
2687 <AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
2688 <div className="flex-1 min-w-0">
2689 <h3 className="text-sm font-semibold text-red-900 dark:text-red-100">
2690 {sprintf(
2691 /* translators: %d: number of buried deliveries */
2692 __("%d delivery(ies) reached permanent failure", "yatra"),
2693 total,
2694 )}
2695 </h3>
2696 <p className="text-sm text-red-800 dark:text-red-200/90 mt-0.5">
2697 {__(
2698 "These exhausted automatic retries. Group by endpoint or error pattern below and bulk-replay once the downstream issue is fixed.",
2699 "yatra",
2700 )}
2701 </p>
2702 </div>
2703 <Button
2704 variant="outline"
2705 onClick={() => refetch()}
2706 disabled={isFetching}
2707 >
2708 <RefreshCw
2709 className={`h-4 w-4 mr-1 ${isFetching ? "animate-spin" : ""}`}
2710 />
2711 {__("Refresh", "yatra")}
2712 </Button>
2713 </div>
2714
2715 {/* Three grouping cards: by endpoint / by event / by error */}
2716 <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
2717 <Card>
2718 <CardHeader>
2719 <CardTitle className="text-base">
2720 {__("By endpoint", "yatra")}
2721 </CardTitle>
2722 <CardDescription>
2723 {__("Top receivers accumulating failures.", "yatra")}
2724 </CardDescription>
2725 </CardHeader>
2726 <CardContent className="space-y-2">
2727 {summary!.by_endpoint.length === 0 && (
2728 <p className="text-xs text-gray-500">{__("None.", "yatra")}</p>
2729 )}
2730 {summary!.by_endpoint.map((row) => (
2731 <div
2732 key={row.endpoint_id}
2733 className="flex items-center justify-between gap-2 rounded border border-gray-200 dark:border-gray-700 px-3 py-2"
2734 >
2735 <div className="min-w-0 flex-1">
2736 <div className="text-sm font-medium text-gray-900 dark:text-white truncate">
2737 {endpointNameById.get(row.endpoint_id) ??
2738 `Endpoint #${row.endpoint_id}`}
2739 </div>
2740 <div className="text-xs text-gray-500">
2741 {sprintf(__("%d buried", "yatra"), row.count)}
2742 </div>
2743 </div>
2744 <Button
2745 size="sm"
2746 variant="outline"
2747 onClick={() =>
2748 bulkReplay.mutate({
2749 filter: {
2750 endpoint_id: row.endpoint_id,
2751 status: "permanent_failure",
2752 },
2753 })
2754 }
2755 disabled={bulkReplay.isPending}
2756 >
2757 <RotateCcw className="h-3.5 w-3.5 mr-1" />
2758 {__("Replay all", "yatra")}
2759 </Button>
2760 </div>
2761 ))}
2762 </CardContent>
2763 </Card>
2764
2765 <Card>
2766 <CardHeader>
2767 <CardTitle className="text-base">
2768 {__("By event", "yatra")}
2769 </CardTitle>
2770 <CardDescription>
2771 {__("Event types failing most.", "yatra")}
2772 </CardDescription>
2773 </CardHeader>
2774 <CardContent className="space-y-2">
2775 {summary!.by_event.length === 0 && (
2776 <p className="text-xs text-gray-500">{__("None.", "yatra")}</p>
2777 )}
2778 {summary!.by_event.map((row) => (
2779 <div
2780 key={row.event_key}
2781 className="flex items-center justify-between gap-2 rounded border border-gray-200 dark:border-gray-700 px-3 py-2"
2782 >
2783 <div className="min-w-0 flex-1">
2784 <div className="text-sm font-mono text-gray-900 dark:text-white truncate">
2785 {row.event_key || __("(unknown)", "yatra")}
2786 </div>
2787 <div className="text-xs text-gray-500">
2788 {sprintf(__("%d buried", "yatra"), row.count)}
2789 </div>
2790 </div>
2791 </div>
2792 ))}
2793 </CardContent>
2794 </Card>
2795
2796 <Card>
2797 <CardHeader>
2798 <CardTitle className="text-base">
2799 {__("By error", "yatra")}
2800 </CardTitle>
2801 <CardDescription>
2802 {__("Grouped by error-message prefix.", "yatra")}
2803 </CardDescription>
2804 </CardHeader>
2805 <CardContent className="space-y-2">
2806 {summary!.by_error.length === 0 && (
2807 <p className="text-xs text-gray-500">{__("None.", "yatra")}</p>
2808 )}
2809 {summary!.by_error.map((row, idx) => (
2810 <div
2811 key={`${row.fingerprint}-${idx}`}
2812 className="rounded border border-gray-200 dark:border-gray-700 px-3 py-2"
2813 >
2814 <div className="text-xs text-gray-700 dark:text-gray-300 break-words">
2815 {row.fingerprint}
2816 </div>
2817 <div className="text-xs text-gray-500 mt-0.5">
2818 {sprintf(__("%d buried", "yatra"), row.count)}
2819 </div>
2820 </div>
2821 ))}
2822 </CardContent>
2823 </Card>
2824 </div>
2825
2826 {/* Recent buried — selectable, with per-row Inspect + Replay */}
2827 <Card>
2828 <CardHeader>
2829 <div className="flex flex-wrap items-center justify-between gap-3">
2830 <div>
2831 <CardTitle className="text-base">
2832 {__("Recent buried", "yatra")}
2833 </CardTitle>
2834 <CardDescription>
2835 {sprintf(
2836 /* translators: %d: number of recent rows shown */
2837 __("Last %d deliveries in permanent failure.", "yatra"),
2838 summary!.recent.length,
2839 )}
2840 </CardDescription>
2841 </div>
2842 <div className="flex items-center gap-2">
2843 {selectedIds.size > 0 && (
2844 <span className="text-sm text-gray-500">
2845 {sprintf(
2846 /* translators: %d: number selected */
2847 __("%d selected", "yatra"),
2848 selectedIds.size,
2849 )}
2850 </span>
2851 )}
2852 <Button
2853 onClick={() =>
2854 bulkReplay.mutate({ ids: Array.from(selectedIds) })
2855 }
2856 disabled={selectedIds.size === 0 || bulkReplay.isPending}
2857 >
2858 <RotateCcw className="h-4 w-4 mr-1" />
2859 {__("Replay selected", "yatra")}
2860 </Button>
2861 </div>
2862 </div>
2863 </CardHeader>
2864 <CardContent className="p-0">
2865 <div className="overflow-x-auto">
2866 <table className="min-w-full text-sm">
2867 <thead className="bg-gray-50 dark:bg-gray-800/40 border-b border-gray-200 dark:border-gray-700">
2868 <tr>
2869 <th className="px-3 py-2 text-left w-10">
2870 <input
2871 type="checkbox"
2872 checked={
2873 selectedIds.size > 0 &&
2874 selectedIds.size === summary!.recent.length
2875 }
2876 onChange={(e) => {
2877 if (e.target.checked) {
2878 setSelectedIds(
2879 new Set(summary!.recent.map((r) => r.id)),
2880 );
2881 } else {
2882 setSelectedIds(new Set());
2883 }
2884 }}
2885 />
2886 </th>
2887 <th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300">
2888 {__("Event", "yatra")}
2889 </th>
2890 <th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300">
2891 {__("Endpoint", "yatra")}
2892 </th>
2893 <th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300">
2894 {__("Attempts", "yatra")}
2895 </th>
2896 <th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300">
2897 {__("Error", "yatra")}
2898 </th>
2899 <th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300">
2900 {__("When", "yatra")}
2901 </th>
2902 <th className="px-3 py-2 w-32 text-right font-medium text-gray-700 dark:text-gray-300">
2903 {__("Actions", "yatra")}
2904 </th>
2905 </tr>
2906 </thead>
2907 <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
2908 {summary!.recent.map((row) => (
2909 <tr
2910 key={row.id}
2911 className="hover:bg-gray-50 dark:hover:bg-gray-800/40"
2912 >
2913 <td className="px-3 py-2">
2914 <input
2915 type="checkbox"
2916 checked={selectedIds.has(row.id)}
2917 onChange={(e) => {
2918 const next = new Set(selectedIds);
2919 if (e.target.checked) next.add(row.id);
2920 else next.delete(row.id);
2921 setSelectedIds(next);
2922 }}
2923 />
2924 </td>
2925 <td className="px-3 py-2 font-mono text-xs text-gray-900 dark:text-white">
2926 {row.event_key}
2927 </td>
2928 <td className="px-3 py-2 text-gray-700 dark:text-gray-300">
2929 {endpointNameById.get(row.endpoint_id) ??
2930 `#${row.endpoint_id}`}
2931 </td>
2932 <td className="px-3 py-2 text-gray-700 dark:text-gray-300">
2933 {row.attempts}
2934 </td>
2935 <td className="px-3 py-2 text-xs text-gray-600 dark:text-gray-400 max-w-md truncate">
2936 <Tooltip content={row.error_message ?? ""}>
2937 <span>{row.error_message ?? __("—", "yatra")}</span>
2938 </Tooltip>
2939 </td>
2940 <td className="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">
2941 {row.created_at}
2942 </td>
2943 <td className="px-3 py-2 text-right">
2944 <div className="inline-flex gap-1">
2945 <Button
2946 size="sm"
2947 variant="outline"
2948 onClick={() => setInspectId(row.id)}
2949 >
2950 <Eye className="h-3.5 w-3.5" />
2951 </Button>
2952 <Button
2953 size="sm"
2954 variant="outline"
2955 onClick={() => bulkReplay.mutate({ ids: [row.id] })}
2956 disabled={bulkReplay.isPending}
2957 >
2958 <RotateCcw className="h-3.5 w-3.5" />
2959 </Button>
2960 </div>
2961 </td>
2962 </tr>
2963 ))}
2964 </tbody>
2965 </table>
2966 </div>
2967 </CardContent>
2968 </Card>
2969
2970 <DeliveryInspectDialog
2971 id={inspectId}
2972 onClose={() => setInspectId(null)}
2973 />
2974 </div>
2975 );
2976 };
2977
2978 const StatusBadge: React.FC<{ status: string; httpStatus: number | null }> = ({
2979 status,
2980 httpStatus,
2981 }) => {
2982 const cfg: Record<string, { cls: string; label: string }> = {
2983 queued: {
2984 cls: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
2985 label: __("Queued", "yatra"),
2986 },
2987 delivering: {
2988 cls: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400",
2989 label: __("Delivering", "yatra"),
2990 },
2991 delivered: {
2992 cls: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
2993 label: __("Delivered", "yatra"),
2994 },
2995 failed: {
2996 cls: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
2997 label: __("Failed", "yatra"),
2998 },
2999 permanent_failure: {
3000 cls: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
3001 label: __("Failed (final)", "yatra"),
3002 },
3003 };
3004 const x = cfg[status] ?? {
3005 cls: "bg-gray-100 text-gray-700",
3006 label: status,
3007 };
3008 return (
3009 <Badge className={x.cls}>
3010 {x.label}
3011 {httpStatus !== null ? ` (${httpStatus})` : ""}
3012 </Badge>
3013 );
3014 };
3015
3016 /* -------------------------------------------------------------------------- */
3017 /* Delivery inspect — full payload + response viewer ("listen mode") */
3018 /* -------------------------------------------------------------------------- */
3019
3020 const DeliveryInspectDialog: React.FC<{
3021 id: number | null;
3022 onClose: () => void;
3023 }> = ({ id, onClose }) => {
3024 const queryClient = useQueryClient();
3025 const { showToast } = useToast();
3026
3027 const { data, isLoading } = useQuery({
3028 queryKey: ["webhooks-delivery", id],
3029 queryFn: () => webhooksApi.getDelivery(id!),
3030 enabled: id !== null,
3031 // Auto-refetch while the row hasn't reached a terminal status.
3032 // Ensures the inspector reflects "queued → delivering → delivered"
3033 // live, which matters most right after a Ping click.
3034 refetchInterval: (q) => {
3035 const status = (
3036 q.state.data as { data?: { status?: string } } | undefined
3037 )?.data?.status;
3038 const terminal = status === "delivered" || status === "permanent_failure";
3039 return terminal ? false : 2000;
3040 },
3041 });
3042
3043 const replay = useMutation({
3044 mutationFn: (deliveryId: number) => webhooksApi.replayDelivery(deliveryId),
3045 onSuccess: () => {
3046 queryClient.invalidateQueries({ queryKey: ["webhooks-deliveries"] });
3047 queryClient.invalidateQueries({ queryKey: ["webhooks-delivery", id] });
3048 showToast(__("Delivery re-queued.", "yatra"), "success");
3049 },
3050 onError: (e: any) => showToast(extractError(e), "error"),
3051 });
3052
3053 if (id === null) return null;
3054
3055 const delivery = data?.data;
3056 const payloadJson = delivery ? JSON.stringify(delivery.payload, null, 2) : "";
3057
3058 return (
3059 <Modal
3060 isOpen
3061 onClose={onClose}
3062 title={
3063 <div className="flex items-center gap-2">
3064 <Eye className="w-5 h-5 text-blue-500" />
3065 {__("Delivery inspector", "yatra")}
3066 </div>
3067 }
3068 size="xl"
3069 hideFooter={false}
3070 footer={
3071 <div className="flex justify-end gap-2">
3072 <Button variant="outline" onClick={onClose}>
3073 {__("Close", "yatra")}
3074 </Button>
3075 {delivery &&
3076 (delivery.status === "failed" ||
3077 delivery.status === "permanent_failure") && (
3078 <Button
3079 onClick={() => replay.mutate(delivery.id)}
3080 disabled={replay.isPending}
3081 >
3082 {replay.isPending ? (
3083 <>
3084 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
3085 {__("Replaying…", "yatra")}
3086 </>
3087 ) : (
3088 <>
3089 <RotateCcw className="mr-1.5 h-4 w-4" />
3090 {__("Replay delivery", "yatra")}
3091 </>
3092 )}
3093 </Button>
3094 )}
3095 </div>
3096 }
3097 >
3098 {isLoading || !delivery ? (
3099 <div className="flex justify-center py-12">
3100 <Loader2 className="h-8 w-8 animate-spin text-gray-400" />
3101 </div>
3102 ) : (
3103 <div className="space-y-4">
3104 {/* Metadata grid */}
3105 <div className="grid grid-cols-2 gap-3 text-sm">
3106 <Field
3107 label={__("Event", "yatra")}
3108 value={delivery.event_key}
3109 mono
3110 />
3111 <Field
3112 label={__("Delivery ID", "yatra")}
3113 value={delivery.delivery_id}
3114 mono
3115 />
3116 <Field
3117 label={__("Status", "yatra")}
3118 raw={
3119 <StatusBadge
3120 status={delivery.status}
3121 httpStatus={delivery.http_status}
3122 />
3123 }
3124 />
3125 <Field
3126 label={__("Attempts", "yatra")}
3127 value={String(delivery.attempts)}
3128 />
3129 <Field
3130 label={__("Created", "yatra")}
3131 value={new Date(delivery.created_at).toLocaleString()}
3132 />
3133 <Field
3134 label={__("Delivered", "yatra")}
3135 value={
3136 delivery.delivered_at
3137 ? new Date(delivery.delivered_at).toLocaleString()
3138 : "—"
3139 }
3140 />
3141 {delivery.duration_ms !== null && (
3142 <Field
3143 label={__("Duration", "yatra")}
3144 value={`${delivery.duration_ms} ms`}
3145 />
3146 )}
3147 {delivery.next_attempt_at && (
3148 <Field
3149 label={__("Next attempt", "yatra")}
3150 value={new Date(delivery.next_attempt_at).toLocaleString()}
3151 />
3152 )}
3153 </div>
3154
3155 {delivery.error_message && (
3156 <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-md p-3 text-sm text-red-700 dark:text-red-400">
3157 <strong className="block mb-1">{__("Error", "yatra")}</strong>
3158 {delivery.error_message}
3159 </div>
3160 )}
3161
3162 {/* Payload sent */}
3163 <div>
3164 <div className="flex items-center justify-between mb-1">
3165 <Label className="text-sm font-semibold">
3166 {__("Payload sent", "yatra")}
3167 </Label>
3168 <CopyButton text={payloadJson} />
3169 </div>
3170 <pre className="bg-gray-900 text-gray-100 dark:bg-black rounded-md p-4 text-xs font-mono overflow-auto max-h-96">
3171 {payloadJson}
3172 </pre>
3173 </div>
3174
3175 {/* Response body */}
3176 {delivery.response_body && (
3177 <div>
3178 <Label className="text-sm font-semibold mb-1 block">
3179 {__("Response body", "yatra")}
3180 </Label>
3181 <pre className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-4 text-xs font-mono overflow-auto max-h-48">
3182 {delivery.response_body}
3183 </pre>
3184 </div>
3185 )}
3186
3187 {/* Response headers */}
3188 {delivery.response_headers && (
3189 <div>
3190 <Label className="text-sm font-semibold mb-1 block">
3191 {__("Response headers", "yatra")}
3192 </Label>
3193 <pre className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-3 text-xs font-mono overflow-auto max-h-32">
3194 {delivery.response_headers}
3195 </pre>
3196 </div>
3197 )}
3198 </div>
3199 )}
3200 </Modal>
3201 );
3202 };
3203
3204 const Field: React.FC<{
3205 label: string;
3206 value?: string;
3207 mono?: boolean;
3208 raw?: React.ReactNode;
3209 }> = ({ label, value, mono, raw }) => (
3210 <div>
3211 <div className="text-xs text-gray-500 dark:text-gray-400">{label}</div>
3212 <div
3213 className={`text-sm text-gray-900 dark:text-white ${mono ? "font-mono" : ""}`}
3214 >
3215 {raw ?? value ?? "—"}
3216 </div>
3217 </div>
3218 );
3219
3220 const CopyButton: React.FC<{ text: string }> = ({ text }) => {
3221 const [copied, setCopied] = useState(false);
3222 return (
3223 <Button
3224 size="sm"
3225 variant="outline"
3226 onClick={async () => {
3227 try {
3228 await navigator.clipboard.writeText(text);
3229 setCopied(true);
3230 window.setTimeout(() => setCopied(false), 1800);
3231 } catch (_e) {}
3232 }}
3233 >
3234 {copied ? (
3235 <>
3236 <Check className="mr-1 h-3 w-3" />
3237 {__("Copied", "yatra")}
3238 </>
3239 ) : (
3240 <>
3241 <Copy className="mr-1 h-3 w-3" />
3242 {__("Copy", "yatra")}
3243 </>
3244 )}
3245 </Button>
3246 );
3247 };
3248
3249 function extractError(e: any): string {
3250 if (!e) return "Request failed.";
3251 const data = e?.response?.data ?? e?.data ?? null;
3252 if (
3253 data &&
3254 typeof data === "object" &&
3255 typeof (data as any).message === "string"
3256 ) {
3257 return (data as any).message;
3258 }
3259 return e?.message || "Request failed.";
3260 }
3261
3262 export default Webhooks;
3263