security-client.tsx438 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { useCallback, useEffect, useState } from 'react'; |
| 4 | |
| 5 | import type { AuthV2ProjectRow } from '../lib/auth-v2-types'; |
| 6 | |
| 7 | interface TwoFactorState { |
| 8 | enabled: boolean; |
| 9 | required: boolean; |
| 10 | backupCodeCount: number; |
| 11 | } |
| 12 | |
| 13 | interface 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 | |
| 23 | const 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 | |
| 33 | export 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 | |
| 396 | function 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 | } |