fix: preserve terminal access during auth sync
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user