idp-clients-client.tsx465 lines · main
1'use client';
2
3import { useCallback, useEffect, useState } from 'react';
4
5import type { AuthV2ProjectRow } from '../lib/auth-v2-types';
6
7type ClientRow = {
8 id: string;
9 clientId: string;
10 name: string;
11 logoUrl: string | null;
12 isPublic: boolean;
13 redirectUris: string[];
14 scopes: string[];
15 hint: string | null;
16 revokedAt: string | null;
17 createdAt: string;
18};
19
20/**
21 * Production IdP client registry — apps that use Briven as login office.
22 * Renewing a secret kills the old one; revoke wipes secret + tokens;
23 * hard-delete removes leftover apps.
24 */
25export function AuthIdpClientsClient({
26 projects,
27 lockProjectId,
28}: {
29 projects: AuthV2ProjectRow[];
30 lockProjectId?: string;
31}) {
32 const [projectId, setProjectId] = useState(
33 lockProjectId ?? projects[0]?.id ?? '',
34 );
35 const [clients, setClients] = useState<ClientRow[]>([]);
36 const [showRevoked, setShowRevoked] = useState(false);
37 const [issuer, setIssuer] = useState('');
38 const [discovery, setDiscovery] = useState('');
39 const [name, setName] = useState('');
40 const [logoUrl, setLogoUrl] = useState('');
41 const [redirectUris, setRedirectUris] = useState(
42 'https://localhost:3000/callback',
43 );
44 const [isPublic, setIsPublic] = useState(false);
45 const [created, setCreated] = useState<{
46 clientId: string;
47 clientSecret: string | null;
48 note?: string;
49 } | null>(null);
50 const [err, setErr] = useState<string | null>(null);
51 const [note, setNote] = useState<string | null>(null);
52 const [pending, setPending] = useState(false);
53
54 const load = useCallback(
55 async (id: string, includeRevoked = showRevoked) => {
56 if (!id) return;
57 setErr(null);
58 const q = includeRevoked ? '?includeRevoked=1' : '';
59 const res = await fetch(
60 `/api/v1/auth-core/projects/${encodeURIComponent(id)}/oidc/clients${q}`,
61 { credentials: 'include', cache: 'no-store' },
62 );
63 if (res.status === 401) {
64 setErr('sign in to briven.tech to manage IdP clients');
65 return;
66 }
67 if (res.status === 403) {
68 setErr('you need admin access on this project');
69 return;
70 }
71 if (!res.ok) {
72 setErr(`load failed (${res.status})`);
73 return;
74 }
75 const body = (await res.json()) as {
76 clients?: ClientRow[];
77 issuer?: string;
78 discovery?: string;
79 };
80 setClients(body.clients ?? []);
81 setIssuer(body.issuer ?? '');
82 setDiscovery(body.discovery ?? '');
83 },
84 [showRevoked],
85 );
86
87 useEffect(() => {
88 if (projectId) void load(projectId, showRevoked);
89 }, [projectId, load, showRevoked]);
90
91 async function create(): Promise<void> {
92 if (!projectId) return;
93 setPending(true);
94 setErr(null);
95 setCreated(null);
96 setNote(null);
97 try {
98 const uris = redirectUris
99 .split(/[\n,]+/)
100 .map((u) => u.trim())
101 .filter(Boolean);
102 const res = await fetch(
103 `/api/v1/auth-core/projects/${encodeURIComponent(projectId)}/oidc/clients`,
104 {
105 method: 'POST',
106 credentials: 'include',
107 headers: { 'content-type': 'application/json' },
108 body: JSON.stringify({
109 name: name || 'My app',
110 redirectUris: uris,
111 logoUrl: logoUrl || undefined,
112 isPublic,
113 }),
114 },
115 );
116 const body = (await res.json().catch(() => ({}))) as {
117 client?: { clientId?: string; clientSecret?: string | null };
118 message?: string;
119 note?: string;
120 };
121 if (!res.ok) throw new Error(body.message ?? `http ${res.status}`);
122 if (!body.client?.clientId) throw new Error('no client id returned');
123 setCreated({
124 clientId: body.client.clientId,
125 clientSecret: body.client.clientSecret ?? null,
126 note: body.note,
127 });
128 setName('');
129 await load(projectId, showRevoked);
130 } catch (e) {
131 setErr(e instanceof Error ? e.message : 'create failed');
132 } finally {
133 setPending(false);
134 }
135 }
136
137 async function rotateSecret(clientId: string): Promise<void> {
138 if (!projectId) return;
139 const ok = window.confirm(
140 'Generate a new client secret?\n\nThe old secret stops working immediately. Apps using this client must be updated with the new secret. Live refresh tokens for this app are revoked.',
141 );
142 if (!ok) return;
143 setPending(true);
144 setErr(null);
145 setNote(null);
146 setCreated(null);
147 try {
148 const res = await fetch(
149 `/api/v1/auth-core/projects/${encodeURIComponent(projectId)}/oidc/clients/${encodeURIComponent(clientId)}/rotate-secret`,
150 { method: 'POST', credentials: 'include' },
151 );
152 const body = (await res.json().catch(() => ({}))) as {
153 client?: { clientId?: string; clientSecret?: string | null };
154 message?: string;
155 note?: string;
156 };
157 if (!res.ok) throw new Error(body.message ?? `http ${res.status}`);
158 if (!body.client?.clientId || !body.client.clientSecret) {
159 throw new Error('rotate did not return a new secret');
160 }
161 setCreated({
162 clientId: body.client.clientId,
163 clientSecret: body.client.clientSecret,
164 note: body.note,
165 });
166 setNote('Secret rotated — old secret is dead. Copy the new one below.');
167 await load(projectId, showRevoked);
168 } catch (e) {
169 setErr(e instanceof Error ? e.message : 'rotate failed');
170 } finally {
171 setPending(false);
172 }
173 }
174
175 async function revoke(clientId: string): Promise<void> {
176 if (!projectId) return;
177 const ok = window.confirm(
178 'Revoke this app?\n\nThe secret is wiped and tokens are killed. The row stays until you delete it.',
179 );
180 if (!ok) return;
181 setPending(true);
182 setErr(null);
183 setNote(null);
184 try {
185 const res = await fetch(
186 `/api/v1/auth-core/projects/${encodeURIComponent(projectId)}/oidc/clients/${encodeURIComponent(clientId)}`,
187 { method: 'DELETE', credentials: 'include' },
188 );
189 if (!res.ok) {
190 const body = (await res.json().catch(() => ({}))) as {
191 message?: string;
192 };
193 throw new Error(body.message ?? `http ${res.status}`);
194 }
195 setNote('App revoked — old credentials no longer work.');
196 await load(projectId, showRevoked);
197 } catch (e) {
198 setErr(e instanceof Error ? e.message : 'revoke failed');
199 } finally {
200 setPending(false);
201 }
202 }
203
204 async function hardDelete(clientId: string): Promise<void> {
205 if (!projectId) return;
206 const ok = window.confirm(
207 'Permanently delete this app row and leftovers?\n\nThis cannot be undone.',
208 );
209 if (!ok) return;
210 setPending(true);
211 setErr(null);
212 setNote(null);
213 try {
214 const res = await fetch(
215 `/api/v1/auth-core/projects/${encodeURIComponent(projectId)}/oidc/clients/${encodeURIComponent(clientId)}?hard=1`,
216 { method: 'DELETE', credentials: 'include' },
217 );
218 if (!res.ok) {
219 const body = (await res.json().catch(() => ({}))) as {
220 message?: string;
221 };
222 throw new Error(body.message ?? `http ${res.status}`);
223 }
224 setNote('App permanently deleted.');
225 await load(projectId, showRevoked);
226 } catch (e) {
227 setErr(e instanceof Error ? e.message : 'delete failed');
228 } finally {
229 setPending(false);
230 }
231 }
232
233 if (projects.length === 0) {
234 return (
235 <p className="font-mono text-xs text-[var(--color-text-muted)]">
236 no projects yet
237 </p>
238 );
239 }
240
241 return (
242 <div className="flex max-w-2xl flex-col gap-6">
243 {!lockProjectId ? (
244 <label className="flex flex-col gap-1 font-mono text-xs">
245 <span className="text-[var(--color-text-muted)]">project</span>
246 <select
247 value={projectId}
248 onChange={(e) => setProjectId(e.target.value)}
249 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
250 style={{ borderColor: 'var(--auth-accent-border)' }}
251 >
252 {projects.map((p) => (
253 <option key={p.id} value={p.id}>
254 {p.name}
255 </option>
256 ))}
257 </select>
258 </label>
259 ) : null}
260
261 {issuer ? (
262 <div className="rounded-md border border-[var(--color-border-subtle)] p-3 font-mono text-[11px] text-[var(--color-text-muted)]">
263 <p>
264 issuer:{' '}
265 <code className="break-all text-[var(--color-text)]">{issuer}</code>
266 </p>
267 <p className="mt-1">
268 discovery:{' '}
269 <code className="break-all text-[var(--color-text)]">
270 {discovery}
271 </code>
272 </p>
273 <p className="mt-2">
274 outside apps use the standard OpenID Connect flow against these
275 URLs. regenerating a secret kills the old one immediately.
276 </p>
277 </div>
278 ) : null}
279
280 <section className="flex flex-col gap-3">
281 <h3 className="font-mono text-sm text-[var(--color-text)]">
282 register an app
283 </h3>
284 <label className="flex flex-col gap-1 font-mono text-xs">
285 <span className="text-[var(--color-text-muted)]">app name</span>
286 <input
287 value={name}
288 onChange={(e) => setName(e.target.value)}
289 placeholder="Acme Dashboard"
290 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
291 style={{ borderColor: 'var(--auth-accent-border)' }}
292 />
293 </label>
294 <label className="flex flex-col gap-1 font-mono text-xs">
295 <span className="text-[var(--color-text-muted)]">
296 logo URL (https, optional)
297 </span>
298 <input
299 value={logoUrl}
300 onChange={(e) => setLogoUrl(e.target.value)}
301 placeholder="https://…"
302 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
303 style={{ borderColor: 'var(--auth-accent-border)' }}
304 />
305 </label>
306 <label className="flex flex-col gap-1 font-mono text-xs">
307 <span className="text-[var(--color-text-muted)]">
308 redirect URIs (one per line)
309 </span>
310 <textarea
311 value={redirectUris}
312 onChange={(e) => setRedirectUris(e.target.value)}
313 rows={3}
314 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
315 style={{ borderColor: 'var(--auth-accent-border)' }}
316 />
317 </label>
318 <label className="flex items-center gap-2 font-mono text-xs text-[var(--color-text)]">
319 <input
320 type="checkbox"
321 checked={isPublic}
322 onChange={(e) => setIsPublic(e.target.checked)}
323 />
324 public client (SPA / mobile — PKCE required, no secret)
325 </label>
326 <button
327 type="button"
328 disabled={pending}
329 onClick={() => void create()}
330 className="self-start rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
331 style={{ background: '#FFFD74' }}
332 >
333 {pending ? 'creating…' : 'create IdP client'}
334 </button>
335 </section>
336
337 {created ? (
338 <div
339 className="rounded-md border p-3 font-mono text-xs space-y-2"
340 style={{ borderColor: 'var(--auth-accent-border)' }}
341 >
342 <p className="text-[var(--color-text-muted)]">
343 copy now — secret is only shown once:
344 </p>
345 <p>
346 client_id:{' '}
347 <code className="break-all text-[var(--color-text)]">
348 {created.clientId}
349 </code>
350 </p>
351 {created.clientSecret ? (
352 <p>
353 client_secret:{' '}
354 <code className="break-all text-[var(--color-text)]">
355 {created.clientSecret}
356 </code>
357 </p>
358 ) : (
359 <p className="text-[var(--color-text-muted)]">
360 public client — use PKCE (S256), no secret
361 </p>
362 )}
363 {created.note ? (
364 <p className="text-[10px] text-[var(--color-text-muted)]">
365 {created.note}
366 </p>
367 ) : null}
368 </div>
369 ) : null}
370
371 <section className="flex flex-col gap-2">
372 <div className="flex flex-wrap items-center justify-between gap-2">
373 <h3 className="font-mono text-sm text-[var(--color-text)]">
374 registered apps
375 </h3>
376 <label className="flex items-center gap-2 font-mono text-[10px] text-[var(--color-text-muted)]">
377 <input
378 type="checkbox"
379 checked={showRevoked}
380 onChange={(e) => setShowRevoked(e.target.checked)}
381 />
382 show revoked leftovers
383 </label>
384 </div>
385 {clients.length === 0 ? (
386 <p className="font-mono text-xs text-[var(--color-text-muted)]">
387 none yet
388 </p>
389 ) : (
390 <ul className="flex flex-col gap-2">
391 {clients.map((cl) => (
392 <li
393 key={cl.id}
394 className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 font-mono text-xs"
395 style={{ borderColor: 'var(--auth-accent-border)' }}
396 >
397 <span className="text-[var(--color-text)]">
398 {cl.name}
399 {cl.isPublic ? ' · public' : ' · confidential'}
400 {cl.revokedAt ? ' · revoked' : ''}
401 {cl.hint ? ` · secret ${cl.hint}` : ''}
402 <br />
403 <span className="text-[10px] text-[var(--color-text-muted)]">
404 {cl.clientId}
405 </span>
406 </span>
407 <span className="flex flex-wrap gap-2">
408 {!cl.revokedAt && !cl.isPublic ? (
409 <button
410 type="button"
411 disabled={pending}
412 onClick={() => void rotateSecret(cl.clientId)}
413 className="text-[var(--color-text-muted)] underline disabled:opacity-50"
414 >
415 regenerate secret
416 </button>
417 ) : null}
418 {!cl.revokedAt ? (
419 <button
420 type="button"
421 disabled={pending}
422 onClick={() => void revoke(cl.clientId)}
423 className="text-[var(--color-text-muted)] underline disabled:opacity-50"
424 >
425 revoke
426 </button>
427 ) : (
428 <button
429 type="button"
430 disabled={pending}
431 onClick={() => void hardDelete(cl.clientId)}
432 className="text-red-400/90 underline disabled:opacity-50"
433 >
434 delete leftover
435 </button>
436 )}
437 {!cl.revokedAt ? (
438 <button
439 type="button"
440 disabled={pending}
441 onClick={() => void hardDelete(cl.clientId)}
442 className="text-[10px] text-[var(--color-text-subtle)] underline disabled:opacity-50"
443 title="Permanently remove without soft-revoke first"
444 >
445 delete
446 </button>
447 ) : null}
448 </span>
449 </li>
450 ))}
451 </ul>
452 )}
453 </section>
454
455 {note ? (
456 <p className="font-mono text-xs text-[var(--color-text-muted)]">
457 {note}
458 </p>
459 ) : null}
460 {err ? (
461 <p className="font-mono text-xs text-red-400">{err}</p>
462 ) : null}
463 </div>
464 );
465}