admin / Synapse-Sonar
publicAttack Surface Simulation
Synapse-Sonar / synapse-sonar / components / MfaEnrollment.tsx
5541 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | "use client"; import { useState } from "react"; type Phase = "idle" | "enrolling" | "verifying" | "enabled"; export default function MfaEnrollment({ initialEnabled = false, continueHref, }: { initialEnabled?: boolean; /** When set, show a "Continue" link after enrollment (used by the mandatory gate). */ continueHref?: string; }) { const [phase, setPhase] = useState<Phase>(initialEnabled ? "enabled" : "idle"); const [qr, setQr] = useState<string | null>(null); const [otpauth, setOtpauth] = useState<string | null>(null); const [token, setToken] = useState(""); const [error, setError] = useState<string | null>(null); const [busy, setBusy] = useState(false); const begin = async () => { setBusy(true); setError(null); try { const res = await fetch("/api/mfa/enroll", { method: "POST" }); const data = await res.json(); if (!res.ok) { setError(data.error ?? "Could not start enrollment"); return; } setQr(data.qrDataUrl); setOtpauth(data.otpauth); setPhase("verifying"); } finally { setBusy(false); } }; const verify = async () => { setBusy(true); setError(null); try { const res = await fetch("/api/mfa/enroll", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); const data = await res.json(); if (!res.ok) { setError(data.error ?? "Verification failed"); return; } setPhase("enabled"); } finally { setBusy(false); } }; return ( <div className="rounded-xl border border-slate-700/60 bg-gradient-to-b from-[#1e293b] to-[#0f172a] p-6"> <div className="flex items-center justify-between"> <div> <h2 className="text-base font-semibold text-slate-100"> Multi-Factor Authentication </h2> <p className="text-xs text-slate-400">TOTP via any authenticator app (optional)</p> </div> <span className={`rounded px-2 py-0.5 text-[11px] font-medium ${ phase === "enabled" ? "bg-emerald-500/15 text-emerald-300" : "bg-slate-500/15 text-slate-400" }`} > {phase === "enabled" ? "Enabled" : "Disabled"} </span> </div> {error && ( <div className="mt-4 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-xs text-rose-300"> {error} </div> )} {phase === "idle" && ( <div className="mt-5"> <p className="text-sm text-slate-400"> Add a second factor to protect your account. You’ll scan a QR code and confirm a one-time code. </p> <button onClick={begin} disabled={busy} className="mt-4 rounded-lg bg-synapse-violet px-4 py-2 text-sm font-semibold text-white transition hover:brightness-110 disabled:opacity-40" > {busy ? "Starting…" : "Enable MFA"} </button> </div> )} {phase === "verifying" && ( <div className="mt-5 flex flex-col items-center gap-4 sm:flex-row sm:items-start"> <div className="rounded-lg border border-slate-700/60 bg-white p-3"> {qr ? ( // eslint-disable-next-line @next/next/no-img-element <img src={qr} alt="MFA QR code" width={160} height={160} /> ) : null} </div> <div className="flex-1"> <p className="text-sm text-slate-300"> 1. Scan the QR with your authenticator app. </p> {otpauth && ( <p className="mt-1 break-all font-mono text-[10px] text-slate-500">{otpauth}</p> )} <p className="mt-3 text-sm text-slate-300">2. Enter the 6-digit code to confirm.</p> <div className="mt-2 flex gap-2"> <input value={token} onChange={(e) => setToken(e.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="123456" inputMode="numeric" className="w-32 rounded-lg border border-slate-600/60 bg-[#05080f] px-3 py-2 text-center font-mono tracking-[0.3em] text-slate-100 outline-none focus:border-synapse-violet" /> <button onClick={verify} disabled={busy || token.length !== 6} className="rounded-lg bg-synapse-violet px-4 py-2 text-sm font-semibold text-white transition hover:brightness-110 disabled:opacity-40" > {busy ? "Verifying…" : "Verify & enable"} </button> </div> </div> </div> )} {phase === "enabled" && ( <div className="mt-5"> <div className="flex items-center gap-2 text-sm text-emerald-300"> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-500/15"> ✓ </span> MFA is active. You’ll be asked for a code at every sign-in. </div> {continueHref && ( <a href={continueHref} className="mt-4 inline-block w-full rounded-lg bg-synapse-cyan px-4 py-2.5 text-center text-sm font-semibold text-[#05080f] transition hover:brightness-110" > Continue to Synapse Sonar </a> )} </div> )} </div> ); } |