import { useCallback, useEffect, useState } from "react" import { Clock, ListChecks, Plus, RefreshCw, Trash2 } from "lucide-react" import { useArcadiaClient } from "@crema/arcadia-core-client" import { useToast } from "@crema/notification-ui" import { ConfirmDialog, EmptyState } from "@crema/feedback-ui" import { BadgeCell, type BadgeTone } from "@crema/table-ui" import type { TenantTabProps } from "~/routes/tenants.$id" import { DataState, DialogError } from "~/components/data-state" import { errorMessage } from "~/lib/errors" import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog" import { Input } from "~/components/ui/input" import { Label } from "~/components/ui/label" import { Switch } from "~/components/ui/switch" import { createInboundWebhook, deleteInboundWebhook, listInboundWebhookDeliveries, listInboundWebhooks, updateInboundWebhook, type InboundWebhookDelivery, type InboundWebhookSource, } from "~/lib/arcadia/tenants" /** * Inbound webhook sources: external providers whose signed callbacks this * tenant accepts and verifies. Each source can be toggled, deleted, and has a * recent-deliveries log. */ export function InboundWebhooksTab({ tenant }: TenantTabProps) { const arcadia = useArcadiaClient() const toast = useToast() const [sources, setSources] = useState([]) const [loading, setLoading] = useState(true) // Raw thrown value; DataState normalises it. A failed load must never render // as "no sources yet". const [error, setError] = useState(null) const [busy, setBusy] = useState>(new Set()) const [addOpen, setAddOpen] = useState(false) const [deliveriesFor, setDeliveriesFor] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const load = useCallback(async () => { setError(null) setLoading(true) try { setSources(await listInboundWebhooks(arcadia, tenant.id)) } catch (err) { setError(err) } finally { setLoading(false) } }, [arcadia, tenant.id]) useEffect(() => { load() }, [load]) const markBusy = (id: string, on: boolean) => setBusy((prev) => { const next = new Set(prev) if (on) next.add(id) else next.delete(id) return next }) const toggle = useCallback( async (src: InboundWebhookSource, next: boolean) => { markBusy(src.id, true) setSources((prev) => prev.map((s) => (s.id === src.id ? { ...s, enabled: next } : s)), ) try { const updated = await updateInboundWebhook(arcadia, tenant.id, src.id, { enabled: next, }) setSources((prev) => prev.map((s) => (s.id === updated.id ? updated : s))) toast.success(`${next ? "Enabled" : "Disabled"} ${src.name}`) } catch (err) { setSources((prev) => prev.map((s) => (s.id === src.id ? { ...s, enabled: !next } : s)), ) toast.error(errorMessage(err, `update ${src.name}`)) } finally { markBusy(src.id, false) } }, [arcadia, tenant.id, toast], ) return (
Inbound webhooks External providers whose signed callbacks {tenant.name} accepts and verifies.
} >
{sources.map((src) => { const isBusy = busy.has(src.id) return ( ) })}
Name Provider Enabled
{src.name} {src.provider || "—"} toggle(src, next)} data-action={`tenant-detail-webhooks-toggle-${src.id}`} />
setAddOpen(false)} onSaved={async (msg) => { setAddOpen(false) await load() toast.success(msg) }} /> setDeliveriesFor(null)} /> !o && setPendingDelete(null)} title="Delete webhook source?" description={ pendingDelete ? `${pendingDelete.name} will be removed. Incoming callbacks from this provider will be rejected, and its delivery history is discarded.` : "" } confirmLabel="Delete" variant="danger" onConfirm={async () => { if (!pendingDelete) return const target = pendingDelete try { await deleteInboundWebhook(arcadia, tenant.id, target.id) setPendingDelete(null) await load() toast.success(`Deleted ${target.name}`) } catch (err) { setPendingDelete(null) toast.error(errorMessage(err, `delete ${target.name}`)) } }} />
) } function AddWebhookDialog({ open, tenantId, onClose, onSaved, }: { open: boolean tenantId: string onClose: () => void onSaved: (message: string) => Promise }) { const arcadia = useArcadiaClient() const [name, setName] = useState("") const [provider, setProvider] = useState("") const [signingSecret, setSigningSecret] = useState("") const [signatureHeader, setSignatureHeader] = useState("") const [signatureAlgorithm, setSignatureAlgorithm] = useState("hmac_sha256") const [enabled, setEnabled] = useState(true) const [saving, setSaving] = useState(false) // Failed submit renders here, above the buttons, with the form intact. const [error, setError] = useState(null) useEffect(() => { if (!open) return setName("") setProvider("") setSigningSecret("") setSignatureHeader("") setSignatureAlgorithm("hmac_sha256") setEnabled(true) setError(null) }, [open]) const submit = async () => { const trimmedName = name.trim() setError(null) setSaving(true) try { if (!trimmedName) throw new Error("A name is required.") await createInboundWebhook(arcadia, tenantId, { name: trimmedName, provider: provider.trim() || undefined, signing_secret: signingSecret || undefined, signature_header: signatureHeader.trim() || undefined, signature_algorithm: signatureAlgorithm.trim() || undefined, enabled, }) await onSaved(`Added webhook source ${trimmedName}`) } catch (err) { setError(err) } finally { setSaving(false) } } return ( !o && onClose()}> Add webhook source Register an external provider whose signed callbacks this tenant will accept and verify.
setName(e.target.value)} placeholder="Stripe events" autoFocus data-action="tenant-detail-webhooks-form-name" />
setProvider(e.target.value)} placeholder="stripe" data-action="tenant-detail-webhooks-form-provider" />
setSigningSecret(e.target.value)} placeholder="whsec_…" data-action="tenant-detail-webhooks-form-secret" />

