1"use client";
2
3
15import { useState } from "react";
16import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
17import {
18 Check,
19 Copy,
20 Loader2,
21 Pause,
22 Play,
23 Plus,
24 RefreshCw,
25 ShieldAlert,
26 Trash2,
27 Webhook as WebhookIcon
28} from "lucide-react";
29
30
31import { WEBHOOK_EVENT_CATALOG, WEBHOOK_EVENT_NAMES } from "@factory/shared/events/catalog";
32import { toast } from "@/stores/ui-store";
33import { apiFetch } from "@/lib/api";
34import { Dialog } from "@/components/dialogs/dialog";
35import { Field, inputCls } from "@/components/settings/settings-shell";
36import { cn } from "@/lib/utils";
37import { DeliveryInspector } from "./DeliveryInspector";
38
39interface WebhookRow {
40 id: string;
41 url: string;
42 events: string[];
43 active: boolean;
44 secretPrefix: string;
45 createdAt: string;
46}
47
48function unwrap<T>(body: unknown): T[] {
49 const b = body as { data?: T[] } | T[] | null;
50 if (Array.isArray(b)) return b;
51 if (b && typeof b === "object" && Array.isArray((b as { data?: T[] }).data))
52 return (b as { data: T[] }).data;
53 return [];
54}
55
56async function copyText(text: string): Promise<boolean> {
57 try {
58 await navigator.clipboard.writeText(text);
59 return true;
60 } catch {
61 try {
62 const ta = document.createElement("textarea");
63 ta.value = text;
64 ta.setAttribute("readonly", "");
65 ta.style.position = "fixed";
66 ta.style.opacity = "0";
67 document.body.appendChild(ta);
68 ta.select();
69 const ok = document.execCommand("copy");
70 document.body.removeChild(ta);
71 return ok;
72 } catch {
73 return false;
74 }
75 }
76}
77
78
79function WebhookSecretDialog({
80 open,
81 secret,
82 url,
83 mode,
84 onConfirmed
85}: {
86 open: boolean;
87 secret: string | null;
88 url: string;
89 mode: "create" | "rotate";
90 onConfirmed: () => void;
91}) {
92 const [copied, setCopied] = useState(false);
93 const [storeChecked, setStoreChecked] = useState(false);
94 const doCopy = async () => {
95 if (!secret) return;
96 const ok = await copyText(secret);
97 setCopied(ok);
98 setTimeout(() => setCopied(false), 2500);
99 };
100 return (
101 <Dialog
102 open={open}
103 title={mode === "create" ? "Your webhook signing secret" : "New signing secret"}
104 description={`Endpoint ${url}`}
105 onClose={onConfirmed}
106 footer={
107 <button
108 type="button"
109 disabled={!storeChecked}
110 onClick={onConfirmed}
111 className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
112 >
113 I have stored it
114 </button>
115 }
116 >
117 <div className="space-y-3">
118 <div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-2.5 text-xs text-destructive">
119 <ShieldAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
120 <span>
121 <strong className="font-semibold">
122 {mode === "create"
123 ? "This secret will not be shown again."
124 : "In-flight deliveries signed with the old secret will fail verification."}
125 </strong>{" "}
126 {mode === "create"
127 ? "Store it securely — the Factory only keeps an encrypted copy for signing."
128 : "Store the new secret and rotate your verifier."}
129 </span>
130 </div>
131 <div>
132 <label className="mb-1 block text-xs font-medium text-muted-foreground" htmlFor="wh-sec-raw">
133 Signing secret
134 </label>
135 <div className="flex flex-col gap-1.5 md:flex-row md:items-center">
136 <input
137 id="wh-sec-raw"
138 readOnly
139 value={secret ?? ""}
140 onFocus={(e) => e.currentTarget.select()}
141 aria-label="Webhook signing secret (shown once)"
142 data-testid="webhook-secret-raw"
143 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"
144 />
145 <button
146 type="button"
147 onClick={doCopy}
148 data-testid="webhook-secret-copy"
149 aria-label="Copy signing secret"
150 className="inline-flex shrink-0 items-center justify-center gap-1 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
151 >
152 {copied ? (
153 <Check className="size-3.5 text-emerald-600" />
154 ) : (
155 <Copy className="size-3.5" />
156 )}
157 {copied ? "Copied" : "Copy"}
158 </button>
159 </div>
160 <span role="status" className="sr-only">
161 {copied ? "Signing secret copied to clipboard" : ""}
162 </span>
163 </div>
164 <label className="flex cursor-pointer items-start gap-2 text-sm">
165 <input
166 type="checkbox"
167 className="mt-0.5 accent-primary"
168 checked={storeChecked}
169 onChange={(e) => setStoreChecked(e.target.checked)}
170 />
171 <span>I have stored this secret securely.</span>
172 </label>
173 </div>
174 </Dialog>
175 );
176}
177
178export function WebhooksTab({ workspaceId }: { workspaceId: string }) {
179 const qc = useQueryClient();
180 const [url, setUrl] = useState("");
181 const [events, setEvents] = useState<string[]>(["*"]);
182 const [secret, setSecret] = useState("");
183 const [reveal, setReveal] = useState<{ secret: string; url: string; mode: "create" | "rotate" } | null>(null);
184 const [inspectorFor, setInspectorFor] = useState<string | null>(null);
185
186 const hooks = useQuery({
187 queryKey: ["workspaces", workspaceId, "webhooks"],
188 queryFn: async (): Promise<WebhookRow[]> =>
189 unwrap<WebhookRow>(await apiFetch(`/workspaces/${workspaceId}/webhooks`)),
190 enabled: !!workspaceId
191 });
192
193 const create = useMutation({
194 mutationFn: async () => {
195 const out = await apiFetch<{ secret?: string }>(`/workspaces/${workspaceId}/webhooks`, {
196 method: "POST",
197 body: {
198 url: url.trim(),
199 events,
200 ...(secret.trim() ? { secret: secret.trim() } : {})
201 }
202 });
203 const b = out as { data?: { secret?: string }; secret?: string };
204 return { secret: b.secret ?? b.data?.secret ?? "" };
205 },
206 onSuccess: (r) => {
207 setUrl("");
208 setEvents(["*"]);
209 setSecret("");
210 qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] });
211 if (r.secret) setReveal({ secret: r.secret, url: url, mode: "create" });
212 },
213 onError: (e) => toast("error", (e as Error).message || "Could not create the webhook.")
214 });
215
216 const patch = useMutation({
217 mutationFn: (input: { id: string; body: Record<string, unknown> }) =>
218 apiFetch(`/workspaces/${workspaceId}/webhooks/${input.id}`, {
219 method: "PATCH",
220 body: input.body
221 }),
222 onSuccess: () => qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] }),
223 onError: (e) => toast("error", (e as Error).message || "Update failed.")
224 });
225
226 const rotate = useMutation({
227 mutationFn: async (id: string) => {
228 const out = await apiFetch<{ secret?: string }>(`/workspaces/${workspaceId}/webhooks/${id}`, {
229 method: "PATCH",
230 body: { rotateSecret: true }
231 });
232 const b = out as { data?: { secret?: string }; secret?: string };
233 return { secret: b.secret ?? b.data?.secret ?? "" };
234 },
235 onSuccess: (r, id) => {
236 const row = (hooks.data ?? []).find((h) => h.id === id);
237 qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] });
238 if (r.secret) setReveal({ secret: r.secret, url: row?.url ?? "", mode: "rotate" });
239 },
240 onError: (e) => toast("error", (e as Error).message || "Could not rotate the secret.")
241 });
242
243 const remove = useMutation({
244 mutationFn: (id: string) =>
245 apiFetch(`/workspaces/${workspaceId}/webhooks/${id}`, { method: "DELETE" }),
246 onSuccess: () => qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] })
247 });
248
249 const toggleEvent = (ev: string) =>
250 setEvents((prev) => (prev.includes(ev) ? prev.filter((x) => x !== ev) : [...prev, ev]));
251
252 const eventOptions: string[] = ["*", ...WEBHOOK_EVENT_NAMES];
253
254 return (
255 <div className="space-y-4">
256 <div className="rounded-lg border border-border p-4">
257 <h3 className="text-sm font-semibold">New webhook</h3>
258 <div className="mt-3 grid gap-2 sm:grid-cols-2">
259 <Field label="URL" hint="http(s) endpoint that accepts POST JSON. Private addresses are blocked unless WEBHOOK_ALLOW_PRIVATE is set.">
260 <input
261 className={inputCls}
262 placeholder="https://example.com/hook"
263 value={url}
264 onChange={(e) => setUrl(e.target.value)}
265 />
266 </Field>
267 <Field label="Signing secret (optional)" hint="HMAC-SHA256 signing (X-Factory-Signature). Auto-generated if empty.">
268 <input
269 className={inputCls}
270 type="password"
271 placeholder="auto-generated if empty"
272 value={secret}
273 onChange={(e) => setSecret(e.target.value)}
274 />
275 </Field>
276 </div>
277 <div className="mt-2">
278 <p className="mb-1 text-xs font-medium text-muted-foreground">Events</p>
279 <div className="flex flex-wrap gap-1.5">
280 {eventOptions.map((ev) => (
281 <label
282 key={ev}
283 className={cn(
284 "inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-[11px]",
285 events.includes(ev)
286 ? "border-primary/50 bg-primary/10 text-foreground"
287 : "border-border text-muted-foreground"
288 )}
289 >
290 <input
291 type="checkbox"
292 className="sr-only"
293 checked={events.includes(ev)}
294 onChange={() => toggleEvent(ev)}
295 />
296 {ev}
297 </label>
298 ))}
299 </div>
300 <p className="mt-1 text-[11px] text-muted-foreground">
301 <code className="font-mono">*</code> subscribes to every event. A prefix wildcard
302 (e.g. <code className="font-mono">page.*</code>) is also supported.
303 </p>
304 </div>
305 <div className="mt-3 flex justify-end">
306 <button
307 type="button"
308 disabled={!url.trim() || events.length === 0 || create.isPending}
309 onClick={() => create.mutate()}
310 className="inline-flex items-center gap-1 rounded-md bg-primary px-2.5 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
311 >
312 {create.isPending ? (
313 <Loader2 className="size-3.5 animate-spin" />
314 ) : (
315 <Plus className="size-3.5" />
316 )}
317 Create webhook
318 </button>
319 </div>
320 </div>
321
322 <ul className="space-y-2">
323 {(hooks.data ?? []).map((h) => (
324 <li key={h.id} className="rounded-md border border-border px-3 py-2">
325 <div className="flex flex-wrap items-center gap-2">
326 <WebhookIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
327 <p className="min-w-0 flex-1 truncate text-sm font-medium">{h.url}</p>
328 <span
329 className={cn(
330 "rounded-full px-1.5 py-0.5 text-[10px] font-medium",
331 h.active
332 ? "bg-emerald-500/15 text-emerald-600"
333 : "bg-muted text-muted-foreground"
334 )}
335 >
336 {h.active ? "active" : "paused"}
337 </span>
338 <button
339 type="button"
340 onClick={() => setInspectorFor(h.id)}
341 className="rounded-md border px-2 py-1 text-xs"
342 >
343 Deliveries
344 </button>
345 <button
346 type="button"
347 onClick={() => rotate.mutate(h.id)}
348 disabled={rotate.isPending}
349 title="Rotate signing secret"
350 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs disabled:opacity-50"
351 >
352 <RefreshCw className="size-3" aria-hidden />
353 Rotate
354 </button>
355 <button
356 type="button"
357 onClick={() =>
358 patch.mutate({ id: h.id, body: { active: !h.active } })
359 }
360 disabled={patch.isPending}
361 className="rounded-md border px-2 py-1 text-xs"
362 >
363 {h.active ? (
364 <>
365 <Pause className="mr-1 inline size-3" aria-hidden /> Pause
366 </>
367 ) : (
368 <>
369 <Play className="mr-1 inline size-3" aria-hidden /> Resume
370 </>
371 )}
372 </button>
373 <button
374 type="button"
375 onClick={() => {
376 if (window.confirm(`Delete webhook ${h.url}?`)) remove.mutate(h.id);
377 }}
378 aria-label={`Delete ${h.url}`}
379 className="rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
380 >
381 <Trash2 className="size-4" />
382 </button>
383 </div>
384 <p className="mt-1 truncate text-xs text-muted-foreground">
385 <code className="font-mono">{h.secretPrefix}</code> · {h.events.join(", ")}
386 </p>
387 {inspectorFor === h.id ? (
388 <div className="mt-2">
389 <DeliveryInspector workspaceId={workspaceId} webhookId={h.id} />
390 </div>
391 ) : null}
392 </li>
393 ))}
394 {hooks.data && hooks.data.length === 0 && (
395 <li className="text-sm text-muted-foreground">No webhooks yet.</li>
396 )}
397 </ul>
398
399 {reveal ? (
400 <WebhookSecretDialog
401 open
402 secret={reveal.secret}
403 url={reveal.url}
404 mode={reveal.mode}
405 onConfirmed={() => setReveal(null)}
406 />
407 ) : null}
408 </div>
409 );
410}
411