keys-client.tsx465 lines · main
1'use client';
2
3import { useCallback, useEffect, useState } from 'react';
4
5import type { AuthV2ProjectRow } from '../lib/auth-v2-types';
6
7interface KeyRow {
8 id: string;
9 name: string;
10 hint: string;
11 scope: string;
12 createdAt?: string;
13 revokedAt: string | null;
14}
15
16interface M2mClientRow {
17 id: string;
18 clientId: string;
19 name: string;
20 role: string;
21 hint: string;
22 revokedAt: string | null;
23 lastUsedAt: string | null;
24 createdAt?: string;
25}
26
27/**
28 * Mint / list briven-engine SDK keys + M2M machine clients.
29 */
30export function AuthKeysClient({
31 projects,
32 lockProjectId,
33}: {
34 projects: AuthV2ProjectRow[];
35 /** When set, hide project picker (per-project Auth page). */
36 lockProjectId?: string;
37}) {
38 const [projectId, setProjectId] = useState(
39 lockProjectId ?? projects[0]?.id ?? '',
40 );
41 const [items, setItems] = useState<KeyRow[]>([]);
42 const [name, setName] = useState('browser');
43 const [scope, setScope] = useState<'read' | 'read-write' | 'admin'>('read-write');
44 const [plaintext, setPlaintext] = useState<string | null>(null);
45 const [err, setErr] = useState<string | null>(null);
46 const [pending, setPending] = useState(false);
47
48 // M2M machine clients
49 const [m2mItems, setM2mItems] = useState<M2mClientRow[]>([]);
50 const [m2mName, setM2mName] = useState('cron-job');
51 const [m2mRole, setM2mRole] = useState<'viewer' | 'developer' | 'admin'>(
52 'developer',
53 );
54 const [m2mCreated, setM2mCreated] = useState<{
55 clientId: string;
56 clientSecret: string;
57 tokenUrl: string;
58 } | null>(null);
59 const [m2mErr, setM2mErr] = useState<string | null>(null);
60 const [m2mPending, setM2mPending] = useState(false);
61
62 const load = useCallback(async (id: string) => {
63 if (!id) return;
64 setErr(null);
65 const res = await fetch(`/api/v1/auth-core/projects/${id}/keys`, {
66 credentials: 'include',
67 });
68 if (res.status === 401) {
69 setErr('sign in to briven.tech to manage keys');
70 return;
71 }
72 if (res.status === 403) {
73 setErr('you need admin access on this project');
74 return;
75 }
76 if (!res.ok) {
77 setErr(`load failed (${res.status})`);
78 return;
79 }
80 const body = (await res.json()) as { keys?: KeyRow[] };
81 setItems(body.keys ?? []);
82 }, []);
83
84 const loadM2m = useCallback(async (id: string) => {
85 if (!id) return;
86 setM2mErr(null);
87 const res = await fetch(`/api/v1/auth-core/projects/${id}/m2m/clients`, {
88 credentials: 'include',
89 });
90 if (res.status === 401) {
91 setM2mErr('sign in to manage machine clients');
92 return;
93 }
94 if (res.status === 403) {
95 setM2mErr('you need admin access on this project');
96 return;
97 }
98 if (!res.ok) {
99 setM2mErr(`machine clients load failed (${res.status})`);
100 return;
101 }
102 const body = (await res.json()) as { clients?: M2mClientRow[] };
103 setM2mItems(body.clients ?? []);
104 }, []);
105
106 useEffect(() => {
107 if (projectId) {
108 void load(projectId);
109 void loadM2m(projectId);
110 }
111 }, [projectId, load, loadM2m]);
112
113 async function mint(): Promise<void> {
114 if (!projectId) return;
115 setPending(true);
116 setErr(null);
117 setPlaintext(null);
118 try {
119 const res = await fetch(`/api/v1/auth-core/projects/${projectId}/keys`, {
120 method: 'POST',
121 credentials: 'include',
122 headers: { 'content-type': 'application/json' },
123 body: JSON.stringify({ name: name || 'browser', scope }),
124 });
125 const body = (await res.json().catch(() => ({}))) as {
126 key?: { plaintext?: string };
127 message?: string;
128 code?: string;
129 };
130 if (!res.ok) {
131 throw new Error(body.message ?? body.code ?? `http ${res.status}`);
132 }
133 const pk = body.key?.plaintext;
134 if (!pk) throw new Error('created but no key string returned');
135 setPlaintext(pk);
136 await load(projectId);
137 } catch (e) {
138 setErr(e instanceof Error ? e.message : 'mint failed');
139 } finally {
140 setPending(false);
141 }
142 }
143
144 async function revoke(keyId: string): Promise<void> {
145 if (!projectId) return;
146 setPending(true);
147 setErr(null);
148 try {
149 const res = await fetch(
150 `/api/v1/auth-core/projects/${projectId}/keys/${keyId}`,
151 { method: 'DELETE', credentials: 'include' },
152 );
153 if (!res.ok) {
154 const body = (await res.json().catch(() => ({}))) as { message?: string };
155 throw new Error(body.message ?? `http ${res.status}`);
156 }
157 await load(projectId);
158 } catch (e) {
159 setErr(e instanceof Error ? e.message : 'revoke failed');
160 } finally {
161 setPending(false);
162 }
163 }
164
165 async function createM2m(): Promise<void> {
166 if (!projectId) return;
167 setM2mPending(true);
168 setM2mErr(null);
169 setM2mCreated(null);
170 try {
171 const res = await fetch(
172 `/api/v1/auth-core/projects/${projectId}/m2m/clients`,
173 {
174 method: 'POST',
175 credentials: 'include',
176 headers: { 'content-type': 'application/json' },
177 body: JSON.stringify({ name: m2mName || 'cron-job', role: m2mRole }),
178 },
179 );
180 const body = (await res.json().catch(() => ({}))) as {
181 client?: { clientId?: string; clientSecret?: string };
182 tokenUrl?: string;
183 message?: string;
184 code?: string;
185 };
186 if (!res.ok) {
187 throw new Error(body.message ?? body.code ?? `http ${res.status}`);
188 }
189 if (!body.client?.clientId || !body.client?.clientSecret) {
190 throw new Error('created but no secret returned');
191 }
192 setM2mCreated({
193 clientId: body.client.clientId,
194 clientSecret: body.client.clientSecret,
195 tokenUrl:
196 body.tokenUrl ??
197 'https://api.briven.tech/v1/auth-core/oauth/token',
198 });
199 await loadM2m(projectId);
200 } catch (e) {
201 setM2mErr(e instanceof Error ? e.message : 'create failed');
202 } finally {
203 setM2mPending(false);
204 }
205 }
206
207 async function revokeM2m(clientId: string): Promise<void> {
208 if (!projectId) return;
209 setM2mPending(true);
210 setM2mErr(null);
211 try {
212 const res = await fetch(
213 `/api/v1/auth-core/projects/${projectId}/m2m/clients/${encodeURIComponent(clientId)}`,
214 { method: 'DELETE', credentials: 'include' },
215 );
216 if (!res.ok) {
217 const body = (await res.json().catch(() => ({}))) as { message?: string };
218 throw new Error(body.message ?? `http ${res.status}`);
219 }
220 await loadM2m(projectId);
221 } catch (e) {
222 setM2mErr(e instanceof Error ? e.message : 'revoke failed');
223 } finally {
224 setM2mPending(false);
225 }
226 }
227
228 if (projects.length === 0) {
229 return (
230 <div className="rounded-md border border-dashed border-[var(--color-border)] p-8 font-mono text-sm text-[var(--color-text-muted)]">
231 no projects yet. create a project first, then come back for keys.
232 </div>
233 );
234 }
235
236 return (
237 <div className="flex max-w-2xl flex-col gap-10">
238 {!lockProjectId ? (
239 <label className="flex flex-col gap-1 font-mono text-xs">
240 <span className="text-[var(--color-text-muted)]">project</span>
241 <select
242 value={projectId}
243 onChange={(e) => setProjectId(e.target.value)}
244 className="rounded-md border bg-[var(--color-surface)] px-3 py-2 text-[var(--color-text)]"
245 style={{ borderColor: 'var(--auth-accent-border)' }}
246 >
247 {projects.map((p) => (
248 <option key={p.id} value={p.id}>
249 {p.name}
250 {p.authEnabled ? '' : ' (Auth off)'}
251 </option>
252 ))}
253 </select>
254 </label>
255 ) : null}
256
257 {/* ─── App / browser SDK keys ─── */}
258 <section className="flex flex-col gap-4">
259 <div>
260 <h3 className="font-mono text-sm text-[var(--color-text)]">
261 app keys
262 </h3>
263 <p className="mt-1 font-mono text-[11px] text-[var(--color-text-muted)]">
264 long-lived keys for your app SDK (shown once when created).
265 </p>
266 </div>
267
268 <div className="flex flex-wrap items-end gap-2">
269 <label className="flex flex-col gap-1 font-mono text-xs">
270 <span className="text-[var(--color-text-muted)]">key name</span>
271 <input
272 value={name}
273 onChange={(e) => setName(e.target.value)}
274 className="rounded-md border bg-[var(--color-surface)] px-3 py-2 text-[var(--color-text)]"
275 style={{ borderColor: 'var(--auth-accent-border)' }}
276 />
277 </label>
278 <label className="flex flex-col gap-1 font-mono text-xs">
279 <span className="text-[var(--color-text-muted)]">scope</span>
280 <select
281 value={scope}
282 onChange={(e) =>
283 setScope(e.target.value as 'read' | 'read-write' | 'admin')
284 }
285 className="rounded-md border bg-[var(--color-surface)] px-3 py-2 text-[var(--color-text)]"
286 style={{ borderColor: 'var(--auth-accent-border)' }}
287 >
288 <option value="read">read</option>
289 <option value="read-write">read-write</option>
290 <option value="admin">admin</option>
291 </select>
292 </label>
293 <button
294 type="button"
295 disabled={pending}
296 onClick={() => void mint()}
297 className="rounded-md px-3 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
298 style={{ background: '#FFFD74' }}
299 >
300 {pending ? 'minting…' : 'mint key'}
301 </button>
302 </div>
303
304 {plaintext ? (
305 <div
306 className="rounded-md border p-3 font-mono text-xs"
307 style={{ borderColor: 'var(--auth-accent-border)' }}
308 >
309 <p className="text-[var(--color-text-muted)]">copy now — shown once:</p>
310 <code className="mt-1 block break-all text-[var(--color-text)]">
311 {plaintext}
312 </code>
313 </div>
314 ) : null}
315
316 {items.length === 0 ? (
317 <p className="font-mono text-xs text-[var(--color-text-muted)]">
318 no keys for this project yet
319 </p>
320 ) : (
321 <ul className="flex flex-col gap-2">
322 {items.map((k) => (
323 <li
324 key={k.id}
325 className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 font-mono text-xs"
326 style={{ borderColor: 'var(--auth-accent-border)' }}
327 >
328 <span className="text-[var(--color-text)]">
329 {k.name} · {k.hint} · {k.scope}
330 {k.revokedAt ? ' · revoked' : ''}
331 </span>
332 {!k.revokedAt ? (
333 <button
334 type="button"
335 disabled={pending}
336 onClick={() => void revoke(k.id)}
337 className="text-[var(--color-text-muted)] underline disabled:opacity-50"
338 >
339 revoke
340 </button>
341 ) : null}
342 </li>
343 ))}
344 </ul>
345 )}
346 {err ? (
347 <p className="font-mono text-xs text-red-400">{err}</p>
348 ) : null}
349 </section>
350
351 {/* ─── M2M machine clients ─── */}
352 <section className="flex flex-col gap-4">
353 <div>
354 <h3 className="font-mono text-sm text-[var(--color-text)]">
355 machine clients (M2M)
356 </h3>
357 <p className="mt-1 font-mono text-[11px] leading-relaxed text-[var(--color-text-muted)]">
358 for robots and servers — not people. create a client id + secret,
359 then exchange them for a short-lived token (about 1 hour) to call
360 this project&apos;s APIs.
361 </p>
362 </div>
363
364 <div className="flex flex-wrap items-end gap-2">
365 <label className="flex flex-col gap-1 font-mono text-xs">
366 <span className="text-[var(--color-text-muted)]">name</span>
367 <input
368 value={m2mName}
369 onChange={(e) => setM2mName(e.target.value)}
370 className="rounded-md border bg-[var(--color-surface)] px-3 py-2 text-[var(--color-text)]"
371 style={{ borderColor: 'var(--auth-accent-border)' }}
372 placeholder="cron-job"
373 />
374 </label>
375 <label className="flex flex-col gap-1 font-mono text-xs">
376 <span className="text-[var(--color-text-muted)]">power level</span>
377 <select
378 value={m2mRole}
379 onChange={(e) =>
380 setM2mRole(e.target.value as 'viewer' | 'developer' | 'admin')
381 }
382 className="rounded-md border bg-[var(--color-surface)] px-3 py-2 text-[var(--color-text)]"
383 style={{ borderColor: 'var(--auth-accent-border)' }}
384 >
385 <option value="viewer">viewer (read only)</option>
386 <option value="developer">developer (read + write)</option>
387 <option value="admin">admin</option>
388 </select>
389 </label>
390 <button
391 type="button"
392 disabled={m2mPending}
393 onClick={() => void createM2m()}
394 className="rounded-md px-3 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
395 style={{ background: '#FFFD74' }}
396 >
397 {m2mPending ? 'creating…' : 'create machine client'}
398 </button>
399 </div>
400
401 {m2mCreated ? (
402 <div
403 className="rounded-md border p-3 font-mono text-xs space-y-2"
404 style={{ borderColor: 'var(--auth-accent-border)' }}
405 >
406 <p className="text-[var(--color-text-muted)]">
407 copy both now — secret shown once:
408 </p>
409 <p className="text-[var(--color-text)]">
410 <span className="text-[var(--color-text-muted)]">client_id: </span>
411 <code className="break-all">{m2mCreated.clientId}</code>
412 </p>
413 <p className="text-[var(--color-text)]">
414 <span className="text-[var(--color-text-muted)]">client_secret: </span>
415 <code className="break-all">{m2mCreated.clientSecret}</code>
416 </p>
417 <p className="text-[var(--color-text-muted)]">
418 get a token:{' '}
419 <code className="text-[var(--color-text)] break-all">
420 POST {m2mCreated.tokenUrl}
421 </code>
422 {' · '}
423 grant_type=client_credentials
424 </p>
425 </div>
426 ) : null}
427
428 {m2mItems.length === 0 ? (
429 <p className="font-mono text-xs text-[var(--color-text-muted)]">
430 no machine clients yet
431 </p>
432 ) : (
433 <ul className="flex flex-col gap-2">
434 {m2mItems.map((cl) => (
435 <li
436 key={cl.id}
437 className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 font-mono text-xs"
438 style={{ borderColor: 'var(--auth-accent-border)' }}
439 >
440 <span className="text-[var(--color-text)]">
441 {cl.name} · {cl.clientId.slice(0, 16)}… · {cl.role}
442 {cl.hint ? ` · secret ${cl.hint}` : ''}
443 {cl.revokedAt ? ' · revoked' : ''}
444 </span>
445 {!cl.revokedAt ? (
446 <button
447 type="button"
448 disabled={m2mPending}
449 onClick={() => void revokeM2m(cl.clientId)}
450 className="text-[var(--color-text-muted)] underline disabled:opacity-50"
451 >
452 revoke
453 </button>
454 ) : null}
455 </li>
456 ))}
457 </ul>
458 )}
459 {m2mErr ? (
460 <p className="font-mono text-xs text-red-400">{m2mErr}</p>
461 ) : null}
462 </section>
463 </div>
464 );
465}