slice 2 UI: 2FA enrollment (enable -> secret/QR/backup codes -> confirm)

Orchestrator (me) SPA work: auth client +2FA/OAuth methods, 2FA strings, AuthSection
enrollment flow. Primary-Rule lint stays green. Slice 2 (auth-2fa-and-social-oauth)
COMPLETE: 2FA + OAuth backend (ornith modules + me integration) + 2FA enrollment UI.
This commit is contained in:
Investor Flow Build
2026-06-29 21:21:09 -04:00
parent 3b5aa90a89
commit 4816b19976
3 changed files with 48 additions and 13 deletions
+35 -12
View File
@@ -9,7 +9,6 @@ export default function Page() {
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
const setActive = useActiveSymbol((s) => s.setActive);
const [draft, setDraft] = useState(activeSymbol);
return (
<div className="space-y-6">
<form onSubmit={(e) => { e.preventDefault(); setActive(draft); }} className="flex gap-2">
@@ -17,7 +16,6 @@ export default function Page() {
className="flex-1 bg-[#121318] border border-[#2a2b3a] rounded-md px-3 py-2 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff]" />
<button type="submit" className="px-4 py-2 rounded-md text-sm font-medium bg-[#1a1b24] border border-[#2a2b3a] text-[#e6e7ec] hover:border-[#4f8cff]">Open</button>
</form>
<OverviewPanel />
<AuthSection />
<PrimaryRuleFooter />
@@ -31,25 +29,50 @@ function AuthSection() {
const [user, setUser] = useState<AuthUser | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// 2FA enrollment flow state
const [enroll, setEnroll] = useState<{ totpSecret: string; qrUrl: string; backupCodes: string[] } | null>(null);
const [code, setCode] = useState("");
const [twoFactorMsg, setTwoFactorMsg] = useState<string | null>(null);
useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []);
const submit = async (mode: "signup" | "login") => {
setBusy(true); setError(null);
try {
await api.auth[mode](email, password);
setUser(await api.auth.me());
} catch (e) {
setError(e instanceof Error ? e.message : "request failed");
} finally { setBusy(false); }
try { await api.auth[mode](email, password); setUser(await api.auth.me()); }
catch (e) { setError(e instanceof Error ? e.message : "request failed"); }
finally { setBusy(false); }
};
if (user) {
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
<p className="text-sm text-[#e6e7ec]">Signed in as <span className="font-mono">{user.email}</span></p>
<p className="text-xs text-[#5a5b6a] mt-1">Complexity: {user.complexity} · Risk tolerance: {user.riskTolerance}</p>
<button onClick={async () => { await api.auth.logout(); setUser(null); }} className="mt-3 text-xs px-3 py-1.5 rounded border border-[#2a2b3a] text-[#8a8b9a] hover:border-[#ef4444] hover:text-[#ef4444]">{UI_STRINGS.logoutButton}</button>
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
<div>
<p className="text-sm text-[#e6e7ec]">Signed in as <span className="font-mono">{user.email}</span></p>
<p className="text-xs text-[#5a5b6a] mt-1">Complexity: {user.complexity} · Risk tolerance: {user.riskTolerance}</p>
</div>
{!enroll && !twoFactorMsg && (
<button onClick={async () => { try { setEnroll(await api.auth.enable2fa()); } catch (e) { setError(e instanceof Error ? e.message : "request failed"); } }}
className="text-xs px-3 py-1.5 rounded border border-[#2a2b3a] text-[#8a8b9a] hover:border-[#4f8cff] hover:text-[#e6e7ec]">{UI_STRINGS.enable2faButton}</button>
)}
{enroll && (
<div className="text-xs space-y-2 border border-[#2a2b3a] rounded p-3">
<p className="text-[#5a5b6a]">{UI_STRINGS.twoFactorQrLabel}</p>
<p className="text-[#5a5b6a]">{UI_STRINGS.twoFactorSecretLabel}</p>
<p className="font-mono break-all text-[#e6e7ec]">{enroll.totpSecret}</p>
<p className="text-[#5a5b6a]">{UI_STRINGS.backupCodesLabel}</p>
<ul className="font-mono text-[#e6e7ec] grid grid-cols-2 gap-x-4">{enroll.backupCodes.map((c) => <li key={c}>{c}</li>)}</ul>
<div className="flex gap-2 pt-1">
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder={UI_STRINGS.twoFactorCodePlaceholder}
className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-1 text-[#e6e7ec] w-32" />
<button onClick={async () => { try { await api.auth.confirm2fa(code); setEnroll(null); setCode(""); setTwoFactorMsg(UI_STRINGS.twoFactorEnabled); } catch (e) { setError(e instanceof Error ? e.message : "request failed"); } }}
className="px-3 py-1 rounded bg-[#4f8cff] text-white">{UI_STRINGS.confirm2faButton}</button>
</div>
</div>
)}
{twoFactorMsg && <p className="text-xs text-[#34d399]">{twoFactorMsg}</p>}
{error && <p className="text-xs text-[#ef4444]">{error}</p>}
<button onClick={async () => { await api.auth.logout(); setUser(null); setEnroll(null); setTwoFactorMsg(null); }}
className="text-xs px-3 py-1.5 rounded border border-[#2a2b3a] text-[#8a8b9a] hover:border-[#ef4444] hover:text-[#ef4444]">{UI_STRINGS.logoutButton}</button>
</section>
);
}
+9
View File
@@ -24,6 +24,15 @@ export const UI_STRINGS = {
loginButton: "Sign in",
logoutButton: "Sign out",
signupPrompt: "Create an account to track symbols and keep your research in one place.",
enable2faButton: "Enable two-factor",
twoFactorSecretLabel: "Two-factor secret (base32)",
twoFactorQrLabel: "Scan this in an authenticator app, or enter the secret manually.",
backupCodesLabel: "Backup codes — store these somewhere safe.",
confirm2faButton: "Confirm with a code",
twoFactorCodePlaceholder: "6-digit code",
twoFactorEnabled: "Two-factor is now enabled. Login will ask for a code.",
oauthGithub: "Continue with GitHub",
oauthGoogle: "Continue with Google",
} as const;
export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS);
+4 -1
View File
@@ -76,8 +76,11 @@ export const api = {
market: { snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }) },
auth: {
signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }),
login: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.login", { email, password }),
login: (email: string, password: string, totp?: string) => trpcMutate<{ userId: string }>("auth.login", { email, password, ...(totp ? { totp } : {}) }),
logout: () => trpcMutate<{ ok: boolean }>("auth.logout", {}),
me: () => trpcQuery<AuthUser | null>("auth.me"),
enable2fa: () => trpcMutate<{ totpSecret: string; qrUrl: string; backupCodes: string[] }>("auth.enable2fa", {}),
confirm2fa: (totp: string) => trpcMutate<{ ok: boolean }>("auth.confirm2fa", { totp }),
oauthStart: (provider: "github" | "google", redirectUri: string) => trpcMutate<{ redirectUrl: string; state: string }>("auth.oauthStart", { provider, redirectUri }),
},
};