Files
property-management-network/components/dashboard/webhook-manager.tsx
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00

358 lines
13 KiB
TypeScript

"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
Check,
Copy,
Eye,
EyeOff,
Loader2,
Plus,
Power,
RefreshCw,
Send,
Trash2,
Webhook,
} from "lucide-react"
import { WEBHOOK_EVENTS } from "@/lib/webhooks/events"
import {
createWebhookEndpoint,
deleteWebhookEndpoint,
rotateWebhookSecret,
sendTestWebhook,
updateWebhookEndpoint,
type WebhookEndpointDTO,
} from "@/app/actions/webhooks"
const inputClass =
"w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
function formatDate(value: string | null): string {
if (!value) return "Never"
const d = new Date(value)
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
}
function eventsSummary(events: string[]): string {
if (events.length === 0) return "All events"
if (events.length === 1) return events[0]
return `${events.length} events`
}
function SecretField({ secret }: { secret: string }) {
const [shown, setShown] = useState(false)
const [copied, setCopied] = useState(false)
async function copy() {
try {
await navigator.clipboard.writeText(secret)
setCopied(true)
toast.success("Signing secret copied")
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error("Copy failed — reveal and copy manually")
}
}
return (
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg border border-white/10 bg-[#0a0a12] px-3 py-2 font-mono text-xs text-emerald-300">
{shown ? secret : "whsec_" + "•".repeat(24)}
</code>
<button
type="button"
onClick={() => setShown((s) => !s)}
title={shown ? "Hide" : "Reveal"}
className="shrink-0 rounded-lg border border-white/10 p-2 text-white/50 transition hover:bg-white/5 hover:text-white"
>
{shown ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={copy}
title="Copy"
className="shrink-0 rounded-lg border border-white/10 p-2 text-white/50 transition hover:bg-white/5 hover:text-white"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
)
}
export function WebhookManager({ initialEndpoints }: { initialEndpoints: WebhookEndpointDTO[] }) {
const router = useRouter()
const [url, setUrl] = useState("")
const [description, setDescription] = useState("")
const [allEvents, setAllEvents] = useState(true)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [creating, setCreating] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
function toggleEvent(id: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const trimmed = url.trim()
if (!trimmed) return
setCreating(true)
try {
await createWebhookEndpoint({
url: trimmed,
description: description.trim() || undefined,
events: allEvents ? [] : Array.from(selected),
})
setUrl("")
setDescription("")
setAllEvents(true)
setSelected(new Set())
toast.success("Webhook endpoint created")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create webhook")
} finally {
setCreating(false)
}
}
async function withBusy(id: string, fn: () => Promise<void>) {
setBusyId(id)
try {
await fn()
} finally {
setBusyId(null)
}
}
async function handleTest(ep: WebhookEndpointDTO) {
await withBusy(ep.id, async () => {
try {
const res = await sendTestWebhook(ep.id)
if (res.ok) toast.success(`Test delivered (HTTP ${res.responseStatus})`)
else toast.error(`Test failed: ${res.error ?? "no response"}`)
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Test failed")
}
})
}
async function handleToggle(ep: WebhookEndpointDTO) {
await withBusy(ep.id, async () => {
try {
await updateWebhookEndpoint(ep.id, { status: ep.status === "active" ? "disabled" : "active" })
toast.success(ep.status === "active" ? "Webhook disabled" : "Webhook enabled")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Update failed")
}
})
}
async function handleRotate(ep: WebhookEndpointDTO) {
if (!confirm("Rotate the signing secret? The current secret stops working immediately.")) return
await withBusy(ep.id, async () => {
try {
await rotateWebhookSecret(ep.id)
toast.success("Signing secret rotated")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Rotate failed")
}
})
}
async function handleDelete(ep: WebhookEndpointDTO) {
if (!confirm(`Delete webhook for ${ep.url}? This cannot be undone.`)) return
await withBusy(ep.id, async () => {
try {
await deleteWebhookEndpoint(ep.id)
toast.success("Webhook deleted")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed")
}
})
}
return (
<div className="space-y-6">
{/* Create form */}
<form
onSubmit={handleCreate}
className="space-y-4 rounded-xl border border-white/[0.06] bg-[#16161f] p-6"
>
<div>
<h3 className="text-sm font-semibold text-white">Add an endpoint</h3>
<p className="mt-0.5 text-xs text-white/40">
We&apos;ll POST a signed JSON payload to this URL whenever a subscribed event happens.
</p>
</div>
<input
type="url"
required
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://hooks.zapier.com/hooks/catch/…"
className={inputClass}
/>
<input
type="text"
maxLength={200}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional) — e.g. Zapier: new tenant → Slack"
className={inputClass}
/>
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] p-4">
<label className="flex cursor-pointer items-center gap-2.5">
<input
type="checkbox"
checked={allEvents}
onChange={(e) => setAllEvents(e.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-white/5 text-indigo-500 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-white">Send all events</span>
</label>
{!allEvents && (
<div className="mt-3 grid gap-2 border-t border-white/[0.06] pt-3 sm:grid-cols-2">
{WEBHOOK_EVENTS.map((ev) => (
<label key={ev.id} className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={selected.has(ev.id)}
onChange={() => toggleEvent(ev.id)}
className="mt-0.5 h-4 w-4 rounded border-white/20 bg-white/5 text-indigo-500 focus:ring-indigo-500"
/>
<span>
<code className="text-xs font-mono text-indigo-300">{ev.id}</code>
<span className="block text-[11px] text-white/40">{ev.label}</span>
</span>
</label>
))}
</div>
)}
</div>
<button
type="submit"
disabled={creating || !url.trim() || (!allEvents && selected.size === 0)}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
Add endpoint
</button>
</form>
{/* Endpoints list */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
<div className="border-b border-white/[0.06] px-6 py-4">
<h3 className="text-sm font-semibold text-white">
Endpoints <span className="text-white/30">({initialEndpoints.length})</span>
</h3>
</div>
{initialEndpoints.length === 0 ? (
<div className="px-6 py-10 text-center text-sm text-white/40">
No endpoints yet. Add one above to start receiving events.
</div>
) : (
<ul className="divide-y divide-white/[0.06]">
{initialEndpoints.map((ep) => {
const busy = busyId === ep.id
const disabled = ep.status === "disabled"
return (
<li key={ep.id} className="space-y-3 px-6 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Webhook className="h-4 w-4 shrink-0 text-white/30" />
<p className="truncate text-sm font-medium text-white">{ep.url}</p>
{disabled && (
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white/40">
Disabled
</span>
)}
{ep.source === "zapier" && (
<span className="rounded-full border border-orange-500/20 bg-orange-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-orange-300">
Zapier
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 pl-6 text-xs text-white/40">
<span>{eventsSummary(ep.events)}</span>
<span>Last success {formatDate(ep.last_success_at)}</span>
{ep.failure_count > 0 && (
<span className="text-red-400">{ep.failure_count} recent failure{ep.failure_count > 1 ? "s" : ""}</span>
)}
</div>
{ep.description && (
<p className="mt-1 pl-6 text-xs text-white/30">{ep.description}</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => handleTest(ep)}
disabled={busy}
title="Send test event"
className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1.5 text-xs font-medium text-white/70 transition hover:bg-white/5 disabled:opacity-50"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
Test
</button>
<button
type="button"
onClick={() => handleToggle(ep)}
disabled={busy}
title={disabled ? "Enable" : "Disable"}
className="rounded-lg border border-white/10 p-2 text-white/50 transition hover:bg-white/5 hover:text-white disabled:opacity-50"
>
<Power className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleRotate(ep)}
disabled={busy}
title="Rotate signing secret"
className="rounded-lg border border-white/10 p-2 text-white/50 transition hover:bg-white/5 hover:text-white disabled:opacity-50"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleDelete(ep)}
disabled={busy}
title="Delete"
className="rounded-lg border border-red-500/20 p-2 text-red-400 transition hover:bg-red-500/10 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<SecretField secret={ep.secret} />
</li>
)
})}
</ul>
)}
</div>
</div>
)
}