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