enterprise-client.tsx525 lines · main
1'use client';
2
3import { useCallback, useEffect, useState } from 'react';
4import { useRouter } from 'next/navigation';
5
6import type { AuthV2ProjectRow } from '../lib/auth-v2-types';
7
8type TenantRow = {
9 tenantId: string;
10 projectId: string;
11 createdAt: string | null;
12 authEnabled: true;
13};
14
15type SsoConn = {
16 id: string;
17 name: string;
18 providerType: 'saml' | 'oidc';
19 domains: string[];
20 productionReady: boolean;
21 ready: boolean;
22 configKeys: string[];
23};
24
25/**
26 * Enterprise: real Auth-enabled status from Doltgres + SAML/OIDC SSO setup.
27 */
28export function AuthEnterpriseClient({
29 projects: initial,
30 lockProjectId,
31}: {
32 projects: AuthV2ProjectRow[];
33 /** When set, hide multi-project enable table (per-project Auth page). */
34 lockProjectId?: string;
35}) {
36 const router = useRouter();
37 const [projects, setProjects] = useState(initial);
38 const [tenants, setTenants] = useState<TenantRow[]>([]);
39 const [projectId, setProjectId] = useState(
40 lockProjectId ?? initial[0]?.id ?? '',
41 );
42 const [connections, setConnections] = useState<SsoConn[]>([]);
43 const [err, setErr] = useState<string | null>(null);
44 const [note, setNote] = useState<string | null>(null);
45 const [pendingId, setPendingId] = useState<string | null>(null);
46
47 // create form
48 const [name, setName] = useState('');
49 const [providerType, setProviderType] = useState<'saml' | 'oidc'>('oidc');
50 const [domains, setDomains] = useState('');
51 // SAML
52 const [idpSsoUrl, setIdpSsoUrl] = useState('');
53 const [idpCert, setIdpCert] = useState('');
54 // OIDC
55 const [issuer, setIssuer] = useState('');
56 const [clientId, setClientId] = useState('');
57 const [clientSecret, setClientSecret] = useState('');
58 const [authUrl, setAuthUrl] = useState('');
59 const [tokenUrl, setTokenUrl] = useState('');
60
61 const loadTenants = useCallback(async () => {
62 const res = await fetch('/api/v1/auth-core/tenants', {
63 credentials: 'include',
64 });
65 if (res.status === 401) {
66 setErr('sign in to briven.tech to see tenants');
67 return;
68 }
69 if (!res.ok) {
70 setErr(`tenants load failed (${res.status})`);
71 return;
72 }
73 const body = (await res.json()) as {
74 tenants?: TenantRow[];
75 tenantIds?: string[];
76 };
77 if (body.tenants?.length) {
78 setTenants(body.tenants);
79 } else {
80 setTenants(
81 (body.tenantIds ?? []).map((tenantId) => ({
82 tenantId,
83 projectId: tenantId,
84 createdAt: null,
85 authEnabled: true as const,
86 })),
87 );
88 }
89 }, []);
90
91 const loadConnections = useCallback(async (id: string) => {
92 if (!id) return;
93 const res = await fetch(
94 `/api/v1/auth-core/projects/${id}/sso/connections`,
95 { credentials: 'include' },
96 );
97 if (!res.ok) {
98 if (res.status !== 401 && res.status !== 403) {
99 setErr(`sso load failed (${res.status})`);
100 }
101 setConnections([]);
102 return;
103 }
104 const body = (await res.json()) as { connections?: SsoConn[] };
105 setConnections(body.connections ?? []);
106 }, []);
107
108 useEffect(() => {
109 void loadTenants();
110 }, [loadTenants]);
111
112 useEffect(() => {
113 if (projectId) void loadConnections(projectId);
114 }, [projectId, loadConnections]);
115
116 async function enableAuth(id: string): Promise<void> {
117 setPendingId(id);
118 setErr(null);
119 setNote(null);
120 try {
121 const res = await fetch(`/api/v1/auth-core/projects/${id}/enable`, {
122 method: 'POST',
123 credentials: 'include',
124 });
125 const body = (await res.json().catch(() => ({}))) as {
126 ok?: boolean;
127 message?: string;
128 tenantId?: string;
129 created?: boolean;
130 };
131 if (!res.ok || body.ok === false) {
132 throw new Error(body.message ?? `http ${res.status}`);
133 }
134 setNote(
135 body.created
136 ? `Auth enabled — tenant ${body.tenantId ?? ''}`
137 : `Auth already on — tenant ${body.tenantId ?? ''}`,
138 );
139 setProjects((prev) =>
140 prev.map((p) => (p.id === id ? { ...p, authEnabled: true } : p)),
141 );
142 await loadTenants();
143 router.refresh();
144 } catch (e) {
145 setErr(e instanceof Error ? e.message : 'enable failed');
146 } finally {
147 setPendingId(null);
148 }
149 }
150
151 async function createSso(): Promise<void> {
152 if (!projectId || !name.trim()) return;
153 setPendingId('sso');
154 setErr(null);
155 setNote(null);
156 try {
157 const domainList = domains
158 .split(/[,\s]+/)
159 .map((d) => d.trim().toLowerCase())
160 .filter(Boolean);
161 const config: Record<string, string> =
162 providerType === 'saml'
163 ? {
164 idpSsoUrl: idpSsoUrl.trim(),
165 idpCert: idpCert.trim(),
166 }
167 : {
168 issuer: issuer.trim(),
169 clientId: clientId.trim(),
170 clientSecret: clientSecret.trim(),
171 ...(authUrl.trim() ? { authorizationUrl: authUrl.trim() } : {}),
172 ...(tokenUrl.trim() ? { tokenUrl: tokenUrl.trim() } : {}),
173 };
174 const res = await fetch(
175 `/api/v1/auth-core/projects/${projectId}/sso/connections`,
176 {
177 method: 'POST',
178 credentials: 'include',
179 headers: { 'content-type': 'application/json' },
180 body: JSON.stringify({
181 name: name.trim(),
182 providerType,
183 domains: domainList,
184 config,
185 jitEnabled: true,
186 }),
187 },
188 );
189 const body = (await res.json().catch(() => ({}))) as {
190 message?: string;
191 connection?: SsoConn;
192 };
193 if (!res.ok) throw new Error(body.message ?? `http ${res.status}`);
194 setName('');
195 setDomains('');
196 setIdpSsoUrl('');
197 setIdpCert('');
198 setIssuer('');
199 setClientId('');
200 setClientSecret('');
201 setAuthUrl('');
202 setTokenUrl('');
203 const ready = body.connection?.productionReady;
204 setNote(
205 ready
206 ? 'SSO connection production-ready — login paths are live'
207 : 'SSO connection saved — add full IdP fields so productionReady turns on',
208 );
209 await loadConnections(projectId);
210 } catch (e) {
211 setErr(e instanceof Error ? e.message : 'create failed');
212 } finally {
213 setPendingId(null);
214 }
215 }
216
217 async function removeSso(connectionId: string): Promise<void> {
218 if (!projectId) return;
219 setPendingId(connectionId);
220 try {
221 const res = await fetch(
222 `/api/v1/auth-core/projects/${projectId}/sso/connections/${connectionId}`,
223 { method: 'DELETE', credentials: 'include' },
224 );
225 if (!res.ok) throw new Error(`http ${res.status}`);
226 await loadConnections(projectId);
227 } catch (e) {
228 setErr(e instanceof Error ? e.message : 'delete failed');
229 } finally {
230 setPendingId(null);
231 }
232 }
233
234 const enabledIds = new Set(tenants.map((t) => t.projectId));
235 // workspace flag + live tenant row both count
236 const rows = projects.map((p) => ({
237 ...p,
238 reallyEnabled: p.authEnabled || enabledIds.has(p.id),
239 tenantFromDb: tenants.find((t) => t.projectId === p.id),
240 }));
241
242 return (
243 <div className="space-y-8">
244 {!lockProjectId ? (
245 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
246 <h2 className="font-mono text-sm text-[var(--color-text)]">
247 Auth enabled — live check
248 </h2>
249 <p className="mt-1 font-mono text-[11px] text-[var(--color-text-muted)]">
250 A project is Auth-enabled when it has a tenant row in Doltgres
251 (be_tenants). That is what app login uses.
252 </p>
253
254 {rows.length === 0 ? (
255 <p className="mt-4 font-mono text-xs text-[var(--color-text-muted)]">
256 no projects yet
257 </p>
258 ) : (
259 <div className="mt-4 overflow-x-auto">
260 <table className="w-full min-w-[520px] text-left font-mono text-xs">
261 <thead className="border-b border-[var(--color-border-subtle)] text-[var(--color-text-muted)]">
262 <tr>
263 <th className="px-2 py-2 font-normal">project</th>
264 <th className="px-2 py-2 font-normal">Auth</th>
265 <th className="px-2 py-2 font-normal">tenant</th>
266 <th className="px-2 py-2 font-normal" />
267 </tr>
268 </thead>
269 <tbody>
270 {rows.map((p) => (
271 <tr
272 key={p.id}
273 className="border-b border-[var(--color-border-subtle)] last:border-0"
274 >
275 <td className="px-2 py-2 text-[var(--color-text)]">
276 {p.name}
277 <span className="ml-2 text-[var(--color-text-muted)]">
278 {p.slug}
279 </span>
280 </td>
281 <td className="px-2 py-2">
282 {p.reallyEnabled ? (
283 <span style={{ color: '#FFFD74' }}>enabled</span>
284 ) : (
285 <span className="text-[var(--color-text-muted)]">
286 off
287 </span>
288 )}
289 </td>
290 <td className="max-w-[12rem] truncate px-2 py-2 text-[var(--color-text-muted)]">
291 {p.tenantFromDb?.tenantId ?? '—'}
292 </td>
293 <td className="px-2 py-2 text-right">
294 {!p.reallyEnabled ? (
295 <button
296 type="button"
297 disabled={pendingId === p.id}
298 onClick={() => void enableAuth(p.id)}
299 className="rounded-md px-2 py-1 font-mono text-[11px] font-medium text-black disabled:opacity-50"
300 style={{ background: '#FFFD74' }}
301 >
302 {pendingId === p.id ? '…' : 'enable'}
303 </button>
304 ) : (
305 <span className="text-[var(--color-text-muted)]">
306 ok
307 </span>
308 )}
309 </td>
310 </tr>
311 ))}
312 </tbody>
313 </table>
314 </div>
315 )}
316
317 {note ? (
318 <p className="mt-3 font-mono text-xs text-[var(--color-text-muted)]">
319 {note}
320 </p>
321 ) : null}
322 {err ? (
323 <p className="mt-3 font-mono text-xs text-red-400">{err}</p>
324 ) : null}
325 </div>
326 ) : null}
327
328 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
329 <h2 className="font-mono text-sm text-[var(--color-text)]">
330 Company SSO (SAML + OIDC)
331 </h2>
332 <p className="mt-1 max-w-2xl font-mono text-[11px] leading-relaxed text-[var(--color-text-muted)]">
333 Let staff sign into this app with their company login (Okta, Azure AD,
334 Google Workspace, etc.) instead of a separate password. Add a
335 connection when a company asks for single sign-on.
336 </p>
337
338 {!lockProjectId ? (
339 <label className="mt-4 flex max-w-md flex-col gap-1 font-mono text-xs">
340 <span className="text-[var(--color-text-muted)]">project</span>
341 <select
342 value={projectId}
343 onChange={(e) => setProjectId(e.target.value)}
344 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)]"
345 style={{ borderColor: 'var(--auth-accent-border)' }}
346 >
347 {projects.map((p) => (
348 <option key={p.id} value={p.id}>
349 {p.name}
350 </option>
351 ))}
352 </select>
353 </label>
354 ) : null}
355 {lockProjectId && note ? (
356 <p className="mt-3 font-mono text-xs text-[var(--color-text-muted)]">
357 {note}
358 </p>
359 ) : null}
360 {lockProjectId && err ? (
361 <p className="mt-3 font-mono text-xs text-red-400">{err}</p>
362 ) : null}
363
364 <div className="mt-4 space-y-2">
365 {connections.length === 0 ? (
366 <p className="font-mono text-xs text-[var(--color-text-muted)]">
367 no SSO connections yet for this project
368 </p>
369 ) : (
370 connections.map((c) => (
371 <div
372 key={c.id}
373 className="flex flex-wrap items-center justify-between gap-2 rounded border border-[var(--color-border-subtle)] px-3 py-2 font-mono text-xs"
374 >
375 <div>
376 <span className="text-[var(--color-text)]">{c.name}</span>
377 <span className="ml-2 text-[var(--color-text-muted)]">
378 {c.providerType}
379 </span>
380 {c.productionReady ? (
381 <span className="ml-2" style={{ color: '#FFFD74' }}>
382 production ready
383 </span>
384 ) : (
385 <span className="ml-2 text-[var(--color-text-muted)]">
386 incomplete config
387 </span>
388 )}
389 {c.domains?.length ? (
390 <span className="ml-2 text-[var(--color-text-muted)]">
391 · {c.domains.join(', ')}
392 </span>
393 ) : null}
394 {c.productionReady ? (
395 <p className="mt-1 text-[10px] text-[var(--color-text-muted)]">
396 start:{' '}
397 {c.providerType === 'saml'
398 ? `/v1/auth-core/sso/saml/${c.id}`
399 : `/v1/auth-core/sso/oidc/${c.id}`}
400 </p>
401 ) : null}
402 </div>
403 <button
404 type="button"
405 disabled={pendingId === c.id}
406 onClick={() => void removeSso(c.id)}
407 className="text-[var(--color-text-muted)] underline"
408 >
409 remove
410 </button>
411 </div>
412 ))
413 )}
414 </div>
415
416 <div className="mt-6 space-y-3 border-t border-[var(--color-border-subtle)] pt-4">
417 <p className="font-mono text-xs text-[var(--color-text)]">
418 add connection
419 </p>
420 <div className="flex flex-wrap gap-2">
421 <input
422 value={name}
423 onChange={(e) => setName(e.target.value)}
424 placeholder="name (e.g. Okta)"
425 className="min-w-[8rem] flex-1 rounded-md border bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
426 style={{ borderColor: 'var(--auth-accent-border)' }}
427 />
428 <select
429 value={providerType}
430 onChange={(e) =>
431 setProviderType(e.target.value as 'saml' | 'oidc')
432 }
433 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
434 style={{ borderColor: 'var(--auth-accent-border)' }}
435 >
436 <option value="oidc">OIDC</option>
437 <option value="saml">SAML 2.0</option>
438 </select>
439 <input
440 value={domains}
441 onChange={(e) => setDomains(e.target.value)}
442 placeholder="email domains (acme.com)"
443 className="min-w-[10rem] flex-1 rounded-md border bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
444 style={{ borderColor: 'var(--auth-accent-border)' }}
445 />
446 </div>
447
448 {providerType === 'saml' ? (
449 <div className="space-y-2">
450 <input
451 value={idpSsoUrl}
452 onChange={(e) => setIdpSsoUrl(e.target.value)}
453 placeholder="IdP SSO URL"
454 className="w-full rounded-md border bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
455 style={{ borderColor: 'var(--auth-accent-border)' }}
456 />
457 <textarea
458 value={idpCert}
459 onChange={(e) => setIdpCert(e.target.value)}
460 placeholder="IdP signing certificate (PEM)"
461 rows={3}
462 className="w-full rounded-md border bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
463 style={{ borderColor: 'var(--auth-accent-border)' }}
464 />
465 </div>
466 ) : (
467 <div className="space-y-2">
468 <input
469 value={issuer}
470 onChange={(e) => setIssuer(e.target.value)}
471 placeholder="Issuer URL (OpenID discovery)"
472 className="w-full rounded-md border bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
473 style={{ borderColor: 'var(--auth-accent-border)' }}
474 />
475 <div className="flex flex-wrap gap-2">
476 <input
477 value={clientId}
478 onChange={(e) => setClientId(e.target.value)}
479 placeholder="client id"
480 className="min-w-[8rem] flex-1 rounded-md border bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
481 style={{ borderColor: 'var(--auth-accent-border)' }}
482 />
483 <input
484 type="password"
485 value={clientSecret}
486 onChange={(e) => setClientSecret(e.target.value)}
487 placeholder="client secret"
488 className="min-w-[8rem] flex-1 rounded-md border bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
489 style={{ borderColor: 'var(--auth-accent-border)' }}
490 />
491 </div>
492 <p className="font-mono text-[10px] text-[var(--color-text-muted)]">
493 optional if discovery works — override authorize / token:
494 </p>
495 <input
496 value={authUrl}
497 onChange={(e) => setAuthUrl(e.target.value)}
498 placeholder="authorization URL (optional)"
499 className="w-full rounded-md border bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
500 style={{ borderColor: 'var(--auth-accent-border)' }}
501 />
502 <input
503 value={tokenUrl}
504 onChange={(e) => setTokenUrl(e.target.value)}
505 placeholder="token URL (optional)"
506 className="w-full rounded-md border bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
507 style={{ borderColor: 'var(--auth-accent-border)' }}
508 />
509 </div>
510 )}
511
512 <button
513 type="button"
514 disabled={pendingId === 'sso' || !name.trim()}
515 onClick={() => void createSso()}
516 className="rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
517 style={{ background: '#FFFD74' }}
518 >
519 {pendingId === 'sso' ? 'saving…' : 'save SSO connection'}
520 </button>
521 </div>
522 </div>
523 </div>
524 );
525}