fix: preserve terminal access during auth sync

This commit is contained in:
2569718930@qq.com
2026-05-28 12:18:43 +08:00
parent eef611adb4
commit 784b27a954
5 changed files with 194 additions and 65 deletions
+17
View File
@@ -47,6 +47,23 @@ export async function GET(req: NextRequest) {
} finally { } finally {
clearTimeout(timeoutId); 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) { if (res.status === 401 || res.status === 403) {
const response = NextResponse.json({ const response = NextResponse.json({
authenticated: false, authenticated: false,
@@ -88,6 +88,10 @@ export function runTests() {
), ),
"utf8", "utf8",
); );
const authMeRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "auth", "me", "route.ts"),
"utf8",
);
const subscriptionsPageSource = fs.readFileSync( const subscriptionsPageSource = fs.readFileSync(
path.join( path.join(
projectRoot, projectRoot,
@@ -167,4 +171,10 @@ export function runTests() {
grantRouteSource.includes('"status": "active"'), grantRouteSource.includes('"status": "active"'),
"ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing", "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",
);
} }
@@ -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 { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown"; import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown";
import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector"; import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector";
import {
mergeAccessStateWithAuthPayload,
type AuthProfilePayload,
} from "@/components/dashboard/scan-terminal/terminal-access-state";
import { import {
cityListItemsToScanRows, cityListItemsToScanRows,
mergeScanRowsWithCityFallbackRows, mergeScanRowsWithCityFallbackRows,
@@ -921,6 +925,21 @@ function ScanTerminalScreen() {
createEmptyAccess(true), createEmptyAccess(true),
); );
const loadAuthProfile = useCallback(
async (accessToken?: string | null): Promise<AuthProfilePayload> => {
const headers: Record<string, string> = { 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<AuthProfilePayload>;
},
[],
);
// Listen to Supabase auth events (e.g. token refreshed, signed out) // Listen to Supabase auth events (e.g. token refreshed, signed out)
useEffect(() => { useEffect(() => {
if (!hasSupabasePublicEnv()) return; if (!hasSupabasePublicEnv()) return;
@@ -933,31 +952,8 @@ function ScanTerminalScreen() {
setProAccess(createEmptyAccess(false)); setProAccess(createEmptyAccess(false));
} else if (event === "TOKEN_REFRESHED" || event === "SIGNED_IN") { } else if (event === "TOKEN_REFRESHED" || event === "SIGNED_IN") {
try { try {
const response = await fetch("/api/auth/me", { const payload = await loadAuthProfile(session?.access_token);
cache: "no-store", setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
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,
});
}
} catch {} } catch {}
} }
}); });
@@ -1034,55 +1030,28 @@ function ScanTerminalScreen() {
cancelled = true; cancelled = true;
}; };
} }
fetch("/api/auth/me", { const sessionPromise = hasSupabasePublicEnv()
cache: "no-store", ? getSupabaseBrowserClient().auth.getSession()
headers: { Accept: "application/json" }, : Promise.resolve({ data: { session: null } });
})
.then(async (response) => { sessionPromise
if (!response.ok) throw new Error(`HTTP ${response.status}`); .then(({ data: { session } }) => loadAuthProfile(session?.access_token))
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;
}>;
})
.then((payload) => { .then((payload) => {
if (cancelled) return; if (cancelled) return;
setProAccess({ setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
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,
});
}) })
.catch((error) => { .catch((error) => {
if (cancelled) return; if (cancelled) return;
setProAccess({ setProAccess((prev) => (
...createEmptyAccess(false), prev.subscriptionActive
error: String(error), ? { ...prev, loading: false, error: String(error) }
}); : { ...createEmptyAccess(false), error: String(error) }
));
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [loadAuthProfile]);
useEffect(() => { useEffect(() => {
setSelectedRegionKey("all"); setSelectedRegionKey("all");
@@ -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",
);
}
@@ -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,
};
}