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 activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||||
const setActive = useActiveSymbol((s) => s.setActive);
|
const setActive = useActiveSymbol((s) => s.setActive);
|
||||||
const [draft, setDraft] = useState(activeSymbol);
|
const [draft, setDraft] = useState(activeSymbol);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<form onSubmit={(e) => { e.preventDefault(); setActive(draft); }} className="flex gap-2">
|
<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]" />
|
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>
|
<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>
|
</form>
|
||||||
|
|
||||||
<OverviewPanel />
|
<OverviewPanel />
|
||||||
<AuthSection />
|
<AuthSection />
|
||||||
<PrimaryRuleFooter />
|
<PrimaryRuleFooter />
|
||||||
@@ -31,25 +29,50 @@ function AuthSection() {
|
|||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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(() => {}); }, []);
|
useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []);
|
||||||
|
|
||||||
const submit = async (mode: "signup" | "login") => {
|
const submit = async (mode: "signup" | "login") => {
|
||||||
setBusy(true); setError(null);
|
setBusy(true); setError(null);
|
||||||
try {
|
try { await api.auth[mode](email, password); setUser(await api.auth.me()); }
|
||||||
await api.auth[mode](email, password);
|
catch (e) { setError(e instanceof Error ? e.message : "request failed"); }
|
||||||
setUser(await api.auth.me());
|
finally { setBusy(false); }
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "request failed");
|
|
||||||
} finally { setBusy(false); }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return (
|
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-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>
|
<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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ export const UI_STRINGS = {
|
|||||||
loginButton: "Sign in",
|
loginButton: "Sign in",
|
||||||
logoutButton: "Sign out",
|
logoutButton: "Sign out",
|
||||||
signupPrompt: "Create an account to track symbols and keep your research in one place.",
|
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;
|
} as const;
|
||||||
|
|
||||||
export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS);
|
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 }) },
|
market: { snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }) },
|
||||||
auth: {
|
auth: {
|
||||||
signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }),
|
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", {}),
|
logout: () => trpcMutate<{ ok: boolean }>("auth.logout", {}),
|
||||||
me: () => trpcQuery<AuthUser | null>("auth.me"),
|
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