page.tsx406 lines · main
| 1 | import Link from 'next/link'; |
| 2 | import { revalidatePath } from 'next/cache'; |
| 3 | |
| 4 | import { apiFetch, apiJson } from '../../../../../../lib/api'; |
| 5 | import { CreateSubscriberForm } from './create-subscriber-form'; |
| 6 | import { CreateWebhookForm } from './create-form'; |
| 7 | import { RotateSecretButton } from './rotate-secret-button'; |
| 8 | import { RotateSubscriberSecretButton } from './rotate-subscriber-secret-button'; |
| 9 | import { TestFireButton } from './test-fire-button'; |
| 10 | |
| 11 | interface 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 | |
| 28 | interface 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 | |
| 40 | interface FunctionNames { |
| 41 | names: string[]; |
| 42 | } |
| 43 | |
| 44 | export const dynamic = 'force-dynamic'; |
| 45 | |
| 46 | function publicApiOrigin(): string { |
| 47 | return process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? ''; |
| 48 | } |
| 49 | |
| 50 | export 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 | |
| 313 | function 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 | |
| 355 | function 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 | |
| 404 | function formatTimestamp(iso: string): string { |
| 405 | return new Date(iso).toISOString().replace('T', ' ').slice(0, 16) + ' utc'; |
| 406 | } |