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:
+33
-10
@@ -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">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user