From cc1213fb640a809c4538d349122b57318223791a Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Tue, 30 Jun 2026 08:54:33 -0400 Subject: [PATCH] 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. --- app/src/app/page.tsx | 82 +++++++++++++++++++++++----- app/src/components/OverviewPanel.tsx | 41 ++++++++++++-- app/src/lib/strings.ts | 1 - app/src/lib/trpc.ts | 44 ++++++++++++--- 4 files changed, 140 insertions(+), 28 deletions(-) diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index bc2619d..04696aa 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -1,17 +1,34 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { OverviewPanel } from "@/components/OverviewPanel"; import { ChartLabPanel } from "@/components/ChartLabPanel"; import { useActiveSymbol } from "@/stores/active-symbol-store"; import { api, type AuthUser } from "@/lib/trpc"; 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 { + 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() { const activeSymbol = useActiveSymbol((s) => s.activeSymbol); const setActive = useActiveSymbol((s) => s.setActive); const [draft, setDraft] = useState(activeSymbol); const [user, setUser] = useState(null); - useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []); + useEffect(() => { + let cancelled = false; + fetchMe().then((u) => { if (!cancelled) setUser(u); }).catch(() => {}); + return () => { cancelled = true; }; + }, []); return (
{ e.preventDefault(); setActive(draft); }} className="flex gap-2"> @@ -23,7 +40,9 @@ export default function Page() { {user && !user.onboarded && api.auth.me().then(setUser)} />} - +
+

{UI_STRINGS.primaryRuleFooter}

+
); } @@ -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 ( +
+

{label}

+

+ {revealed ? value : "****"} +

+ +
+ ); +} + function AuthSection({ user, setUser }: { user: AuthUser | null; setUser: (u: AuthUser | null) => void }) { const [email, setEmail] = 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 [code, setCode] = useState(""); const [twoFactorMsg, setTwoFactorMsg] = useState(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") => { 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"); } finally { setBusy(false); } }; @@ -98,16 +145,27 @@ function AuthSection({ user, setUser }: { user: AuthUser | null; setUser: (u: Au

Complexity: {user.complexity} ยท Risk tolerance: {user.riskTolerance}

{!enroll && !twoFactorMsg && ( - )} {enroll && (

{UI_STRINGS.twoFactorQrLabel}

{UI_STRINGS.twoFactorSecretLabel}

-

{enroll.totpSecret}

+

{UI_STRINGS.backupCodesLabel}

-
    {enroll.backupCodes.map((c) =>
  • {c}
  • )}
+
    + {enroll.backupCodes.map((c) =>
  • {c}
  • )} +
setCode(e.target.value)} placeholder={UI_STRINGS.twoFactorCodePlaceholder} 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 ( -
-

{UI_STRINGS.primaryRuleFooter}

-
- ); -} + diff --git a/app/src/components/OverviewPanel.tsx b/app/src/components/OverviewPanel.tsx index 70a26d6..9d60755 100644 --- a/app/src/components/OverviewPanel.tsx +++ b/app/src/components/OverviewPanel.tsx @@ -17,12 +17,43 @@ export function OverviewPanel() { useEffect(() => { let cancelled = false; setLoading(true); setError(null); - const load = () => api.market.snapshot(symbol) - .then((s) => { if (!cancelled) { setSnap(s); setLoading(false); } }) - .catch((e) => { if (!cancelled) { setError(e instanceof Error ? e.message : "request failed"); setLoading(false); } }); + let consecutiveErrors = 0; + const BASE_INTERVAL = 5_000; + 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(); - const t = setInterval(load, 5000); // poll for SWR refresh - return () => { cancelled = true; clearInterval(t); }; + // Jittered backoff: 5s base, up to 30s max, with +/- 20% jitter. + 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; + scheduleNext(); + return () => { + cancelled = true; + if (t) clearTimeout(t); + }; }, [symbol]); const q = snap?.quote ?? null; diff --git a/app/src/lib/strings.ts b/app/src/lib/strings.ts index 79b705d..184bada 100644 --- a/app/src/lib/strings.ts +++ b/app/src/lib/strings.ts @@ -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).", completeOnboardingButton: "Finish setup", onboardingDone: "Setup complete โ€” your starter watchlist is tracking.", - onboardingDone: "Setup complete โ€” your starter watchlist is tracking.", // Chart Lab (Slice 5c) chartLabTitle: "Chart Lab โ€” price, volume, and indicator overlays", diff --git a/app/src/lib/trpc.ts b/app/src/lib/trpc.ts index 42d1c5c..aaf5c80 100644 --- a/app/src/lib/trpc.ts +++ b/app/src/lib/trpc.ts @@ -45,22 +45,52 @@ export interface AuthUser { onboarded?: boolean; } +// 8-second timeout for all tRPC calls to prevent hung requests. +const TRPC_TIMEOUT_MS = 8_000; + async function parse(res: Response): Promise { + if (!res.ok) { + const body = await res.text(); + throw new Error(`tRPC ${res.status}: ${body}`); + } const json = await res.json(); if (json?.error) { - const msg = json.error.message ?? `tRPC error ${json.error.data?.code ?? ""}`; - throw new Error(typeof msg === "string" ? msg : "tRPC error"); + throw new Error(`tRPC error: ${json.error.message ?? json.error.data?.code ?? "unknown"}`); } return json.result.data as T; } + async function trpcQuery(procedure: string, input?: unknown): Promise { - const search = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : ""; - const res = await fetch(`/api/trpc/${procedure}${search}`, { headers: { "content-type": "application/json" }, credentials: "include" }); - return parse(res); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TRPC_TIMEOUT_MS); + 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(res); + } finally { + clearTimeout(timer); + } } + async function trpcMutate(procedure: string, input: unknown): Promise { - const res = await fetch(`/api/trpc/${procedure}`, { method: "POST", headers: { "content-type": "application/json" }, credentials: "include", body: JSON.stringify(input) }); - return parse(res); + const controller = new AbortController(); + 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(res); + } finally { + clearTimeout(timer); + } } export type Complexity = "beginner" | "intermediate" | "advanced";