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 ( +
+ 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. +
++ This will permanently remove the endpoint {url}. Deliveries will stop immediately. +
++ Register HTTPS endpoints to receive real-time payment event notifications. +
+Loading…
+ )} + + {!loading && webhooks.length === 0 && ( +No webhooks configured yet.
+ )} + +{wh.url}
++ Added {new Date(wh.createdAt).toLocaleString()} ·{" "} + + {wh.status} + +
+