From 784b27a954608340283ee4586d47bf207dbf6fbd Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Thu, 28 May 2026 12:18:43 +0800 Subject: [PATCH] fix: preserve terminal access during auth sync --- frontend/app/api/auth/me/route.ts | 17 ++++ .../account/__tests__/paymentShell.test.ts | 10 ++ .../dashboard/ScanTerminalDashboard.tsx | 99 +++++++------------ .../__tests__/terminalAccessState.test.ts | 71 +++++++++++++ .../scan-terminal/terminal-access-state.ts | 62 ++++++++++++ 5 files changed, 194 insertions(+), 65 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts create mode 100644 frontend/components/dashboard/scan-terminal/terminal-access-state.ts diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index 4cefedf2..9639314a 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -47,6 +47,23 @@ export async function GET(req: NextRequest) { } finally { clearTimeout(timeoutId); } + if ((res.status === 401 || res.status === 403) && auth.authUserId) { + const response = NextResponse.json({ + authenticated: true, + user_id: auth.authUserId, + email: auth.authEmail || null, + subscription_active: null, + subscription_plan_code: null, + subscription_expires_at: null, + subscription_total_expires_at: null, + subscription_queued_days: 0, + subscription_queued_count: 0, + points: 0, + degraded_auth_profile: true, + degraded_reason: `backend_${res.status}`, + }); + return applyAuthResponseCookies(response, auth.response); + } if (res.status === 401 || res.status === 403) { const response = NextResponse.json({ authenticated: false, diff --git a/frontend/components/account/__tests__/paymentShell.test.ts b/frontend/components/account/__tests__/paymentShell.test.ts index 283db44e..2db8408d 100644 --- a/frontend/components/account/__tests__/paymentShell.test.ts +++ b/frontend/components/account/__tests__/paymentShell.test.ts @@ -88,6 +88,10 @@ export function runTests() { ), "utf8", ); + const authMeRouteSource = fs.readFileSync( + path.join(projectRoot, "app", "api", "auth", "me", "route.ts"), + "utf8", + ); const subscriptionsPageSource = fs.readFileSync( path.join( projectRoot, @@ -167,4 +171,10 @@ export function runTests() { grantRouteSource.includes('"status": "active"'), "ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing", ); + assert( + authMeRouteSource.includes("if ((res.status === 401 || res.status === 403) && auth.authUserId)") && + authMeRouteSource.includes("degraded_reason: `backend_${res.status}`") && + authMeRouteSource.includes("subscription_active: null"), + "auth profile proxy must preserve authenticated identity with unknown subscription on backend 401/403 instead of forcing a false paywall", + ); } diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index dcd04a36..da9794cf 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -50,6 +50,10 @@ import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRows import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils"; import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown"; import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector"; +import { + mergeAccessStateWithAuthPayload, + type AuthProfilePayload, +} from "@/components/dashboard/scan-terminal/terminal-access-state"; import { cityListItemsToScanRows, mergeScanRowsWithCityFallbackRows, @@ -921,6 +925,21 @@ function ScanTerminalScreen() { createEmptyAccess(true), ); + const loadAuthProfile = useCallback( + async (accessToken?: string | null): Promise => { + const headers: Record = { Accept: "application/json" }; + const token = String(accessToken || "").trim(); + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch("/api/auth/me", { + cache: "no-store", + headers, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; + }, + [], + ); + // Listen to Supabase auth events (e.g. token refreshed, signed out) useEffect(() => { if (!hasSupabasePublicEnv()) return; @@ -933,31 +952,8 @@ function ScanTerminalScreen() { setProAccess(createEmptyAccess(false)); } else if (event === "TOKEN_REFRESHED" || event === "SIGNED_IN") { try { - const response = await fetch("/api/auth/me", { - cache: "no-store", - headers: { Accept: "application/json" }, - }); - if (response.ok) { - const payload = await response.json(); - setProAccess({ - loading: false, - authenticated: Boolean(payload.authenticated), - userId: payload.user_id ?? null, - subscriptionActive: payload.subscription_active === true, - subscriptionPlanCode: payload.subscription_plan_code ?? null, - subscriptionExpiresAt: payload.subscription_expires_at ?? null, - subscriptionTotalExpiresAt: - payload.subscription_total_expires_at ?? - payload.subscription_expires_at ?? - null, - subscriptionQueuedDays: Math.max( - 0, - Number(payload.subscription_queued_days ?? 0), - ), - points: Number(payload.points ?? 0), - error: null, - }); - } + const payload = await loadAuthProfile(session?.access_token); + setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload)); } catch {} } }); @@ -1034,55 +1030,28 @@ function ScanTerminalScreen() { cancelled = true; }; } - fetch("/api/auth/me", { - cache: "no-store", - headers: { Accept: "application/json" }, - }) - .then(async (response) => { - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise<{ - authenticated?: boolean; - user_id?: string | null; - subscription_active?: boolean | null; - subscription_plan_code?: string | null; - subscription_expires_at?: string | null; - subscription_total_expires_at?: string | null; - subscription_queued_days?: number | null; - points?: number | null; - }>; - }) + const sessionPromise = hasSupabasePublicEnv() + ? getSupabaseBrowserClient().auth.getSession() + : Promise.resolve({ data: { session: null } }); + + sessionPromise + .then(({ data: { session } }) => loadAuthProfile(session?.access_token)) .then((payload) => { if (cancelled) return; - setProAccess({ - loading: false, - authenticated: Boolean(payload.authenticated), - userId: payload.user_id ?? null, - subscriptionActive: payload.subscription_active === true, - subscriptionPlanCode: payload.subscription_plan_code ?? null, - subscriptionExpiresAt: payload.subscription_expires_at ?? null, - subscriptionTotalExpiresAt: - payload.subscription_total_expires_at ?? - payload.subscription_expires_at ?? - null, - subscriptionQueuedDays: Math.max( - 0, - Number(payload.subscription_queued_days ?? 0), - ), - points: Number(payload.points ?? 0), - error: null, - }); + setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload)); }) .catch((error) => { if (cancelled) return; - setProAccess({ - ...createEmptyAccess(false), - error: String(error), - }); + setProAccess((prev) => ( + prev.subscriptionActive + ? { ...prev, loading: false, error: String(error) } + : { ...createEmptyAccess(false), error: String(error) } + )); }); return () => { cancelled = true; }; - }, []); + }, [loadAuthProfile]); useEffect(() => { setSelectedRegionKey("all"); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts new file mode 100644 index 00000000..ac89db31 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts @@ -0,0 +1,71 @@ +import { + createAccessStateFromAuthPayload, + mergeAccessStateWithAuthPayload, +} from "@/components/dashboard/scan-terminal/terminal-access-state"; +import type { ProAccessState } from "@/lib/dashboard-types"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +const activeAccess: ProAccessState = { + loading: false, + authenticated: true, + userId: "user-1", + subscriptionActive: true, + subscriptionPlanCode: "pro_monthly", + subscriptionExpiresAt: "2026-06-30T00:00:00Z", + subscriptionTotalExpiresAt: "2026-06-30T00:00:00Z", + subscriptionQueuedDays: 0, + points: 120, + error: null, +}; + +export function runTests() { + const degraded = mergeAccessStateWithAuthPayload(activeAccess, { + authenticated: true, + user_id: "user-1", + subscription_active: null, + subscription_plan_code: null, + subscription_expires_at: null, + subscription_total_expires_at: null, + subscription_queued_days: 0, + points: 0, + degraded_auth_profile: true, + }); + + assert( + degraded.subscriptionActive === true, + "terminal gate must preserve a previously active subscription when auth profile is degraded/unknown", + ); + assert( + degraded.subscriptionPlanCode === "pro_monthly", + "terminal gate must keep the previous plan metadata while subscription sync is unknown", + ); + + const confirmedInactive = mergeAccessStateWithAuthPayload(activeAccess, { + authenticated: true, + user_id: "user-1", + subscription_active: false, + subscription_plan_code: null, + subscription_expires_at: null, + subscription_total_expires_at: null, + subscription_queued_days: 0, + points: 0, + }); + assert( + confirmedInactive.subscriptionActive === false, + "terminal gate must still respect a confirmed inactive subscription response", + ); + + const coldUnknown = createAccessStateFromAuthPayload({ + authenticated: true, + user_id: "user-1", + subscription_active: null, + degraded_auth_profile: true, + }); + assert( + coldUnknown.subscriptionActive === false && coldUnknown.authenticated === true, + "cold-start unknown subscription state must not fabricate Pro access", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/terminal-access-state.ts b/frontend/components/dashboard/scan-terminal/terminal-access-state.ts new file mode 100644 index 00000000..ee591c95 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/terminal-access-state.ts @@ -0,0 +1,62 @@ +import type { ProAccessState } from "@/lib/dashboard-types"; + +export type AuthProfilePayload = { + authenticated?: boolean; + user_id?: string | null; + subscription_active?: boolean | null; + subscription_plan_code?: string | null; + subscription_expires_at?: string | null; + subscription_total_expires_at?: string | null; + subscription_queued_days?: number | null; + points?: number | null; + degraded_auth_profile?: boolean | null; +}; + +function queuedDays(value: unknown) { + return Math.max(0, Number(value ?? 0)); +} + +export function createAccessStateFromAuthPayload( + payload: AuthProfilePayload, +): ProAccessState { + return { + loading: false, + authenticated: Boolean(payload.authenticated), + userId: payload.user_id ?? null, + subscriptionActive: payload.subscription_active === true, + subscriptionPlanCode: payload.subscription_plan_code ?? null, + subscriptionExpiresAt: payload.subscription_expires_at ?? null, + subscriptionTotalExpiresAt: + payload.subscription_total_expires_at ?? + payload.subscription_expires_at ?? + null, + subscriptionQueuedDays: queuedDays(payload.subscription_queued_days), + points: Number(payload.points ?? 0), + error: null, + }; +} + +export function mergeAccessStateWithAuthPayload( + previous: ProAccessState, + payload: AuthProfilePayload, +): ProAccessState { + const next = createAccessStateFromAuthPayload(payload); + const subscriptionUnknown = + payload.subscription_active === null || + payload.subscription_active === undefined || + payload.degraded_auth_profile === true; + + if (!subscriptionUnknown || !previous.subscriptionActive || !next.authenticated) { + return next; + } + + return { + ...next, + subscriptionActive: true, + subscriptionPlanCode: previous.subscriptionPlanCode, + subscriptionExpiresAt: previous.subscriptionExpiresAt, + subscriptionTotalExpiresAt: previous.subscriptionTotalExpiresAt, + subscriptionQueuedDays: previous.subscriptionQueuedDays, + points: Number.isFinite(next.points) && next.points > 0 ? next.points : previous.points, + }; +}