Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/app/api/settings/webhooks/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
49 changes: 49 additions & 0 deletions src/app/api/settings/webhooks/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
24 changes: 24 additions & 0 deletions src/app/api/settings/webhooks/store.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
225 changes: 225 additions & 0 deletions src/app/settings/webhooks/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby="secret-modal-title"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60"
>
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow-xl p-6 space-y-4">
<h2 id="secret-modal-title" className="text-base font-semibold">
Webhook Secret — Save it now
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
This secret is shown only once. Use it to verify the{" "}
<code className="text-xs">X-Webhook-Signature</code> header on incoming requests.
</p>
<div className="flex items-start gap-2">
<code className="flex-1 break-all bg-gray-100 dark:bg-neutral-800 rounded px-3 py-2 text-xs">
{secret}
</code>
<button
onClick={handleCopy}
className="shrink-0 px-3 py-2 text-sm rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors"
aria-label="Copy secret to clipboard"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<p className="text-xs text-amber-600 dark:text-amber-400">
⚠ You will not be able to retrieve this secret again.
</p>
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
>
I&apos;ve saved it
</button>
</div>
</div>
</div>
);
}

/** Confirmation dialog for delete. */
function ConfirmDeleteModal({ url, onConfirm, onCancel }: { url: string; onConfirm: () => void; onCancel: () => void }) {
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-delete-title"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60"
>
<div className="w-full max-w-sm bg-white dark:bg-neutral-900 rounded-lg shadow-xl p-6 space-y-4">
<h2 id="confirm-delete-title" className="text-base font-semibold">
Delete webhook?
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 break-all">
This will permanently remove the endpoint <strong>{url}</strong>. Deliveries will stop immediately.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
className="px-4 py-2 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white text-sm font-semibold transition-colors"
>
Delete
</button>
</div>
</div>
</div>
);
}

export default function WebhooksPage() {
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
const [loading, setLoading] = useState(true);
const [revealSecret, setRevealSecret] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(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 (
<div className="max-w-3xl mx-auto px-4 py-8 space-y-8">
<div>
<h1 className="text-2xl font-semibold">Webhooks</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Register HTTPS endpoints to receive real-time payment event notifications.
</p>
</div>

{/* Add form */}
<WebhookForm onCreated={handleCreated} />

{/* Endpoint list */}
<section aria-label="Registered webhook endpoints">
<h2 className="text-base font-semibold mb-3">Registered Endpoints</h2>

{loading && (
<p className="text-sm text-gray-500">Loading…</p>
)}

{!loading && webhooks.length === 0 && (
<p className="text-sm text-gray-500">No webhooks configured yet.</p>
)}

<ul className="space-y-3">
{webhooks.map((wh) => (
<li
key={wh.id}
className="flex flex-col sm:flex-row sm:items-start gap-3 bg-white dark:bg-neutral-900 border border-gray-200 dark:border-gray-800 rounded-lg p-4"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium break-all">{wh.url}</p>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{wh.events.map((ev) => (
<span
key={ev}
className="text-xs px-2 py-0.5 rounded-full bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300"
>
{ev}
</span>
))}
</div>
<p className="text-xs text-gray-400 mt-1.5">
Added {new Date(wh.createdAt).toLocaleString()} ·{" "}
<span className={wh.status === "active" ? "text-green-500" : "text-gray-400"}>
{wh.status}
</span>
</p>
</div>

<div className="flex gap-2 shrink-0">
<button
onClick={() => handleRotate(wh.id)}
className="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={`Rotate secret for ${wh.url}`}
>
Rotate secret
</button>
<button
onClick={() => setDeleteTarget(wh)}
className="px-3 py-1.5 text-xs rounded bg-red-600 hover:bg-red-500 text-white transition-colors"
aria-label={`Delete webhook ${wh.url}`}
>
Delete
</button>
</div>
</li>
))}
</ul>
</section>

{/* One-time secret reveal modal */}
{revealSecret && (
<SecretModal secret={revealSecret} onClose={() => setRevealSecret(null)} />
)}

{/* Delete confirmation modal */}
{deleteTarget && (
<ConfirmDeleteModal
url={deleteTarget.url}
onConfirm={() => handleDelete(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
Loading