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