page.tsx406 lines · main
1import Link from 'next/link';
2import { revalidatePath } from 'next/cache';
3
4import { apiFetch, apiJson } from '../../../../../../lib/api';
5import { CreateSubscriberForm } from './create-subscriber-form';
6import { CreateWebhookForm } from './create-form';
7import { RotateSecretButton } from './rotate-secret-button';
8import { RotateSubscriberSecretButton } from './rotate-subscriber-secret-button';
9import { TestFireButton } from './test-fire-button';
10
11interface Endpoint {
12 id: string;
13 name: string;
14 functionName: string;
15 enabled: boolean;
16 lastDeliveryAt: string | null;
17 lastDeliveryStatus:
18 | 'ok'
19 | 'rejected_signature'
20 | 'rejected_replay'
21 | 'invoke_error'
22 | 'disabled'
23 | null;
24 createdAt: string;
25 updatedAt: string;
26}
27
28interface Subscriber {
29 id: string;
30 name: string;
31 targetUrl: string;
32 eventTypes: string;
33 enabled: boolean;
34 lastDeliveryAt: string | null;
35 lastDeliveryStatus: 'pending' | 'ok' | 'failed' | 'cancelled' | null;
36 createdAt: string;
37 updatedAt: string;
38}
39
40interface FunctionNames {
41 names: string[];
42}
43
44export const dynamic = 'force-dynamic';
45
46function publicApiOrigin(): string {
47 return process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? '';
48}
49
50export default async function WebhooksPage({ params }: { params: Promise<{ id: string }> }) {
51 const { id } = await params;
52
53 const [endpointsResult, fnNamesResult, subscribersResult] = await Promise.all([
54 apiJson<{ endpoints: Endpoint[] }>(`/v1/projects/${id}/webhooks`).catch(() => ({
55 endpoints: [] as Endpoint[],
56 })),
57 apiJson<FunctionNames>(`/v1/projects/${id}/function-names`).catch(() => ({
58 names: [] as string[],
59 })),
60 apiJson<{ subscribers: Subscriber[]; knownEventTypes: string[] }>(
61 `/v1/projects/${id}/outbound-webhooks`,
62 ).catch(() => ({ subscribers: [] as Subscriber[], knownEventTypes: [] as string[] })),
63 ]);
64 const endpoints = endpointsResult.endpoints;
65 const functionNames = fnNamesResult.names;
66 const subscribers = subscribersResult.subscribers;
67 const knownEventTypes = subscribersResult.knownEventTypes;
68
69 async function toggle(formData: FormData) {
70 'use server';
71 const endpointId = String(formData.get('endpointId') ?? '');
72 const enabled = String(formData.get('enabled') ?? '') === 'true';
73 const res = await apiFetch(`/v1/projects/${id}/webhooks/${endpointId}`, {
74 method: 'PATCH',
75 headers: { 'content-type': 'application/json' },
76 body: JSON.stringify({ enabled }),
77 });
78 if (!res.ok) {
79 const body = await res.text().catch(() => '');
80 throw new Error(body || `toggle failed: ${res.status}`);
81 }
82 revalidatePath(`/dashboard/projects/${id}/webhooks`);
83 }
84
85 async function remove(formData: FormData) {
86 'use server';
87 const endpointId = String(formData.get('endpointId') ?? '');
88 const res = await apiFetch(`/v1/projects/${id}/webhooks/${endpointId}`, {
89 method: 'DELETE',
90 });
91 if (!res.ok) throw new Error(`delete failed: ${res.status}`);
92 revalidatePath(`/dashboard/projects/${id}/webhooks`);
93 }
94
95 async function toggleSubscriber(formData: FormData) {
96 'use server';
97 const subscriberId = String(formData.get('subscriberId') ?? '');
98 const enabled = String(formData.get('enabled') ?? '') === 'true';
99 const res = await apiFetch(`/v1/projects/${id}/outbound-webhooks/${subscriberId}`, {
100 method: 'PATCH',
101 headers: { 'content-type': 'application/json' },
102 body: JSON.stringify({ enabled }),
103 });
104 if (!res.ok) {
105 const body = await res.text().catch(() => '');
106 throw new Error(body || `toggle failed: ${res.status}`);
107 }
108 revalidatePath(`/dashboard/projects/${id}/webhooks`);
109 }
110
111 async function removeSubscriber(formData: FormData) {
112 'use server';
113 const subscriberId = String(formData.get('subscriberId') ?? '');
114 const res = await apiFetch(`/v1/projects/${id}/outbound-webhooks/${subscriberId}`, {
115 method: 'DELETE',
116 });
117 if (!res.ok) throw new Error(`delete failed: ${res.status}`);
118 revalidatePath(`/dashboard/projects/${id}/webhooks`);
119 }
120
121 const apiOrigin = publicApiOrigin();
122 // Webhook URLs use the same origin as the api (since the public route
123 // is mounted on the api app). We surface this so the dashboard can
124 // build the full URL the customer needs to paste into the source.
125 const webhookOrigin = apiOrigin;
126
127 return (
128 <div className="flex flex-col gap-6">
129 <header>
130 <h2 className="font-mono text-sm text-[var(--color-text)]">webhooks</h2>
131 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
132 inbound HTTP receivers. each endpoint dispatches a verified payload to one of your
133 deployed functions. authentication is HMAC-SHA256 — briven verifies every request and
134 rejects anything unsigned, replayed, or mis-keyed before your function runs.
135 </p>
136 </header>
137
138 <CreateWebhookForm
139 projectId={id}
140 apiOrigin={apiOrigin}
141 webhookOrigin={webhookOrigin}
142 functionNames={functionNames}
143 />
144
145 <section className="flex flex-col gap-3">
146 <h3 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
147 inbound endpoints ({endpoints.length})
148 </h3>
149 {endpoints.length === 0 ? (
150 <p className="rounded-md border border-dashed border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 text-center font-mono text-sm text-[var(--color-text-muted)]">
151 no inbound endpoints yet.
152 </p>
153 ) : (
154 <ul className="flex flex-col gap-2">
155 {endpoints.map((e) => (
156 <li
157 key={e.id}
158 className="grid grid-cols-1 gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3 sm:grid-cols-[1fr_auto]"
159 >
160 <div className="min-w-0">
161 <div className="flex flex-wrap items-baseline gap-2">
162 <span className="font-mono text-sm text-[var(--color-text)]">{e.name}</span>
163 <StatusPill enabled={e.enabled} lastStatus={e.lastDeliveryStatus} />
164 </div>
165 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
166 calls <code>{e.functionName}</code>
167 {e.lastDeliveryAt ? (
168 <span className="ml-2 text-[var(--color-text-subtle)]">
169 · last fire {formatTimestamp(e.lastDeliveryAt)}
170 </span>
171 ) : (
172 <span className="ml-2 text-[var(--color-text-subtle)]">· never fired</span>
173 )}
174 </p>
175 <p className="mt-1 truncate font-mono text-[10px] text-[var(--color-text-subtle)]">
176 POST {webhookOrigin}/webhooks/{id}/{e.id}
177 </p>
178 </div>
179
180 <div className="flex flex-wrap items-center gap-2">
181 <TestFireButton projectId={id} endpointId={e.id} apiOrigin={apiOrigin} />
182 <Link
183 href={`/dashboard/projects/${id}/webhooks/${e.id}/deliveries`}
184 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]"
185 >
186 deliveries →
187 </Link>
188 <RotateSecretButton
189 projectId={id}
190 endpointId={e.id}
191 endpointName={e.name}
192 apiOrigin={apiOrigin}
193 />
194 <form action={toggle}>
195 <input type="hidden" name="endpointId" value={e.id} />
196 <input type="hidden" name="enabled" value={(!e.enabled).toString()} />
197 <button
198 type="submit"
199 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]"
200 >
201 {e.enabled ? 'pause' : 'resume'}
202 </button>
203 </form>
204 <form action={remove}>
205 <input type="hidden" name="endpointId" value={e.id} />
206 <button
207 type="submit"
208 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-error)] hover:text-[var(--color-error)]"
209 >
210 delete
211 </button>
212 </form>
213 </div>
214 </li>
215 ))}
216 </ul>
217 )}
218 </section>
219
220 {/* ─── Outbound webhooks ─────────────────────────────────────── */}
221 <header className="border-t border-[var(--color-border-subtle)] pt-6">
222 <h2 className="font-mono text-sm text-[var(--color-text)]">outbound webhooks</h2>
223 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
224 platform → your service. briven POSTs to subscribers when project events fire (deploy
225 succeeded/failed, tier changed, project suspended/resumed). every request is signed —
226 verify with X-Briven-Signature exactly like the inbound surface.
227 </p>
228 </header>
229
230 <CreateSubscriberForm
231 projectId={id}
232 apiOrigin={apiOrigin}
233 knownEventTypes={knownEventTypes}
234 />
235
236 <section className="flex flex-col gap-3">
237 <h3 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
238 outbound subscribers ({subscribers.length})
239 </h3>
240 {subscribers.length === 0 ? (
241 <p className="rounded-md border border-dashed border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 text-center font-mono text-sm text-[var(--color-text-muted)]">
242 no outbound subscribers yet.
243 </p>
244 ) : (
245 <ul className="flex flex-col gap-2">
246 {subscribers.map((s) => (
247 <li
248 key={s.id}
249 className="grid grid-cols-1 gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3 sm:grid-cols-[1fr_auto]"
250 >
251 <div className="min-w-0">
252 <div className="flex flex-wrap items-baseline gap-2">
253 <span className="font-mono text-sm text-[var(--color-text)]">{s.name}</span>
254 <OutboundStatusPill
255 enabled={s.enabled}
256 lastStatus={s.lastDeliveryStatus}
257 />
258 </div>
259 <p className="mt-1 truncate font-mono text-xs text-[var(--color-text-muted)]">
260 POST <code>{s.targetUrl}</code>
261 </p>
262 <p className="mt-1 font-mono text-[10px] text-[var(--color-text-subtle)]">
263 listens for: <code>{s.eventTypes}</code>
264 {s.lastDeliveryAt ? (
265 <span className="ml-2">· last fire {formatTimestamp(s.lastDeliveryAt)}</span>
266 ) : (
267 <span className="ml-2">· never fired</span>
268 )}
269 </p>
270 </div>
271 <div className="flex flex-wrap items-center gap-2">
272 <Link
273 href={`/dashboard/projects/${id}/webhooks/outbound/${s.id}/deliveries`}
274 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]"
275 >
276 deliveries →
277 </Link>
278 <RotateSubscriberSecretButton
279 projectId={id}
280 subscriberId={s.id}
281 subscriberName={s.name}
282 apiOrigin={apiOrigin}
283 />
284 <form action={toggleSubscriber}>
285 <input type="hidden" name="subscriberId" value={s.id} />
286 <input type="hidden" name="enabled" value={(!s.enabled).toString()} />
287 <button
288 type="submit"
289 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]"
290 >
291 {s.enabled ? 'pause' : 'resume'}
292 </button>
293 </form>
294 <form action={removeSubscriber}>
295 <input type="hidden" name="subscriberId" value={s.id} />
296 <button
297 type="submit"
298 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-error)] hover:text-[var(--color-error)]"
299 >
300 delete
301 </button>
302 </form>
303 </div>
304 </li>
305 ))}
306 </ul>
307 )}
308 </section>
309 </div>
310 );
311}
312
313function OutboundStatusPill({
314 enabled,
315 lastStatus,
316}: {
317 enabled: boolean;
318 lastStatus: Subscriber['lastDeliveryStatus'];
319}) {
320 if (!enabled) {
321 return (
322 <span className="rounded-full border border-[var(--color-border-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
323 paused
324 </span>
325 );
326 }
327 if (!lastStatus || lastStatus === 'pending') {
328 return (
329 <span className="rounded-full border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-primary)]">
330 ready
331 </span>
332 );
333 }
334 if (lastStatus === 'ok') {
335 return (
336 <span className="rounded-full border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-primary)]">
337 last ok
338 </span>
339 );
340 }
341 if (lastStatus === 'failed') {
342 return (
343 <span className="rounded-full border border-[var(--color-error)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-error)]">
344 last failed
345 </span>
346 );
347 }
348 return (
349 <span className="rounded-full border border-[var(--color-border-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
350 {lastStatus}
351 </span>
352 );
353}
354
355function StatusPill({
356 enabled,
357 lastStatus,
358}: {
359 enabled: boolean;
360 lastStatus: Endpoint['lastDeliveryStatus'];
361}) {
362 if (!enabled) {
363 return (
364 <span className="rounded-full border border-[var(--color-border-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
365 paused
366 </span>
367 );
368 }
369 if (!lastStatus) {
370 return (
371 <span className="rounded-full border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-primary)]">
372 ready
373 </span>
374 );
375 }
376 if (lastStatus === 'ok') {
377 return (
378 <span className="rounded-full border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-primary)]">
379 last ok
380 </span>
381 );
382 }
383 if (lastStatus === 'invoke_error') {
384 return (
385 <span className="rounded-full border border-[var(--color-error)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-error)]">
386 last fn error
387 </span>
388 );
389 }
390 if (lastStatus === 'rejected_signature' || lastStatus === 'rejected_replay') {
391 return (
392 <span className="rounded-full border border-[var(--color-warning)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-warning)]">
393 sig rejected
394 </span>
395 );
396 }
397 return (
398 <span className="rounded-full border border-[var(--color-border-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
399 {lastStatus}
400 </span>
401 );
402}
403
404function formatTimestamp(iso: string): string {
405 return new Date(iso).toISOString().replace('T', ' ').slice(0, 16) + ' utc';
406}