security-client.tsx438 lines · main
1'use client';
2
3import { useCallback, useEffect, useState } from 'react';
4
5import type { AuthV2ProjectRow } from '../lib/auth-v2-types';
6
7interface TwoFactorState {
8 enabled: boolean;
9 required: boolean;
10 backupCodeCount: number;
11}
12
13interface PasswordPolicyState {
14 minLength: number;
15 requireUppercase: boolean;
16 requireLowercase: boolean;
17 requireNumber: boolean;
18 requireSpecial: boolean;
19 maxAgeDays: number | null;
20 preventReuse: number;
21}
22
23const DEFAULT_POLICY: PasswordPolicyState = {
24 minLength: 8,
25 requireUppercase: false,
26 requireLowercase: false,
27 requireNumber: false,
28 requireSpecial: false,
29 maxAgeDays: null,
30 preventReuse: 0,
31};
32
33export function AuthSecurityClient({ projects }: { projects: AuthV2ProjectRow[] }) {
34 const enabledProjects = projects.filter((p) => p.authEnabled);
35 const [projectId, setProjectId] = useState(enabledProjects[0]?.id ?? '');
36 const [twoFactor, setTwoFactor] = useState<TwoFactorState>({
37 enabled: false,
38 required: false,
39 backupCodeCount: 0,
40 });
41 const [policy, setPolicy] = useState<PasswordPolicyState>(DEFAULT_POLICY);
42 const [inactivityMinutes, setInactivityMinutes] = useState(0);
43 const [pendingTf, setPendingTf] = useState(false);
44 const [pendingPolicy, setPendingPolicy] = useState(false);
45 const [pendingSession, setPendingSession] = useState(false);
46 const [err, setErr] = useState<string | null>(null);
47 const [proof, setProof] = useState<string | null>(null);
48
49 const load = useCallback(async (id: string) => {
50 if (!id) return;
51 setErr(null);
52 setProof(null);
53 const [snapRes, cfgRes] = await Promise.all([
54 fetch(`/api/v1/auth-v2/projects/${id}/snapshot`, { credentials: 'include' }),
55 fetch(`/api/v1/projects/${id}/auth/config`, { credentials: 'include' }),
56 ]);
57 if (!snapRes.ok) {
58 const body = (await snapRes.json().catch(() => ({}))) as { message?: string };
59 setErr(body.message ?? `could not load (${snapRes.status})`);
60 return;
61 }
62 const body = (await snapRes.json()) as {
63 twoFactor?: TwoFactorState | null;
64 passwordPolicy?: PasswordPolicyState | null;
65 };
66 if (body.twoFactor) setTwoFactor(body.twoFactor);
67 else setTwoFactor({ enabled: false, required: false, backupCodeCount: 0 });
68 if (body.passwordPolicy) setPolicy(body.passwordPolicy);
69 else setPolicy(DEFAULT_POLICY);
70 if (cfgRes.ok) {
71 const cfg = (await cfgRes.json()) as {
72 config?: { session?: { inactivityTimeoutMinutes?: number } };
73 };
74 setInactivityMinutes(cfg.config?.session?.inactivityTimeoutMinutes ?? 0);
75 }
76 }, []);
77
78 useEffect(() => {
79 if (projectId) void load(projectId);
80 }, [projectId, load]);
81
82 async function saveSession(): Promise<void> {
83 if (!projectId) return;
84 setPendingSession(true);
85 setErr(null);
86 setProof(null);
87 try {
88 const res = await fetch(`/api/v1/projects/${projectId}/auth/config`, {
89 method: 'PATCH',
90 credentials: 'include',
91 headers: { 'content-type': 'application/json' },
92 body: JSON.stringify({
93 session: { inactivityTimeoutMinutes: inactivityMinutes },
94 }),
95 });
96 const body = (await res.json().catch(() => ({}))) as {
97 message?: string;
98 config?: { session?: { inactivityTimeoutMinutes?: number } };
99 };
100 if (!res.ok) throw new Error(body.message ?? `http ${res.status}`);
101 const mins = body.config?.session?.inactivityTimeoutMinutes ?? inactivityMinutes;
102 setInactivityMinutes(mins);
103 setProof(
104 mins > 0
105 ? `session timeout saved — ${mins} minute${mins === 1 ? '' : 's'} of idle`
106 : 'session timeout saved — never expire for idle',
107 );
108 } catch (e) {
109 setErr(e instanceof Error ? e.message : 'session save failed');
110 } finally {
111 setPendingSession(false);
112 }
113 }
114
115 async function saveTwoFactor(): Promise<void> {
116 if (!projectId) return;
117 setPendingTf(true);
118 setErr(null);
119 setProof(null);
120 try {
121 const res = await fetch(`/api/v1/auth-v2/projects/${projectId}/two-factor`, {
122 method: 'PUT',
123 credentials: 'include',
124 headers: { 'content-type': 'application/json' },
125 body: JSON.stringify({
126 enabled: twoFactor.enabled,
127 required: twoFactor.required,
128 }),
129 });
130 const body = (await res.json().catch(() => ({}))) as {
131 message?: string;
132 proof?: TwoFactorState;
133 twoFactor?: TwoFactorState;
134 savedAt?: string;
135 };
136 if (!res.ok) throw new Error(body.message ?? `http ${res.status}`);
137 const live = body.proof ?? body.twoFactor;
138 if (live) setTwoFactor(live);
139 setProof(
140 `2FA saved ${body.savedAt ?? 'ok'} — ${live?.enabled ? 'ON' : 'OFF'}` +
141 (live?.enabled
142 ? ` · required ${live.required ? 'yes' : 'no'} · ${live.backupCodeCount} backup codes per user`
143 : ''),
144 );
145 } catch (e) {
146 setErr(e instanceof Error ? e.message : '2FA save failed');
147 } finally {
148 setPendingTf(false);
149 }
150 }
151
152 async function savePolicy(): Promise<void> {
153 if (!projectId) return;
154 setPendingPolicy(true);
155 setErr(null);
156 setProof(null);
157 try {
158 const res = await fetch(`/api/v1/auth-v2/projects/${projectId}/password-policy`, {
159 method: 'PUT',
160 credentials: 'include',
161 headers: { 'content-type': 'application/json' },
162 body: JSON.stringify(policy),
163 });
164 const body = (await res.json().catch(() => ({}))) as {
165 message?: string;
166 proof?: PasswordPolicyState;
167 passwordPolicy?: PasswordPolicyState;
168 savedAt?: string;
169 };
170 if (!res.ok) throw new Error(body.message ?? `http ${res.status}`);
171 const live = body.proof ?? body.passwordPolicy;
172 if (live) setPolicy(live);
173 setProof(
174 `password policy saved ${body.savedAt ?? 'ok'} — min ${live?.minLength ?? '?'} chars` +
175 (live?.maxAgeDays ? ` · expires every ${live.maxAgeDays} days` : ' · no expiry'),
176 );
177 } catch (e) {
178 setErr(e instanceof Error ? e.message : 'policy save failed');
179 } finally {
180 setPendingPolicy(false);
181 }
182 }
183
184 if (enabledProjects.length === 0) {
185 return (
186 <p className="font-mono text-xs text-[var(--color-text-muted)]">
187 enable Auth on a project first (see projects).
188 </p>
189 );
190 }
191
192 return (
193 <div className="flex max-w-xl flex-col gap-8">
194 <label className="flex flex-col gap-1 font-mono text-xs">
195 <span className="text-[var(--color-text-muted)]">project</span>
196 <select
197 value={projectId}
198 onChange={(e) => setProjectId(e.target.value)}
199 className="rounded-md border bg-[var(--color-surface)] px-3 py-2 text-[var(--color-text)]"
200 style={{ borderColor: 'var(--auth-accent-border)' }}
201 >
202 {enabledProjects.map((p) => (
203 <option key={p.id} value={p.id}>
204 {p.name} ({p.slug})
205 </option>
206 ))}
207 </select>
208 </label>
209
210 {/* 2FA + backup codes */}
211 <section className="flex flex-col gap-3">
212 <div>
213 <h3 className="font-mono text-sm text-[var(--color-text)]">two-factor (2FA)</h3>
214 <p className="mt-1 font-mono text-[10px] leading-relaxed text-[var(--color-text-muted)]">
215 when on, users can enroll an authenticator app. after enroll they get{' '}
216 <strong className="text-[var(--color-text)]">10 one-time backup codes</strong> for if
217 they lose their phone. apps use{' '}
218 <code className="text-[var(--color-text)]">auth.twoFactor.verifyBackupCode</code>.
219 </p>
220 </div>
221
222 <ul className="flex flex-col gap-3">
223 <ToggleRow
224 label="enable 2FA"
225 help="TOTP app codes + 10 backup recovery codes"
226 on={twoFactor.enabled}
227 onToggle={() =>
228 setTwoFactor((f) => ({
229 ...f,
230 enabled: !f.enabled,
231 required: !f.enabled ? f.required : false,
232 backupCodeCount: !f.enabled ? 10 : 0,
233 }))
234 }
235 />
236 <ToggleRow
237 label="require 2FA for all users"
238 help="blocks sign-in until each user enrolls (only if 2FA is enabled)"
239 on={twoFactor.required}
240 disabled={!twoFactor.enabled}
241 onToggle={() => setTwoFactor((f) => ({ ...f, required: !f.required }))}
242 />
243 </ul>
244
245 <button
246 type="button"
247 disabled={pendingTf}
248 onClick={() => void saveTwoFactor()}
249 className="self-start rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
250 style={{ background: '#FFFD74' }}
251 >
252 {pendingTf ? 'saving…' : 'save 2FA (with live proof)'}
253 </button>
254 </section>
255
256 {/* Session inactivity (gap fix #1 polish in UI) */}
257 <section className="flex flex-col gap-3">
258 <div>
259 <h3 className="font-mono text-sm text-[var(--color-text)]">session timeout</h3>
260 <p className="mt-1 font-mono text-[10px] leading-relaxed text-[var(--color-text-muted)]">
261 kick users after this many minutes of no activity. 0 = never. checked on every
262 authenticated request.
263 </p>
264 </div>
265 <label className="flex flex-col gap-1 font-mono text-xs">
266 <span className="text-[var(--color-text-muted)]">inactivity minutes</span>
267 <input
268 type="number"
269 min={0}
270 max={1440}
271 value={inactivityMinutes}
272 onChange={(e) => setInactivityMinutes(Number(e.target.value) || 0)}
273 className="w-28 rounded-md border bg-[var(--color-surface)] px-3 py-2"
274 style={{ borderColor: 'var(--auth-accent-border)' }}
275 />
276 </label>
277 <button
278 type="button"
279 disabled={pendingSession}
280 onClick={() => void saveSession()}
281 className="self-start rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
282 style={{ background: '#FFFD74' }}
283 >
284 {pendingSession ? 'saving…' : 'save session timeout'}
285 </button>
286 </section>
287
288 {/* password policy */}
289 <section className="flex flex-col gap-3">
290 <div>
291 <h3 className="font-mono text-sm text-[var(--color-text)]">password rules</h3>
292 <p className="mt-1 font-mono text-[10px] leading-relaxed text-[var(--color-text-muted)]">
293 applied when people set or change a password. expiry forces a reset after N days.
294 </p>
295 </div>
296
297 <label className="flex flex-col gap-1 font-mono text-xs">
298 <span className="text-[var(--color-text-muted)]">minimum length</span>
299 <input
300 type="number"
301 min={6}
302 max={128}
303 value={policy.minLength}
304 onChange={(e) =>
305 setPolicy((p) => ({ ...p, minLength: Number(e.target.value) || 8 }))
306 }
307 className="w-24 rounded-md border bg-[var(--color-surface)] px-3 py-2"
308 style={{ borderColor: 'var(--auth-accent-border)' }}
309 />
310 </label>
311
312 <ul className="flex flex-col gap-3">
313 <ToggleRow
314 label="require uppercase"
315 help="at least one A–Z"
316 on={policy.requireUppercase}
317 onToggle={() => setPolicy((p) => ({ ...p, requireUppercase: !p.requireUppercase }))}
318 />
319 <ToggleRow
320 label="require lowercase"
321 help="at least one a–z"
322 on={policy.requireLowercase}
323 onToggle={() => setPolicy((p) => ({ ...p, requireLowercase: !p.requireLowercase }))}
324 />
325 <ToggleRow
326 label="require number"
327 help="at least one digit"
328 on={policy.requireNumber}
329 onToggle={() => setPolicy((p) => ({ ...p, requireNumber: !p.requireNumber }))}
330 />
331 <ToggleRow
332 label="require special character"
333 help="symbol such as ! @ #"
334 on={policy.requireSpecial}
335 onToggle={() => setPolicy((p) => ({ ...p, requireSpecial: !p.requireSpecial }))}
336 />
337 </ul>
338
339 <div className="flex flex-wrap gap-4">
340 <label className="flex flex-col gap-1 font-mono text-xs">
341 <span className="text-[var(--color-text-muted)]">max age (days, blank = never)</span>
342 <input
343 type="number"
344 min={1}
345 max={3650}
346 value={policy.maxAgeDays ?? ''}
347 placeholder="never"
348 onChange={(e) => {
349 const v = e.target.value.trim();
350 setPolicy((p) => ({
351 ...p,
352 maxAgeDays: v === '' ? null : Number(v) || null,
353 }));
354 }}
355 className="w-28 rounded-md border bg-[var(--color-surface)] px-3 py-2"
356 style={{ borderColor: 'var(--auth-accent-border)' }}
357 />
358 </label>
359 <label className="flex flex-col gap-1 font-mono text-xs">
360 <span className="text-[var(--color-text-muted)]">block last N passwords</span>
361 <input
362 type="number"
363 min={0}
364 max={24}
365 value={policy.preventReuse}
366 onChange={(e) =>
367 setPolicy((p) => ({ ...p, preventReuse: Number(e.target.value) || 0 }))
368 }
369 className="w-24 rounded-md border bg-[var(--color-surface)] px-3 py-2"
370 style={{ borderColor: 'var(--auth-accent-border)' }}
371 />
372 </label>
373 </div>
374
375 <button
376 type="button"
377 disabled={pendingPolicy}
378 onClick={() => void savePolicy()}
379 className="self-start rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
380 style={{ background: '#FFFD74' }}
381 >
382 {pendingPolicy ? 'saving…' : 'save password rules (with live proof)'}
383 </button>
384 </section>
385
386 {proof ? (
387 <p className="font-mono text-xs" style={{ color: 'var(--auth-accent)' }}>
388 {proof}
389 </p>
390 ) : null}
391 {err ? <p className="font-mono text-xs text-[var(--color-error)]">{err}</p> : null}
392 </div>
393 );
394}
395
396function ToggleRow({
397 label,
398 help,
399 on,
400 onToggle,
401 disabled,
402}: {
403 label: string;
404 help: string;
405 on: boolean;
406 onToggle: () => void;
407 disabled?: boolean;
408}) {
409 return (
410 <li
411 className="flex items-start justify-between gap-4 rounded-md border p-3"
412 style={{
413 borderColor: 'var(--auth-accent-border)',
414 background: 'var(--color-surface-raised)',
415 opacity: disabled ? 0.5 : 1,
416 }}
417 >
418 <div>
419 <p className="font-mono text-sm text-[var(--color-text)]">{label}</p>
420 <p className="mt-0.5 font-mono text-[10px] text-[var(--color-text-muted)]">{help}</p>
421 </div>
422 <button
423 type="button"
424 role="switch"
425 aria-checked={on}
426 disabled={disabled}
427 onClick={onToggle}
428 className="relative h-6 w-11 shrink-0 rounded-full transition disabled:cursor-not-allowed"
429 style={{ background: on ? '#FFFD74' : 'var(--color-border)' }}
430 >
431 <span
432 className="absolute top-0.5 size-5 rounded-full bg-white transition"
433 style={{ left: on ? '1.35rem' : '0.15rem' }}
434 />
435 </button>
436 </li>
437 );
438}