From d9eea721ef411ef014507109ccaa9c3ed0e04c57 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 30 May 2026 21:01:48 +0800 Subject: [PATCH] feat: implement /api/auth/me proxy route with subscription-required state and fallback entitlement snapshots --- frontend/app/api/auth/me/route.ts | 52 +++++++++++++++---- .../account/__tests__/paymentShell.test.ts | 5 +- .../scan-terminal/TemperatureChartCanvas.tsx | 52 ++++++++++++++++++- .../__tests__/terminalAuthBootstrap.test.ts | 31 +++++++++++ .../__tests__/terminalGridPolicy.test.ts | 35 +++++++++++++ frontend/lib/auth-profile-proxy.ts | 43 +++++++++++++++ 6 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 frontend/lib/auth-profile-proxy.ts diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index 1f87bf35..199ddf31 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -17,6 +17,10 @@ import { entitlementSnapshotToAuthPayload, readEntitlementSnapshot, } from "@/lib/entitlement-snapshot"; +import { + buildSubscriptionRequiredAuthProfile, + isSubscriptionRequiredBackendResponse, +} from "@/lib/auth-profile-proxy"; import { hasSupabaseServerEnv } from "@/lib/supabase/server"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -113,6 +117,22 @@ function degradedAuthProfileResponse({ return applyAuthResponseCookies(degraded, response); } +function subscriptionRequiredAuthProfileResponse({ + email, + response, + userId, +}: { + email: string | null; + response: NextResponse | null; + userId: string; +}) { + const inactive = NextResponse.json( + buildSubscriptionRequiredAuthProfile({ email, userId }), + ); + clearEntitlementSnapshotCookie(inactive); + return applyAuthResponseCookies(inactive, response); +} + function snapshotAuthProfileResponse({ email, reason, @@ -223,16 +243,30 @@ export async function GET(req: NextRequest) { } finally { clearTimeout(timeoutId); } - if ((res.status === 401 || res.status === 403) && auth.authUserId) { - return degradedAuthProfileResponse({ - email: auth.authEmail || null, - reason: `backend_${res.status}`, - req, - response: auth.response, - userId: auth.authUserId, - }); - } if (res.status === 401 || res.status === 403) { + const raw = await res.text(); + const authIdentity = auth.authUserId + ? { email: auth.authEmail || null, userId: auth.authUserId } + : await getBearerIdentityOnce(); + if ( + authIdentity?.userId && + isSubscriptionRequiredBackendResponse(res.status, raw) + ) { + return subscriptionRequiredAuthProfileResponse({ + email: authIdentity.email, + response: auth.response, + userId: authIdentity.userId, + }); + } + if (auth.authUserId) { + return degradedAuthProfileResponse({ + email: auth.authEmail || null, + reason: `backend_${res.status}`, + req, + response: auth.response, + userId: auth.authUserId, + }); + } const identity = await getBearerIdentityOnce(); if (identity) { return degradedAuthProfileResponse({ diff --git a/frontend/components/account/__tests__/paymentShell.test.ts b/frontend/components/account/__tests__/paymentShell.test.ts index e225ebc3..8ed80a93 100644 --- a/frontend/components/account/__tests__/paymentShell.test.ts +++ b/frontend/components/account/__tests__/paymentShell.test.ts @@ -282,11 +282,12 @@ export function runTests() { "ops subscription grant route must fall back to direct Supabase grant and resolve users via indexed profiles before Auth Admin", ); assert( - authMeRouteSource.includes("if ((res.status === 401 || res.status === 403) && auth.authUserId)") && + authMeRouteSource.includes("isSubscriptionRequiredBackendResponse(res.status, raw)") && + authMeRouteSource.includes("subscriptionRequiredAuthProfileResponse") && authMeRouteSource.includes("degradedAuthProfileResponse") && authMeRouteSource.includes("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", + "auth profile proxy must only convert explicit subscription-required 403 responses to inactive access while preserving unrelated backend 401/403 responses as unknown subscription", ); assert( (authMeRouteSource.match(/buildBackendRequestHeaders\(req\)/g) || []).length === 1 && diff --git a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx index 07e4a532..574238f2 100644 --- a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx +++ b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx @@ -28,6 +28,46 @@ type CityThreshold = { kind: "gte" | "lte"; }; +function isFiniteChartValue(value: unknown) { + return typeof value === "number" && Number.isFinite(value); +} + +function hasDrawableTemperatureChartContent({ + activeSeries, + probabilityOverlay, + zoomedData, +}: { + activeSeries: EvidenceSeries[]; + probabilityOverlay: ProbabilityOverlay | null; + zoomedData: Array>; +}) { + if (probabilityOverlay?.muLine || probabilityOverlay?.bands.length) return true; + return activeSeries.some((series) => + zoomedData.some((point, index) => { + const value = point?.[series.key] ?? series.values[index]; + return isFiniteChartValue(value); + }), + ); +} + +function shouldKeepTemperatureChartLoading({ + row, + isHourlyLoading, + activeSeries, + probabilityOverlay, + zoomedData, +}: { + row: ScanOpportunityRow | null; + isHourlyLoading: boolean; + activeSeries: EvidenceSeries[]; + probabilityOverlay: ProbabilityOverlay | null; + zoomedData: Array>; +}) { + if (!row?.city) return false; + if (isHourlyLoading) return true; + return !hasDrawableTemperatureChartContent({ activeSeries, probabilityOverlay, zoomedData }); +} + function TemperatureChartCanvasComponent({ isEn, compact, @@ -133,6 +173,13 @@ function TemperatureChartCanvasComponent({ hasRunwayData && individualRunwaySeriesCount > 1 && collapsedRunwaySeries.length < chartSeries.length; + const shouldShowChartLoading = shouldKeepTemperatureChartLoading({ + row, + isHourlyLoading, + activeSeries, + probabilityOverlay, + zoomedData, + }); return (
@@ -195,7 +242,7 @@ function TemperatureChartCanvasComponent({ )}
- {canRenderChart && ( + {canRenderChart && !shouldShowChartLoading && ( )}
- {isHourlyLoading && ( + {shouldShowChartLoading && (
@@ -351,3 +398,4 @@ function TemperatureChartCanvasComponent({ } export const TemperatureChartCanvas = memo(TemperatureChartCanvasComponent); +export const __shouldKeepTemperatureChartLoadingForTest = shouldKeepTemperatureChartLoading; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts index 97fd3cf1..7e307953 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts @@ -2,6 +2,10 @@ import { loadTerminalAuthProfile, type TerminalAuthProfilePayload, } from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap"; +import { + buildSubscriptionRequiredAuthProfile, + isSubscriptionRequiredBackendResponse, +} from "@/lib/auth-profile-proxy"; function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); @@ -148,4 +152,31 @@ export async function runTests() { failedWithTransientAuthError, "terminal auth bootstrap must not resolve to an anonymous paywall when a bearer session exists but the auth profile request is transiently failing", ); + + assert( + isSubscriptionRequiredBackendResponse( + 403, + '{"detail":"Subscription required"}', + ) === true, + "auth profile proxy should recognize backend subscription-required responses as confirmed inactive access", + ); + assert( + isSubscriptionRequiredBackendResponse( + 403, + '{"detail":"temporary entitlement outage"}', + ) === false, + "auth profile proxy should keep unrelated backend 403 responses in the transient/degraded path", + ); + + const subscriptionRequiredProfile = buildSubscriptionRequiredAuthProfile({ + email: "user@example.com", + userId: "user-1", + }); + assert( + subscriptionRequiredProfile.authenticated === true && + subscriptionRequiredProfile.user_id === "user-1" && + subscriptionRequiredProfile.subscription_active === false && + !("degraded_auth_profile" in subscriptionRequiredProfile), + "auth profile proxy must return confirmed inactive access instead of an endless unknown subscription state", + ); } diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts index 21044681..e88d4e9c 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { __shouldKeepTemperatureChartLoadingForTest } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas"; function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); @@ -118,4 +119,38 @@ export function runTests() { chartCanvasSource.includes("TemperatureChartCanvasComponent"), "temperature chart canvas must be memoized so unrelated terminal state does not remount Recharts", ); + assert( + __shouldKeepTemperatureChartLoadingForTest({ + row: { city: "Moscow" } as any, + isHourlyLoading: false, + activeSeries: [], + probabilityOverlay: null, + zoomedData: [ + { label: "00:00", ts: 1 }, + { label: "05:00", ts: 2 }, + ], + }), + "temperature chart must keep loading instead of rendering an empty axis grid when no drawable series is available", + ); + assert( + !__shouldKeepTemperatureChartLoadingForTest({ + row: { city: "Moscow" } as any, + isHourlyLoading: false, + activeSeries: [ + { + key: "current", + label: "Current reference", + source: "Live", + color: "#009688", + values: [13, 13], + }, + ] as any, + probabilityOverlay: null, + zoomedData: [ + { label: "00:00", ts: 1, current: 13 }, + { label: "05:00", ts: 2, current: 13 }, + ], + }), + "temperature chart should render once a visible series has drawable values", + ); } diff --git a/frontend/lib/auth-profile-proxy.ts b/frontend/lib/auth-profile-proxy.ts new file mode 100644 index 00000000..6f0a2897 --- /dev/null +++ b/frontend/lib/auth-profile-proxy.ts @@ -0,0 +1,43 @@ +export type AuthProfileIdentity = { + email: string | null; + userId: string; +}; + +function extractErrorDetail(raw: string) { + const text = String(raw || "").trim(); + if (!text) return ""; + try { + const parsed = JSON.parse(text); + if (typeof parsed?.detail === "string") return parsed.detail; + if (typeof parsed?.error === "string") return parsed.error; + } catch {} + return text; +} + +export function isSubscriptionRequiredBackendResponse( + status: number, + raw: string, +) { + return ( + status === 403 && + extractErrorDetail(raw).trim().toLowerCase() === "subscription required" + ); +} + +export function buildSubscriptionRequiredAuthProfile( + identity: AuthProfileIdentity, +) { + return { + authenticated: true, + user_id: identity.userId, + email: identity.email, + subscription_active: false, + subscription_plan_code: null, + subscription_expires_at: null, + subscription_total_expires_at: null, + subscription_queued_days: 0, + subscription_queued_count: 0, + points: 0, + subscription_required: true, + }; +}