Stored encrypted; used to verify incoming signatures. Write-only.

setSignatureHeader(e.target.value)} placeholder="X-Signature" className="font-mono" data-action="tenant-detail-webhooks-form-header" />
setSignatureAlgorithm(e.target.value)} placeholder="hmac_sha256" className="font-mono" data-action="tenant-detail-webhooks-form-algorithm" />
Enabled
Disabled sources reject incoming callbacks.
{error ? : null}
) } function DeliveriesDialog({ source, tenantId, onClose, }: { source: InboundWebhookSource | null tenantId: string onClose: () => void }) { const arcadia = useArcadiaClient() const [deliveries, setDeliveries] = useState([]) const [loading, setLoading] = useState(true) // A deliveries load that failed is not a source with no history. Own error // state, rendered in place of the list. const [error, setError] = useState(null) const [reloadKey, setReloadKey] = useState(0) useEffect(() => { if (!source) return let mounted = true setLoading(true) setError(null) listInboundWebhookDeliveries(arcadia, tenantId, source.id) .then((rows) => { if (mounted) setDeliveries(rows) }) .catch((err) => { if (mounted) setError(err) }) .finally(() => { if (mounted) setLoading(false) }) return () => { mounted = false } }, [arcadia, tenantId, source, reloadKey]) return ( !o && onClose()}> Deliveries{source ? ` — ${source.name}` : ""} Recent signed callbacks received from this provider. setReloadKey((n) => n + 1)} loadingLabel="Loading deliveries…" empty={

No deliveries yet. Callbacks this source receives and verifies will appear here.

} >
    {deliveries.map((d) => { const when = d.received_at ?? d.inserted_at return (
  • {when ? new Date(when).toLocaleString() : "—"}
  • ) })}
) } function deliveryTone(status?: string): BadgeTone { const s = (status ?? "").toLowerCase() if (["ok", "success", "delivered", "verified", "processed"].includes(s)) return "success" if (["failed", "error", "rejected", "invalid"].includes(s)) return "danger" if (["pending", "retrying", "queued"].includes(s)) return "warning" return "default" }