fix-spa-review-findings (ornith-35): fetch timeout+res.ok, AuthSection loop fix, me() timeout, enable2fa finally, TOTP mask toggle, snapshot backoff, dup key removed
Cross-review by qwopus35b pending. SPA lint 2/2 green.
This commit is contained in:
+67
-15
@@ -1,17 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { OverviewPanel } from "@/components/OverviewPanel";
|
import { OverviewPanel } from "@/components/OverviewPanel";
|
||||||
import { ChartLabPanel } from "@/components/ChartLabPanel";
|
import { ChartLabPanel } from "@/components/ChartLabPanel";
|
||||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||||
import { api, type AuthUser } from "@/lib/trpc";
|
import { api, type AuthUser } from "@/lib/trpc";
|
||||||
import { UI_STRINGS } from "@/lib/strings";
|
import { UI_STRINGS } from "@/lib/strings";
|
||||||
|
|
||||||
|
// 8-second fetch timeout matching trpc.ts (P6: plain English helper).
|
||||||
|
const ME_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
|
async function fetchMe(): Promise<AuthUser | null> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), ME_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
return await api.auth.me();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page() {
|
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);
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []);
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchMe().then((u) => { if (!cancelled) setUser(u); }).catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
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">
|
||||||
@@ -23,7 +40,9 @@ export default function Page() {
|
|||||||
<ChartLabPanel />
|
<ChartLabPanel />
|
||||||
{user && !user.onboarded && <OnboardingSection onDone={() => api.auth.me().then(setUser)} />}
|
{user && !user.onboarded && <OnboardingSection onDone={() => api.auth.me().then(setUser)} />}
|
||||||
<AuthSection user={user} setUser={setUser} />
|
<AuthSection user={user} setUser={setUser} />
|
||||||
<PrimaryRuleFooter />
|
<footer className="border-t border-[#2a2b3a] pt-4">
|
||||||
|
<p className="text-[11px] text-[#5a5b6a]">{UI_STRINGS.primaryRuleFooter}</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,6 +94,25 @@ function OnboardingSection({ onDone }: { onDone: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mask sensitive 2FA data by default; reveal on user action (P7: shape+color+label).
|
||||||
|
function SecretDisplay({ value, label }: { value: string; label: string }) {
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-[#5a5b6a]">{label}</p>
|
||||||
|
<p className="font-mono break-all text-[#e6e7ec]">
|
||||||
|
{revealed ? value : "****"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setRevealed((r) => !r)}
|
||||||
|
className="text-[10px] text-[#4f8cff] hover:text-[#e6e7ec]"
|
||||||
|
>
|
||||||
|
{revealed ? "Hide" : "Show"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AuthSection({ user, setUser }: { user: AuthUser | null; setUser: (u: AuthUser | null) => void }) {
|
function AuthSection({ user, setUser }: { user: AuthUser | null; setUser: (u: AuthUser | null) => void }) {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -83,10 +121,19 @@ function AuthSection({ user, setUser }: { user: AuthUser | null; setUser: (u: Au
|
|||||||
const [enroll, setEnroll] = useState<{ totpSecret: string; qrUrl: string; backupCodes: string[] } | null>(null);
|
const [enroll, setEnroll] = useState<{ totpSecret: string; qrUrl: string; backupCodes: string[] } | null>(null);
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [twoFactorMsg, setTwoFactorMsg] = useState<string | null>(null);
|
const [twoFactorMsg, setTwoFactorMsg] = useState<string | null>(null);
|
||||||
useEffect(() => { if (user === null) api.auth.me().then(setUser).catch(() => {}); }, [user, setUser]);
|
useEffect(() => {
|
||||||
|
if (user === null) {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchMe().then((u) => { if (!cancelled) setUser(u); }).catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
const submit = async (mode: "signup" | "login") => {
|
const submit = async (mode: "signup" | "login") => {
|
||||||
setBusy(true); setError(null);
|
setBusy(true); setError(null);
|
||||||
try { await api.auth[mode](email, password); setUser(await api.auth.me()); }
|
try {
|
||||||
|
await api.auth[mode](email, password);
|
||||||
|
setUser(await fetchMe());
|
||||||
|
}
|
||||||
catch (e) { setError(e instanceof Error ? e.message : "request failed"); }
|
catch (e) { setError(e instanceof Error ? e.message : "request failed"); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
@@ -98,16 +145,27 @@ function AuthSection({ user, setUser }: { user: AuthUser | null; setUser: (u: Au
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
{!enroll && !twoFactorMsg && (
|
{!enroll && !twoFactorMsg && (
|
||||||
<button onClick={async () => { try { setEnroll(await api.auth.enable2fa()); } catch (e) { setError(e instanceof Error ? e.message : "request failed"); } }}
|
<button onClick={async () => {
|
||||||
|
try {
|
||||||
|
setEnroll(await api.auth.enable2fa());
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "request failed");
|
||||||
|
} finally {
|
||||||
|
// loading state is not applicable here (no busy flag), but we
|
||||||
|
// ensure partial state is cleaned up on failure.
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-xs px-3 py-1.5 rounded border border-[#2a2b3a] text-[#8a8b9a] hover:border-[#4f8cff] hover:text-[#e6e7ec]">{UI_STRINGS.enable2faButton}</button>
|
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 && (
|
{enroll && (
|
||||||
<div className="text-xs space-y-2 border border-[#2a2b3a] rounded p-3">
|
<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.twoFactorQrLabel}</p>
|
||||||
<p className="text-[#5a5b6a]">{UI_STRINGS.twoFactorSecretLabel}</p>
|
<p className="text-[#5a5b6a]">{UI_STRINGS.twoFactorSecretLabel}</p>
|
||||||
<p className="font-mono break-all text-[#e6e7ec]">{enroll.totpSecret}</p>
|
<SecretDisplay value={enroll.totpSecret} label="Secret" />
|
||||||
<p className="text-[#5a5b6a]">{UI_STRINGS.backupCodesLabel}</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>
|
<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">
|
<div className="flex gap-2 pt-1">
|
||||||
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder={UI_STRINGS.twoFactorCodePlaceholder}
|
<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" />
|
className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-1 text-[#e6e7ec] w-32" />
|
||||||
@@ -139,10 +197,4 @@ function AuthSection({ user, setUser }: { user: AuthUser | null; setUser: (u: Au
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrimaryRuleFooter() {
|
|
||||||
return (
|
|
||||||
<footer className="border-t border-[#2a2b3a] pt-4">
|
|
||||||
<p className="text-[11px] text-[#5a5b6a]">{UI_STRINGS.primaryRuleFooter}</p>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,12 +17,43 @@ export function OverviewPanel() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true); setError(null);
|
setLoading(true); setError(null);
|
||||||
const load = () => api.market.snapshot(symbol)
|
let consecutiveErrors = 0;
|
||||||
.then((s) => { if (!cancelled) { setSnap(s); setLoading(false); } })
|
const BASE_INTERVAL = 5_000;
|
||||||
.catch((e) => { if (!cancelled) { setError(e instanceof Error ? e.message : "request failed"); setLoading(false); } });
|
const MAX_INTERVAL = 30_000;
|
||||||
|
const load = () => {
|
||||||
|
api.market.snapshot(symbol)
|
||||||
|
.then((s) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSnap(s);
|
||||||
|
setLoading(false);
|
||||||
|
consecutiveErrors = 0; // reset backoff on success
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(e instanceof Error ? e.message : "request failed");
|
||||||
|
setLoading(false);
|
||||||
|
consecutiveErrors++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
load();
|
load();
|
||||||
const t = setInterval(load, 5000); // poll for SWR refresh
|
// Jittered backoff: 5s base, up to 30s max, with +/- 20% jitter.
|
||||||
return () => { cancelled = true; clearInterval(t); };
|
const scheduleNext = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const backoffMs = Math.min(
|
||||||
|
BASE_INTERVAL * Math.pow(2, Math.min(consecutiveErrors - 1, 3)),
|
||||||
|
MAX_INTERVAL
|
||||||
|
);
|
||||||
|
const jitter = backoffMs * (0.8 + Math.random() * 0.4);
|
||||||
|
t = setTimeout(scheduleNext, jitter);
|
||||||
|
};
|
||||||
|
let t: ReturnType<typeof setTimeout>;
|
||||||
|
scheduleNext();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (t) clearTimeout(t);
|
||||||
|
};
|
||||||
}, [symbol]);
|
}, [symbol]);
|
||||||
|
|
||||||
const q = snap?.quote ?? null;
|
const q = snap?.quote ?? null;
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const UI_STRINGS = {
|
|||||||
drawdownNote: "A default max-drawdown tolerance is set from your experience level (you can change it later).",
|
drawdownNote: "A default max-drawdown tolerance is set from your experience level (you can change it later).",
|
||||||
completeOnboardingButton: "Finish setup",
|
completeOnboardingButton: "Finish setup",
|
||||||
onboardingDone: "Setup complete — your starter watchlist is tracking.",
|
onboardingDone: "Setup complete — your starter watchlist is tracking.",
|
||||||
onboardingDone: "Setup complete — your starter watchlist is tracking.",
|
|
||||||
|
|
||||||
// Chart Lab (Slice 5c)
|
// Chart Lab (Slice 5c)
|
||||||
chartLabTitle: "Chart Lab — price, volume, and indicator overlays",
|
chartLabTitle: "Chart Lab — price, volume, and indicator overlays",
|
||||||
|
|||||||
+37
-7
@@ -45,22 +45,52 @@ export interface AuthUser {
|
|||||||
onboarded?: boolean;
|
onboarded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 8-second timeout for all tRPC calls to prevent hung requests.
|
||||||
|
const TRPC_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
async function parse<T>(res: Response): Promise<T> {
|
async function parse<T>(res: Response): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`tRPC ${res.status}: ${body}`);
|
||||||
|
}
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json?.error) {
|
if (json?.error) {
|
||||||
const msg = json.error.message ?? `tRPC error ${json.error.data?.code ?? ""}`;
|
throw new Error(`tRPC error: ${json.error.message ?? json.error.data?.code ?? "unknown"}`);
|
||||||
throw new Error(typeof msg === "string" ? msg : "tRPC error");
|
|
||||||
}
|
}
|
||||||
return json.result.data as T;
|
return json.result.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trpcQuery<T>(procedure: string, input?: unknown): Promise<T> {
|
async function trpcQuery<T>(procedure: string, input?: unknown): Promise<T> {
|
||||||
const search = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : "";
|
const controller = new AbortController();
|
||||||
const res = await fetch(`/api/trpc/${procedure}${search}`, { headers: { "content-type": "application/json" }, credentials: "include" });
|
const timer = setTimeout(() => controller.abort(), TRPC_TIMEOUT_MS);
|
||||||
return parse<T>(res);
|
try {
|
||||||
|
const search = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : "";
|
||||||
|
const res = await fetch(`/api/trpc/${procedure}${search}`, {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return parse<T>(res);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trpcMutate<T>(procedure: string, input: unknown): Promise<T> {
|
async function trpcMutate<T>(procedure: string, input: unknown): Promise<T> {
|
||||||
const res = await fetch(`/api/trpc/${procedure}`, { method: "POST", headers: { "content-type": "application/json" }, credentials: "include", body: JSON.stringify(input) });
|
const controller = new AbortController();
|
||||||
return parse<T>(res);
|
const timer = setTimeout(() => controller.abort(), TRPC_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/trpc/${procedure}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return parse<T>(res);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Complexity = "beginner" | "intermediate" | "advanced";
|
export type Complexity = "beginner" | "intermediate" | "advanced";
|
||||||
|
|||||||
Reference in New Issue
Block a user