page.tsx255 lines · main
1import Link from 'next/link';
2
3import { apiFetch } from '@/lib/api';
4import { AuthAuditTrailClient } from '../../security/audit-trail-client';
5import { AuthRolesForm } from '../../security/roles-form';
6import { AdvancedAuthSettings } from './advanced-auth-settings';
7
8export const metadata = { title: 'Auth · security' };
9export const dynamic = 'force-dynamic';
10
11type RoleRow = { name: string; permissions: string[]; tenantId?: string };
12
13type MethodFlags = {
14 emailPassword: boolean;
15 passwordlessEmail: boolean;
16 magicLink: boolean;
17 passwordlessSms: boolean;
18 passkeys: boolean;
19 mfa: boolean;
20};
21
22const CORE_METHOD_ORDER: Array<{ key: keyof MethodFlags; label: string }> = [
23 { key: 'emailPassword', label: 'email + password' },
24 { key: 'passwordlessEmail', label: 'passwordless-email' },
25 { key: 'magicLink', label: 'magic-link' },
26 { key: 'passwordlessSms', label: 'passwordless-sms' },
27 { key: 'passkeys', label: 'passkeys' },
28 { key: 'mfa', label: 'mfa (TOTP)' },
29];
30
31/**
32 * Security for one Auth project — roles + login methods mirroring Providers.
33 */
34export default async function AuthProjectSecurityPage({
35 params,
36}: {
37 params: Promise<{ projectId: string }>;
38}) {
39 const { projectId } = await params;
40 let roles: RoleRow[] = [];
41 let rolesErr: string | null = null;
42 let methods: MethodFlags | null = null;
43 let oauthOn: Array<{ id: string; label: string }> = [];
44 let configErr: string | null = null;
45
46 const [rolesRes, configRes] = await Promise.all([
47 apiFetch(
48 `/v1/auth-core/roles?projectId=${encodeURIComponent(projectId)}`,
49 ).catch(() => null),
50 apiFetch(
51 `/v1/auth-core/projects/${encodeURIComponent(projectId)}/config`,
52 ).catch(() => null),
53 ]);
54
55 if (rolesRes) {
56 if (rolesRes.status === 401) {
57 rolesErr = 'sign in to briven.tech to manage roles';
58 } else if (rolesRes.ok) {
59 const body = (await rolesRes.json()) as {
60 roles?: Array<{
61 name: string;
62 permissions: string[];
63 tenantId?: string;
64 }>;
65 };
66 roles = body.roles ?? [];
67 } else {
68 rolesErr = await rolesRes.text().catch(() => rolesRes.statusText);
69 }
70 }
71
72 let smsConfigured = false;
73
74 if (configRes?.ok) {
75 const body = (await configRes.json()) as {
76 methods?: MethodFlags;
77 providers?: Array<{
78 thirdPartyId: string;
79 name: string;
80 configured: boolean;
81 }>;
82 delivery?: { sms?: { configured?: boolean; provider?: string | null } };
83 };
84 methods = body.methods ?? null;
85 smsConfigured = Boolean(body.delivery?.sms?.configured);
86 // Only OAuth with saved secrets (same as Providers yellow · on)
87 oauthOn = (body.providers ?? [])
88 .filter((p) => p.configured)
89 .map((p) => ({ id: p.thirdPartyId, label: p.name }));
90 } else if (configRes && configRes.status === 401) {
91 configErr = 'sign in to load methods';
92 } else if (configRes && !configRes.ok) {
93 configErr = `could not load config (${configRes.status})`;
94 }
95
96 const coreChips = CORE_METHOD_ORDER.map((m) => {
97 const on = methods ? Boolean(methods[m.key]) : false;
98 // passwordless-sms: show secrets gap like OAuth "configured"
99 const smsGap =
100 m.key === 'passwordlessSms' && on && !smsConfigured
101 ? ' · needs Twilio'
102 : m.key === 'passwordlessSms' && on && smsConfigured
103 ? ' · Twilio ready'
104 : '';
105 return {
106 id: m.key,
107 label: m.label,
108 on,
109 extra: smsGap,
110 };
111 });
112 const smsReady = Boolean(methods?.passwordlessSms && smsConfigured);
113
114 return (
115 <section className="space-y-8">
116 <header>
117 <h2 className="font-mono text-lg tracking-tight text-[var(--color-text)]">
118 security
119 </h2>
120 <p className="mt-1 font-mono text-sm text-[var(--color-text-muted)]">
121 roles, login methods, and a live diary of security events
122 </p>
123 </header>
124
125 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
126 <AuthAuditTrailClient projectId={projectId} />
127 </div>
128
129 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
130 <AdvancedAuthSettings projectId={projectId} />
131 </div>
132
133 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
134 <h3 className="font-mono text-sm text-[var(--color-text)]">
135 login methods
136 </h3>
137 <p className="mt-1 font-mono text-[11px] text-[var(--color-text-muted)]">
138 same as Providers · yellow = on · change under Providers
139 </p>
140 {configErr ? (
141 <p className="mt-3 font-mono text-xs text-[var(--color-text-muted)]">
142 {configErr}
143 </p>
144 ) : (
145 <>
146 <p className="mt-3 font-mono text-[10px] uppercase tracking-widest text-[var(--color-text-muted)]">
147 sign-in methods
148 </p>
149 <ul className="mt-2 flex flex-wrap gap-2">
150 {coreChips.map((c) => (
151 <li
152 key={c.id}
153 className="rounded border px-2 py-1 font-mono text-[11px]"
154 style={
155 c.on
156 ? {
157 borderColor: 'var(--auth-accent-border, #FFFD74)',
158 background: 'var(--auth-accent-soft)',
159 color: 'var(--color-text)',
160 }
161 : {
162 borderColor: 'var(--color-border-subtle)',
163 color: 'var(--color-text-muted)',
164 opacity: 0.55,
165 }
166 }
167 >
168 {c.label}
169 {c.on ? '' : ' · off'}
170 {c.extra}
171 </li>
172 ))}
173 </ul>
174
175 <p className="mt-4 font-mono text-[10px] uppercase tracking-widest text-[var(--color-text-muted)]">
176 SMS (Twilio)
177 </p>
178 <p className="mt-2 font-mono text-[11px] text-[var(--color-text-muted)]">
179 {smsReady
180 ? 'ready — passwordless-sms on + secrets saved'
181 : methods?.passwordlessSms && !smsConfigured
182 ? 'method on, but secrets not set — open Providers → SMS'
183 : smsConfigured && !methods?.passwordlessSms
184 ? 'secrets saved, method off — turn on passwordless-sms under Providers'
185 : 'not ready — enable passwordless-sms and save Twilio under Providers'}
186 {' · '}
187 <Link
188 href={`/dashboard/auth/${projectId}/providers?method=passwordlessSms#auth-sms-setup`}
189 className="underline"
190 style={{ color: 'var(--auth-accent, #FFFD74)' }}
191 >
192 manage SMS
193 </Link>
194 </p>
195
196 <p className="mt-4 font-mono text-[10px] uppercase tracking-widest text-[var(--color-text-muted)]">
197 OAuth (secrets saved)
198 </p>
199 {oauthOn.length === 0 ? (
200 <p className="mt-2 font-mono text-[11px] text-[var(--color-text-muted)]">
201 none yet — save client id + secret under Providers
202 </p>
203 ) : (
204 <ul className="mt-2 flex flex-wrap gap-2">
205 {oauthOn.map((c) => (
206 <li
207 key={c.id}
208 className="rounded border px-2 py-1 font-mono text-[11px]"
209 style={{
210 borderColor: 'var(--auth-accent-border, #FFFD74)',
211 background: 'var(--auth-accent-soft)',
212 color: 'var(--color-text)',
213 }}
214 >
215 {c.label}
216 </li>
217 ))}
218 </ul>
219 )}
220 </>
221 )}
222 </div>
223
224 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
225 <h3 className="font-mono text-sm text-[var(--color-text)]">roles</h3>
226 {!rolesErr ? <AuthRolesForm projectId={projectId} /> : null}
227 {rolesErr ? (
228 <p className="mt-3 font-mono text-xs text-[var(--color-text-muted)]">
229 {rolesErr}
230 </p>
231 ) : roles.length === 0 ? (
232 <p className="mt-3 font-mono text-xs text-[var(--color-text-muted)]">
233 no roles yet for this project
234 </p>
235 ) : (
236 <ul className="mt-4 space-y-2 font-mono text-xs">
237 {roles.map((r) => (
238 <li
239 key={r.name}
240 className="rounded border border-[var(--color-border-subtle)] px-3 py-2"
241 >
242 <span className="text-[var(--color-text)]">{r.name}</span>
243 {r.permissions?.length ? (
244 <span className="ml-2 text-[var(--color-text-muted)]">
245 {r.permissions.join(', ')}
246 </span>
247 ) : null}
248 </li>
249 ))}
250 </ul>
251 )}
252 </div>
253 </section>
254 );
255}