"use client"; /** * WebhooksTab (TASK-0130) — the developer-hub "Webhooks" tab. * * - List (GET …/webhooks → { data: [shape] }; shape never carries the secret). * - Create (POST …/webhooks) — returns {…shape, secret, warning}; the secret * is revealed ONCE in WebhookSecretDialog (never persisted, like the API-key * reveal). Events come from the SHARED catalog (+ the `*` wildcard). * - Rotate (PATCH …/webhooks/:id { rotateSecret: true }) — new secret revealed * once; in-flight old-signature deliveries will fail verification. * - Pause/resume (PATCH …/webhooks/:id { active }), delete (DELETE, 204). * - DeliveryInspector (per-webhook log + drawer + test-send + resubmit). */ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Loader2, Pause, Play, Plus, RefreshCw, ShieldAlert, Trash2, Webhook as WebhookIcon } from "lucide-react"; // Browser-safe subpath (the root barrel re-exports node:crypto — see // packages/shared/src/events/catalog.ts; the Developers page 500'd bundling it). import { WEBHOOK_EVENT_CATALOG, WEBHOOK_EVENT_NAMES } from "@factory/shared/events/catalog"; import { toast } from "@/stores/ui-store"; import { apiFetch } from "@/lib/api"; import { Dialog } from "@/components/dialogs/dialog"; import { Field, inputCls } from "@/components/settings/settings-shell"; import { cn } from "@/lib/utils"; import { DeliveryInspector } from "./DeliveryInspector"; interface WebhookRow { id: string; url: string; events: string[]; active: boolean; secretPrefix: string; createdAt: string; } function unwrap(body: unknown): T[] { const b = body as { data?: T[] } | T[] | null; if (Array.isArray(b)) return b; if (b && typeof b === "object" && Array.isArray((b as { data?: T[] }).data)) return (b as { data: T[] }).data; return []; } async function copyText(text: string): Promise { try { await navigator.clipboard.writeText(text); return true; } catch { try { const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand("copy"); document.body.removeChild(ta); return ok; } catch { return false; } } } /** The one-time secret reveal (create + rotate share it). */ function WebhookSecretDialog({ open, secret, url, mode, onConfirmed }: { open: boolean; secret: string | null; url: string; mode: "create" | "rotate"; onConfirmed: () => void; }) { const [copied, setCopied] = useState(false); const [storeChecked, setStoreChecked] = useState(false); const doCopy = async () => { if (!secret) return; const ok = await copyText(secret); setCopied(ok); setTimeout(() => setCopied(false), 2500); }; return ( I have stored it } >
{mode === "create" ? "This secret will not be shown again." : "In-flight deliveries signed with the old secret will fail verification."} {" "} {mode === "create" ? "Store it securely — the Factory only keeps an encrypted copy for signing." : "Store the new secret and rotate your verifier."}
e.currentTarget.select()} aria-label="Webhook signing secret (shown once)" data-testid="webhook-secret-raw" className="min-w-0 w-full rounded-md border border-input bg-background px-2.5 py-1.5 font-mono text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring" />
{copied ? "Signing secret copied to clipboard" : ""}
); } export function WebhooksTab({ workspaceId }: { workspaceId: string }) { const qc = useQueryClient(); const [url, setUrl] = useState(""); const [events, setEvents] = useState(["*"]); const [secret, setSecret] = useState(""); const [reveal, setReveal] = useState<{ secret: string; url: string; mode: "create" | "rotate" } | null>(null); const [inspectorFor, setInspectorFor] = useState(null); const hooks = useQuery({ queryKey: ["workspaces", workspaceId, "webhooks"], queryFn: async (): Promise => unwrap(await apiFetch(`/workspaces/${workspaceId}/webhooks`)), enabled: !!workspaceId }); const create = useMutation({ mutationFn: async () => { const out = await apiFetch<{ secret?: string }>(`/workspaces/${workspaceId}/webhooks`, { method: "POST", body: { url: url.trim(), events, ...(secret.trim() ? { secret: secret.trim() } : {}) } }); const b = out as { data?: { secret?: string }; secret?: string }; return { secret: b.secret ?? b.data?.secret ?? "" }; }, onSuccess: (r) => { setUrl(""); setEvents(["*"]); setSecret(""); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] }); if (r.secret) setReveal({ secret: r.secret, url: url, mode: "create" }); }, onError: (e) => toast("error", (e as Error).message || "Could not create the webhook.") }); const patch = useMutation({ mutationFn: (input: { id: string; body: Record }) => apiFetch(`/workspaces/${workspaceId}/webhooks/${input.id}`, { method: "PATCH", body: input.body }), onSuccess: () => qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] }), onError: (e) => toast("error", (e as Error).message || "Update failed.") }); const rotate = useMutation({ mutationFn: async (id: string) => { const out = await apiFetch<{ secret?: string }>(`/workspaces/${workspaceId}/webhooks/${id}`, { method: "PATCH", body: { rotateSecret: true } }); const b = out as { data?: { secret?: string }; secret?: string }; return { secret: b.secret ?? b.data?.secret ?? "" }; }, onSuccess: (r, id) => { const row = (hooks.data ?? []).find((h) => h.id === id); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] }); if (r.secret) setReveal({ secret: r.secret, url: row?.url ?? "", mode: "rotate" }); }, onError: (e) => toast("error", (e as Error).message || "Could not rotate the secret.") }); const remove = useMutation({ mutationFn: (id: string) => apiFetch(`/workspaces/${workspaceId}/webhooks/${id}`, { method: "DELETE" }), onSuccess: () => qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] }) }); const toggleEvent = (ev: string) => setEvents((prev) => (prev.includes(ev) ? prev.filter((x) => x !== ev) : [...prev, ev])); const eventOptions: string[] = ["*", ...WEBHOOK_EVENT_NAMES]; return (

New webhook

setUrl(e.target.value)} /> setSecret(e.target.value)} />

Events

{eventOptions.map((ev) => ( ))}

* subscribes to every event. A prefix wildcard (e.g. page.*) is also supported.

    {(hooks.data ?? []).map((h) => (
  • {h.url}

    {h.active ? "active" : "paused"}

    {h.secretPrefix} · {h.events.join(", ")}

    {inspectorFor === h.id ? (
    ) : null}
  • ))} {hooks.data && hooks.data.length === 0 && (
  • No webhooks yet.
  • )}
{reveal ? ( setReveal(null)} /> ) : null}
); }