Skip to content

Commit f2da109

Browse files
authored
feat(settings): webhook endpoint configuration UI (#547)
Closes #516 - Add shared in-memory store at src/app/api/settings/webhooks/store.ts - Add GET/POST /api/settings/webhooks route: list endpoints, create with URL validation and event selection - Add DELETE/POST /api/settings/webhooks/[id] route: delete with 204, rotate generates new HMAC secret - Add WebhookForm component: URL + event-type checkboxes with validation - Add /settings/webhooks page: endpoint list, one-time secret reveal modal, delete confirmation dialog
1 parent 7994367 commit f2da109

5 files changed

Lines changed: 473 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import crypto from "crypto";
3+
import { webhookStore } from "../store";
4+
5+
/** DELETE /api/settings/webhooks/:id — remove a webhook endpoint */
6+
export async function DELETE(
7+
_req: NextRequest,
8+
{ params }: { params: { id: string } }
9+
) {
10+
const idx = webhookStore.findIndex((w) => w.id === params.id);
11+
if (idx === -1) {
12+
return NextResponse.json({ error: "Not found" }, { status: 404 });
13+
}
14+
webhookStore.splice(idx, 1);
15+
return new NextResponse(null, { status: 204 });
16+
}
17+
18+
/** POST /api/settings/webhooks/:id/rotate — generate a new HMAC secret */
19+
export async function POST(
20+
_req: NextRequest,
21+
{ params }: { params: { id: string } }
22+
) {
23+
const endpoint = webhookStore.find((w) => w.id === params.id);
24+
if (!endpoint) {
25+
return NextResponse.json({ error: "Not found" }, { status: 404 });
26+
}
27+
28+
const secret = `whsec_${crypto.randomBytes(24).toString("hex")}`;
29+
endpoint.secretHash = crypto.createHash("sha256").update(secret).digest("hex");
30+
31+
const { secretHash: _s, ...safe } = endpoint;
32+
return NextResponse.json({ ...safe, secret });
33+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import crypto from "crypto";
3+
import { webhookStore, ALL_EVENTS, type WebhookEndpoint, type WebhookEventType } from "./store";
4+
5+
function generateSecret() {
6+
return `whsec_${crypto.randomBytes(24).toString("hex")}`;
7+
}
8+
9+
export function GET() {
10+
// Strip secretHash before sending to client
11+
const safeList = webhookStore.map(({ secretHash: _s, ...rest }) => rest);
12+
return NextResponse.json(safeList);
13+
}
14+
15+
export async function POST(req: NextRequest) {
16+
const body = await req.json().catch(() => null);
17+
18+
if (!body?.url || typeof body.url !== "string") {
19+
return NextResponse.json({ error: "url is required" }, { status: 400 });
20+
}
21+
22+
try {
23+
new URL(body.url);
24+
} catch {
25+
return NextResponse.json({ error: "Invalid URL format" }, { status: 400 });
26+
}
27+
28+
const events: WebhookEventType[] = Array.isArray(body.events) ? body.events : [];
29+
const validEvents = events.filter((e): e is WebhookEventType => ALL_EVENTS.includes(e));
30+
if (validEvents.length === 0) {
31+
return NextResponse.json({ error: "At least one event type is required" }, { status: 400 });
32+
}
33+
34+
const secret = generateSecret();
35+
const endpoint: WebhookEndpoint = {
36+
id: crypto.randomUUID(),
37+
url: body.url,
38+
events: validEvents,
39+
status: "active",
40+
createdAt: new Date().toISOString(),
41+
secretHash: crypto.createHash("sha256").update(secret).digest("hex"),
42+
};
43+
44+
webhookStore.push(endpoint);
45+
46+
const { secretHash: _s, ...safe } = endpoint;
47+
// Return secret only once on creation
48+
return NextResponse.json({ ...safe, secret }, { status: 201 });
49+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Shared in-memory webhook store for demo purposes.
3+
* In production this would be a database table.
4+
*/
5+
6+
export type WebhookEventType = "invoice.created" | "invoice.funded" | "invoice.released";
7+
8+
export const ALL_EVENTS: WebhookEventType[] = [
9+
"invoice.created",
10+
"invoice.funded",
11+
"invoice.released",
12+
];
13+
14+
export interface WebhookEndpoint {
15+
id: string;
16+
url: string;
17+
events: WebhookEventType[];
18+
status: "active" | "disabled";
19+
createdAt: string;
20+
/** Hashed secret — never returned by API; only raw secret returned on create/rotate */
21+
secretHash: string;
22+
}
23+
24+
export const webhookStore: WebhookEndpoint[] = [];

src/app/settings/webhooks/page.tsx

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import WebhookForm from "@/components/settings/WebhookForm";
5+
import type { WebhookEventType } from "@/app/api/settings/webhooks/store";
6+
7+
interface Webhook {
8+
id: string;
9+
url: string;
10+
events: WebhookEventType[];
11+
status: "active" | "disabled";
12+
createdAt: string;
13+
}
14+
15+
/** One-time secret reveal modal shown after create or rotate. */
16+
function SecretModal({ secret, onClose }: { secret: string; onClose: () => void }) {
17+
const [copied, setCopied] = useState(false);
18+
19+
const handleCopy = () => {
20+
navigator.clipboard.writeText(secret).then(() => setCopied(true));
21+
};
22+
23+
return (
24+
<div
25+
role="dialog"
26+
aria-modal="true"
27+
aria-labelledby="secret-modal-title"
28+
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60"
29+
>
30+
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow-xl p-6 space-y-4">
31+
<h2 id="secret-modal-title" className="text-base font-semibold">
32+
Webhook Secret — Save it now
33+
</h2>
34+
<p className="text-sm text-gray-600 dark:text-gray-400">
35+
This secret is shown only once. Use it to verify the{" "}
36+
<code className="text-xs">X-Webhook-Signature</code> header on incoming requests.
37+
</p>
38+
<div className="flex items-start gap-2">
39+
<code className="flex-1 break-all bg-gray-100 dark:bg-neutral-800 rounded px-3 py-2 text-xs">
40+
{secret}
41+
</code>
42+
<button
43+
onClick={handleCopy}
44+
className="shrink-0 px-3 py-2 text-sm rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors"
45+
aria-label="Copy secret to clipboard"
46+
>
47+
{copied ? "Copied!" : "Copy"}
48+
</button>
49+
</div>
50+
<p className="text-xs text-amber-600 dark:text-amber-400">
51+
⚠ You will not be able to retrieve this secret again.
52+
</p>
53+
<div className="flex justify-end">
54+
<button
55+
onClick={onClose}
56+
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"
57+
>
58+
I&apos;ve saved it
59+
</button>
60+
</div>
61+
</div>
62+
</div>
63+
);
64+
}
65+
66+
/** Confirmation dialog for delete. */
67+
function ConfirmDeleteModal({ url, onConfirm, onCancel }: { url: string; onConfirm: () => void; onCancel: () => void }) {
68+
return (
69+
<div
70+
role="dialog"
71+
aria-modal="true"
72+
aria-labelledby="confirm-delete-title"
73+
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60"
74+
>
75+
<div className="w-full max-w-sm bg-white dark:bg-neutral-900 rounded-lg shadow-xl p-6 space-y-4">
76+
<h2 id="confirm-delete-title" className="text-base font-semibold">
77+
Delete webhook?
78+
</h2>
79+
<p className="text-sm text-gray-600 dark:text-gray-400 break-all">
80+
This will permanently remove the endpoint <strong>{url}</strong>. Deliveries will stop immediately.
81+
</p>
82+
<div className="flex gap-3 justify-end">
83+
<button
84+
onClick={onCancel}
85+
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"
86+
>
87+
Cancel
88+
</button>
89+
<button
90+
onClick={onConfirm}
91+
className="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white text-sm font-semibold transition-colors"
92+
>
93+
Delete
94+
</button>
95+
</div>
96+
</div>
97+
</div>
98+
);
99+
}
100+
101+
export default function WebhooksPage() {
102+
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
103+
const [loading, setLoading] = useState(true);
104+
const [revealSecret, setRevealSecret] = useState<string | null>(null);
105+
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
106+
107+
const loadWebhooks = async () => {
108+
try {
109+
const res = await fetch("/api/settings/webhooks");
110+
if (res.ok) setWebhooks(await res.json());
111+
} finally {
112+
setLoading(false);
113+
}
114+
};
115+
116+
useEffect(() => { loadWebhooks(); }, []);
117+
118+
const handleCreated = (data: Webhook & { secret: string }) => {
119+
const { secret, ...webhook } = data;
120+
setWebhooks((prev) => [webhook, ...prev]);
121+
setRevealSecret(secret);
122+
};
123+
124+
const handleDelete = async (webhook: Webhook) => {
125+
const res = await fetch(`/api/settings/webhooks/${webhook.id}`, { method: "DELETE" });
126+
if (res.ok || res.status === 204) {
127+
setWebhooks((prev) => prev.filter((w) => w.id !== webhook.id));
128+
}
129+
setDeleteTarget(null);
130+
};
131+
132+
const handleRotate = async (id: string) => {
133+
const res = await fetch(`/api/settings/webhooks/${id}`, { method: "POST" });
134+
if (!res.ok) return;
135+
const data = await res.json();
136+
setRevealSecret(data.secret);
137+
};
138+
139+
return (
140+
<div className="max-w-3xl mx-auto px-4 py-8 space-y-8">
141+
<div>
142+
<h1 className="text-2xl font-semibold">Webhooks</h1>
143+
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
144+
Register HTTPS endpoints to receive real-time payment event notifications.
145+
</p>
146+
</div>
147+
148+
{/* Add form */}
149+
<WebhookForm onCreated={handleCreated} />
150+
151+
{/* Endpoint list */}
152+
<section aria-label="Registered webhook endpoints">
153+
<h2 className="text-base font-semibold mb-3">Registered Endpoints</h2>
154+
155+
{loading && (
156+
<p className="text-sm text-gray-500">Loading…</p>
157+
)}
158+
159+
{!loading && webhooks.length === 0 && (
160+
<p className="text-sm text-gray-500">No webhooks configured yet.</p>
161+
)}
162+
163+
<ul className="space-y-3">
164+
{webhooks.map((wh) => (
165+
<li
166+
key={wh.id}
167+
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"
168+
>
169+
<div className="flex-1 min-w-0">
170+
<p className="text-sm font-medium break-all">{wh.url}</p>
171+
<div className="flex flex-wrap gap-1.5 mt-1.5">
172+
{wh.events.map((ev) => (
173+
<span
174+
key={ev}
175+
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"
176+
>
177+
{ev}
178+
</span>
179+
))}
180+
</div>
181+
<p className="text-xs text-gray-400 mt-1.5">
182+
Added {new Date(wh.createdAt).toLocaleString()} ·{" "}
183+
<span className={wh.status === "active" ? "text-green-500" : "text-gray-400"}>
184+
{wh.status}
185+
</span>
186+
</p>
187+
</div>
188+
189+
<div className="flex gap-2 shrink-0">
190+
<button
191+
onClick={() => handleRotate(wh.id)}
192+
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"
193+
aria-label={`Rotate secret for ${wh.url}`}
194+
>
195+
Rotate secret
196+
</button>
197+
<button
198+
onClick={() => setDeleteTarget(wh)}
199+
className="px-3 py-1.5 text-xs rounded bg-red-600 hover:bg-red-500 text-white transition-colors"
200+
aria-label={`Delete webhook ${wh.url}`}
201+
>
202+
Delete
203+
</button>
204+
</div>
205+
</li>
206+
))}
207+
</ul>
208+
</section>
209+
210+
{/* One-time secret reveal modal */}
211+
{revealSecret && (
212+
<SecretModal secret={revealSecret} onClose={() => setRevealSecret(null)} />
213+
)}
214+
215+
{/* Delete confirmation modal */}
216+
{deleteTarget && (
217+
<ConfirmDeleteModal
218+
url={deleteTarget.url}
219+
onConfirm={() => handleDelete(deleteTarget)}
220+
onCancel={() => setDeleteTarget(null)}
221+
/>
222+
)}
223+
</div>
224+
);
225+
}

0 commit comments

Comments
 (0)