diff --git a/src/app/api/settings/webhooks/[id]/route.ts b/src/app/api/settings/webhooks/[id]/route.ts new file mode 100644 index 0000000..4dc04a4 --- /dev/null +++ b/src/app/api/settings/webhooks/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import { webhookStore } from "../store"; + +/** DELETE /api/settings/webhooks/:id — remove a webhook endpoint */ +export async function DELETE( + _req: NextRequest, + { params }: { params: { id: string } } +) { + const idx = webhookStore.findIndex((w) => w.id === params.id); + if (idx === -1) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + webhookStore.splice(idx, 1); + return new NextResponse(null, { status: 204 }); +} + +/** POST /api/settings/webhooks/:id/rotate — generate a new HMAC secret */ +export async function POST( + _req: NextRequest, + { params }: { params: { id: string } } +) { + const endpoint = webhookStore.find((w) => w.id === params.id); + if (!endpoint) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const secret = `whsec_${crypto.randomBytes(24).toString("hex")}`; + endpoint.secretHash = crypto.createHash("sha256").update(secret).digest("hex"); + + const { secretHash: _s, ...safe } = endpoint; + return NextResponse.json({ ...safe, secret }); +} diff --git a/src/app/api/settings/webhooks/route.ts b/src/app/api/settings/webhooks/route.ts new file mode 100644 index 0000000..14d3e8c --- /dev/null +++ b/src/app/api/settings/webhooks/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import { webhookStore, ALL_EVENTS, type WebhookEndpoint, type WebhookEventType } from "./store"; + +function generateSecret() { + return `whsec_${crypto.randomBytes(24).toString("hex")}`; +} + +export function GET() { + // Strip secretHash before sending to client + const safeList = webhookStore.map(({ secretHash: _s, ...rest }) => rest); + return NextResponse.json(safeList); +} + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => null); + + if (!body?.url || typeof body.url !== "string") { + return NextResponse.json({ error: "url is required" }, { status: 400 }); + } + + try { + new URL(body.url); + } catch { + return NextResponse.json({ error: "Invalid URL format" }, { status: 400 }); + } + + const events: WebhookEventType[] = Array.isArray(body.events) ? body.events : []; + const validEvents = events.filter((e): e is WebhookEventType => ALL_EVENTS.includes(e)); + if (validEvents.length === 0) { + return NextResponse.json({ error: "At least one event type is required" }, { status: 400 }); + } + + const secret = generateSecret(); + const endpoint: WebhookEndpoint = { + id: crypto.randomUUID(), + url: body.url, + events: validEvents, + status: "active", + createdAt: new Date().toISOString(), + secretHash: crypto.createHash("sha256").update(secret).digest("hex"), + }; + + webhookStore.push(endpoint); + + const { secretHash: _s, ...safe } = endpoint; + // Return secret only once on creation + return NextResponse.json({ ...safe, secret }, { status: 201 }); +} diff --git a/src/app/api/settings/webhooks/store.ts b/src/app/api/settings/webhooks/store.ts new file mode 100644 index 0000000..8a96a0f --- /dev/null +++ b/src/app/api/settings/webhooks/store.ts @@ -0,0 +1,24 @@ +/** + * Shared in-memory webhook store for demo purposes. + * In production this would be a database table. + */ + +export type WebhookEventType = "invoice.created" | "invoice.funded" | "invoice.released"; + +export const ALL_EVENTS: WebhookEventType[] = [ + "invoice.created", + "invoice.funded", + "invoice.released", +]; + +export interface WebhookEndpoint { + id: string; + url: string; + events: WebhookEventType[]; + status: "active" | "disabled"; + createdAt: string; + /** Hashed secret — never returned by API; only raw secret returned on create/rotate */ + secretHash: string; +} + +export const webhookStore: WebhookEndpoint[] = []; diff --git a/src/app/settings/webhooks/page.tsx b/src/app/settings/webhooks/page.tsx new file mode 100644 index 0000000..bfe45d7 --- /dev/null +++ b/src/app/settings/webhooks/page.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useState } from "react"; +import WebhookForm from "@/components/settings/WebhookForm"; +import type { WebhookEventType } from "@/app/api/settings/webhooks/store"; + +interface Webhook { + id: string; + url: string; + events: WebhookEventType[]; + status: "active" | "disabled"; + createdAt: string; +} + +/** One-time secret reveal modal shown after create or rotate. */ +function SecretModal({ secret, onClose }: { secret: string; onClose: () => void }) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(secret).then(() => setCopied(true)); + }; + + return ( +
+
+

+ Webhook Secret — Save it now +

+

+ This secret is shown only once. Use it to verify the{" "} + X-Webhook-Signature header on incoming requests. +

+
+ + {secret} + + +
+

+ ⚠ You will not be able to retrieve this secret again. +

+
+ +
+
+
+ ); +} + +/** Confirmation dialog for delete. */ +function ConfirmDeleteModal({ url, onConfirm, onCancel }: { url: string; onConfirm: () => void; onCancel: () => void }) { + return ( +
+
+

+ Delete webhook? +

+

+ This will permanently remove the endpoint {url}. Deliveries will stop immediately. +

+
+ + +
+
+
+ ); +} + +export default function WebhooksPage() { + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(true); + const [revealSecret, setRevealSecret] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const loadWebhooks = async () => { + try { + const res = await fetch("/api/settings/webhooks"); + if (res.ok) setWebhooks(await res.json()); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadWebhooks(); }, []); + + const handleCreated = (data: Webhook & { secret: string }) => { + const { secret, ...webhook } = data; + setWebhooks((prev) => [webhook, ...prev]); + setRevealSecret(secret); + }; + + const handleDelete = async (webhook: Webhook) => { + const res = await fetch(`/api/settings/webhooks/${webhook.id}`, { method: "DELETE" }); + if (res.ok || res.status === 204) { + setWebhooks((prev) => prev.filter((w) => w.id !== webhook.id)); + } + setDeleteTarget(null); + }; + + const handleRotate = async (id: string) => { + const res = await fetch(`/api/settings/webhooks/${id}`, { method: "POST" }); + if (!res.ok) return; + const data = await res.json(); + setRevealSecret(data.secret); + }; + + return ( +
+
+

Webhooks

+

+ Register HTTPS endpoints to receive real-time payment event notifications. +

+
+ + {/* Add form */} + + + {/* Endpoint list */} +
+

Registered Endpoints

+ + {loading && ( +

Loading…

+ )} + + {!loading && webhooks.length === 0 && ( +

No webhooks configured yet.

+ )} + +
    + {webhooks.map((wh) => ( +
  • +
    +

    {wh.url}

    +
    + {wh.events.map((ev) => ( + + {ev} + + ))} +
    +

    + Added {new Date(wh.createdAt).toLocaleString()} ·{" "} + + {wh.status} + +

    +
    + +
    + + +
    +
  • + ))} +
+
+ + {/* One-time secret reveal modal */} + {revealSecret && ( + setRevealSecret(null)} /> + )} + + {/* Delete confirmation modal */} + {deleteTarget && ( + handleDelete(deleteTarget)} + onCancel={() => setDeleteTarget(null)} + /> + )} +
+ ); +} diff --git a/src/components/settings/WebhookForm.tsx b/src/components/settings/WebhookForm.tsx new file mode 100644 index 0000000..a22a01c --- /dev/null +++ b/src/components/settings/WebhookForm.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useState } from "react"; +import type { WebhookEventType } from "@/app/api/settings/webhooks/store"; + +const ALL_EVENTS: { value: WebhookEventType; label: string }[] = [ + { value: "invoice.created", label: "Invoice Created" }, + { value: "invoice.funded", label: "Invoice Funded" }, + { value: "invoice.released", label: "Invoice Released" }, +]; + +interface Props { + onCreated: (webhook: { id: string; url: string; events: WebhookEventType[]; status: string; createdAt: string; secret: string }) => void; +} + +export default function WebhookForm({ onCreated }: Props) { + const [url, setUrl] = useState(""); + const [events, setEvents] = useState>(new Set()); + const [urlError, setUrlError] = useState(null); + const [eventError, setEventError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const toggleEvent = (e: WebhookEventType) => { + setEvents((prev) => { + const next = new Set(prev); + if (next.has(e)) next.delete(e); + else next.add(e); + return next; + }); + setEventError(null); + }; + + const validate = () => { + let valid = true; + try { + const u = new URL(url); + if (!["http:", "https:"].includes(u.protocol)) throw new Error(); + setUrlError(null); + } catch { + setUrlError("Enter a valid http(s) URL."); + valid = false; + } + if (events.size === 0) { + setEventError("Select at least one event type."); + valid = false; + } + return valid; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + + setSubmitting(true); + try { + const res = await fetch("/api/settings/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, events: Array.from(events) }), + }); + const data = await res.json(); + if (!res.ok) { + setUrlError(data.error ?? "Failed to create webhook."); + return; + } + onCreated(data); + setUrl(""); + setEvents(new Set()); + } catch { + setUrlError("Network error. Please try again."); + } finally { + setSubmitting(false); + } + }; + + return ( +
+

Add Endpoint

+ + {/* URL field */} +
+ + { setUrl(e.target.value); setUrlError(null); }} + aria-describedby={urlError ? "wh-url-error" : undefined} + aria-invalid={urlError ? "true" : undefined} + className="w-full min-h-10 border border-gray-300 dark:border-gray-700 rounded px-3 py-2 text-sm bg-transparent focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + {urlError && ( + + )} +
+ + {/* Event checkboxes */} +
+ + Event Types * + +
+ {ALL_EVENTS.map(({ value, label }) => ( + + ))} +
+ {eventError && ( +

+ {eventError} +

+ )} +
+ + +
+ ); +}