idp-clients-client.tsx318 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 */
23export function AuthIdpClientsClient({
24 projects,
25 lockProjectId,
26}: {
27 projects: AuthV2ProjectRow[];
28 lockProjectId?: string;
29}) {
30 const [projectId, setProjectId] = useState(
31 lockProjectId ?? projects[0]?.id ?? '',
32 );
33 const [clients, setClients] = useState<ClientRow[]>([]);
34 const [issuer, setIssuer] = useState('');
35 const [discovery, setDiscovery] = useState('');
36 const [name, setName] = useState('');
37 const [logoUrl, setLogoUrl] = useState('');
38 const [redirectUris, setRedirectUris] = useState('https://localhost:3000/callback');
39 const [isPublic, setIsPublic] = useState(false);
40 const [created, setCreated] = useState<{
41 clientId: string;
42 clientSecret: string | null;
43 } | null>(null);
44 const [err, setErr] = useState<string | null>(null);
45 const [pending, setPending] = useState(false);
46
47 const load = useCallback(async (id: string) => {
48 if (!id) return;
49 setErr(null);
50 const res = await fetch(
51 `/api/v1/auth-core/projects/${encodeURIComponent(id)}/oidc/clients`,
52 { credentials: 'include', cache: 'no-store' },
53 );
54 if (res.status === 401) {
55 setErr('sign in to briven.tech to manage IdP clients');
56 return;
57 }
58 if (res.status === 403) {
59 setErr('you need admin access on this project');
60 return;
61 }
62 if (!res.ok) {
63 setErr(`load failed (${res.status})`);
64 return;
65 }
66 const body = (await res.json()) as {
67 clients?: ClientRow[];
68 issuer?: string;
69 discovery?: string;
70 };
71 setClients(body.clients ?? []);
72 setIssuer(body.issuer ?? '');
73 setDiscovery(body.discovery ?? '');
74 }, []);
75
76 useEffect(() => {
77 if (projectId) void load(projectId);
78 }, [projectId, load]);
79
80 async function create(): Promise<void> {
81 if (!projectId) return;
82 setPending(true);
83 setErr(null);
84 setCreated(null);
85 try {
86 const uris = redirectUris
87 .split(/[\n,]+/)
88 .map((u) => u.trim())
89 .filter(Boolean);
90 const res = await fetch(
91 `/api/v1/auth-core/projects/${encodeURIComponent(projectId)}/oidc/clients`,
92 {
93 method: 'POST',
94 credentials: 'include',
95 headers: { 'content-type': 'application/json' },
96 body: JSON.stringify({
97 name: name || 'My app',
98 redirectUris: uris,
99 logoUrl: logoUrl || undefined,
100 isPublic,
101 }),
102 },
103 );
104 const body = (await res.json().catch(() => ({}))) as {
105 client?: { clientId?: string; clientSecret?: string | null };
106 message?: string;
107 };
108 if (!res.ok) throw new Error(body.message ?? `http ${res.status}`);
109 if (!body.client?.clientId) throw new Error('no client id returned');
110 setCreated({
111 clientId: body.client.clientId,
112 clientSecret: body.client.clientSecret ?? null,
113 });
114 setName('');
115 await load(projectId);
116 } catch (e) {
117 setErr(e instanceof Error ? e.message : 'create failed');
118 } finally {
119 setPending(false);
120 }
121 }
122
123 async function revoke(clientId: string): Promise<void> {
124 if (!projectId) return;
125 setPending(true);
126 setErr(null);
127 try {
128 const res = await fetch(
129 `/api/v1/auth-core/projects/${encodeURIComponent(projectId)}/oidc/clients/${encodeURIComponent(clientId)}`,
130 { method: 'DELETE', credentials: 'include' },
131 );
132 if (!res.ok) {
133 const body = (await res.json().catch(() => ({}))) as { message?: string };
134 throw new Error(body.message ?? `http ${res.status}`);
135 }
136 await load(projectId);
137 } catch (e) {
138 setErr(e instanceof Error ? e.message : 'revoke failed');
139 } finally {
140 setPending(false);
141 }
142 }
143
144 if (projects.length === 0) {
145 return (
146 <p className="font-mono text-xs text-[var(--color-text-muted)]">
147 no projects yet
148 </p>
149 );
150 }
151
152 return (
153 <div className="flex max-w-2xl flex-col gap-6">
154 {!lockProjectId ? (
155 <label className="flex flex-col gap-1 font-mono text-xs">
156 <span className="text-[var(--color-text-muted)]">project</span>
157 <select
158 value={projectId}
159 onChange={(e) => setProjectId(e.target.value)}
160 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
161 style={{ borderColor: 'var(--auth-accent-border)' }}
162 >
163 {projects.map((p) => (
164 <option key={p.id} value={p.id}>
165 {p.name}
166 </option>
167 ))}
168 </select>
169 </label>
170 ) : null}
171
172 {issuer ? (
173 <div className="rounded-md border border-[var(--color-border-subtle)] p-3 font-mono text-[11px] text-[var(--color-text-muted)]">
174 <p>
175 issuer:{' '}
176 <code className="break-all text-[var(--color-text)]">{issuer}</code>
177 </p>
178 <p className="mt-1">
179 discovery:{' '}
180 <code className="break-all text-[var(--color-text)]">{discovery}</code>
181 </p>
182 <p className="mt-2">
183 outside apps use the standard OpenID Connect flow against these URLs.
184 </p>
185 </div>
186 ) : null}
187
188 <section className="flex flex-col gap-3">
189 <h3 className="font-mono text-sm text-[var(--color-text)]">
190 register an app
191 </h3>
192 <label className="flex flex-col gap-1 font-mono text-xs">
193 <span className="text-[var(--color-text-muted)]">app name</span>
194 <input
195 value={name}
196 onChange={(e) => setName(e.target.value)}
197 placeholder="Acme Dashboard"
198 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
199 style={{ borderColor: 'var(--auth-accent-border)' }}
200 />
201 </label>
202 <label className="flex flex-col gap-1 font-mono text-xs">
203 <span className="text-[var(--color-text-muted)]">
204 logo URL (https, optional)
205 </span>
206 <input
207 value={logoUrl}
208 onChange={(e) => setLogoUrl(e.target.value)}
209 placeholder="https://…"
210 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
211 style={{ borderColor: 'var(--auth-accent-border)' }}
212 />
213 </label>
214 <label className="flex flex-col gap-1 font-mono text-xs">
215 <span className="text-[var(--color-text-muted)]">
216 redirect URIs (one per line)
217 </span>
218 <textarea
219 value={redirectUris}
220 onChange={(e) => setRedirectUris(e.target.value)}
221 rows={3}
222 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
223 style={{ borderColor: 'var(--auth-accent-border)' }}
224 />
225 </label>
226 <label className="flex items-center gap-2 font-mono text-xs text-[var(--color-text)]">
227 <input
228 type="checkbox"
229 checked={isPublic}
230 onChange={(e) => setIsPublic(e.target.checked)}
231 />
232 public client (SPA / mobile — PKCE required, no secret)
233 </label>
234 <button
235 type="button"
236 disabled={pending}
237 onClick={() => void create()}
238 className="self-start rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
239 style={{ background: '#FFFD74' }}
240 >
241 {pending ? 'creating…' : 'create IdP client'}
242 </button>
243 </section>
244
245 {created ? (
246 <div
247 className="rounded-md border p-3 font-mono text-xs space-y-2"
248 style={{ borderColor: 'var(--auth-accent-border)' }}
249 >
250 <p className="text-[var(--color-text-muted)]">copy now:</p>
251 <p>
252 client_id:{' '}
253 <code className="break-all text-[var(--color-text)]">
254 {created.clientId}
255 </code>
256 </p>
257 {created.clientSecret ? (
258 <p>
259 client_secret:{' '}
260 <code className="break-all text-[var(--color-text)]">
261 {created.clientSecret}
262 </code>
263 </p>
264 ) : (
265 <p className="text-[var(--color-text-muted)]">
266 public client — use PKCE (S256), no secret
267 </p>
268 )}
269 </div>
270 ) : null}
271
272 <section className="flex flex-col gap-2">
273 <h3 className="font-mono text-sm text-[var(--color-text)]">
274 registered apps
275 </h3>
276 {clients.length === 0 ? (
277 <p className="font-mono text-xs text-[var(--color-text-muted)]">
278 none yet
279 </p>
280 ) : (
281 <ul className="flex flex-col gap-2">
282 {clients.map((cl) => (
283 <li
284 key={cl.id}
285 className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 font-mono text-xs"
286 style={{ borderColor: 'var(--auth-accent-border)' }}
287 >
288 <span className="text-[var(--color-text)]">
289 {cl.name}
290 {cl.isPublic ? ' · public' : ' · confidential'}
291 {cl.revokedAt ? ' · revoked' : ''}
292 <br />
293 <span className="text-[10px] text-[var(--color-text-muted)]">
294 {cl.clientId}
295 </span>
296 </span>
297 {!cl.revokedAt ? (
298 <button
299 type="button"
300 disabled={pending}
301 onClick={() => void revoke(cl.clientId)}
302 className="text-[var(--color-text-muted)] underline disabled:opacity-50"
303 >
304 revoke
305 </button>
306 ) : null}
307 </li>
308 ))}
309 </ul>
310 )}
311 </section>
312
313 {err ? (
314 <p className="font-mono text-xs text-red-400">{err}</p>
315 ) : null}
316 </div>
317 );
318}