diff --git a/frontend/app/auth/callback/route.ts b/frontend/app/auth/callback/route.ts index fb8cab07..ff8c14ba 100644 --- a/frontend/app/auth/callback/route.ts +++ b/frontend/app/auth/callback/route.ts @@ -4,6 +4,7 @@ import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/ import { getConfiguredSiteUrl } from "@/lib/site-url"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +const TERMINAL_CHECKOUT_PATH = "/account?checkout=1"; function normalizeNextPath(input: string | null) { const fallback = "/"; @@ -23,6 +24,14 @@ function normalizeAuthError(input: string | null) { return raw.slice(0, 240); } +function isTerminalNextPath(pathname: string) { + return pathname === "/terminal" || pathname.startsWith("/terminal/"); +} + +type AuthProfilePayload = { + subscription_active?: boolean | null; +}; + function redirectToLoginWithError({ baseUrl, error, @@ -39,9 +48,9 @@ function redirectToLoginWithError({ return NextResponse.redirect(loginUrl); } -async function warmSignupTrial(accessToken: string) { +async function warmSignupTrial(accessToken: string): Promise { const token = String(accessToken || "").trim(); - if (!API_BASE || !token) return; + if (!API_BASE || !token) return null; const headers = new Headers({ Accept: "application/json", @@ -55,18 +64,42 @@ async function warmSignupTrial(accessToken: string) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 2500); try { - await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, { + const response = await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, { cache: "no-store", headers, signal: controller.signal, }); + if (!response.ok) { + return { subscription_active: false }; + } + return await response.json(); } catch { // The account/terminal bootstrap will retry. Callback must not strand login. + return null; } finally { clearTimeout(timeoutId); } } +async function resolvePostAuthRedirect({ + accessToken, + baseUrl, + nextPath, +}: { + accessToken: string; + baseUrl: string; + nextPath: string; +}) { + const profile = await warmSignupTrial(accessToken); + if (!isTerminalNextPath(nextPath)) { + return new URL(nextPath, baseUrl); + } + + const redirectPath = + profile?.subscription_active === false ? TERMINAL_CHECKOUT_PATH : nextPath; + return new URL(redirectPath, baseUrl); +} + export async function GET(request: NextRequest) { const siteUrl = getConfiguredSiteUrl(); if (siteUrl) { @@ -122,7 +155,12 @@ export async function GET(request: NextRequest) { nextPath, }); } - await warmSignupTrial(session?.access_token || ""); + const finalRedirectUrl = await resolvePostAuthRedirect({ + accessToken: session.access_token, + baseUrl, + nextPath, + }); + response.headers.set("Location", finalRedirectUrl.toString()); } return response; diff --git a/frontend/components/auth/LoginClient.tsx b/frontend/components/auth/LoginClient.tsx index a8e71dca..bddbc698 100644 --- a/frontend/components/auth/LoginClient.tsx +++ b/frontend/components/auth/LoginClient.tsx @@ -27,6 +27,40 @@ type LoginClientProps = { initialMode?: Mode; }; +const TERMINAL_CHECKOUT_PATH = "/account?checkout=1"; + +function isTerminalNextPath(pathname: string) { + return pathname === "/terminal" || pathname.startsWith("/terminal/"); +} + +async function resolvePostLoginRedirect({ + accessToken, + nextPath, +}: { + accessToken?: string | null; + nextPath: string; +}) { + if (!isTerminalNextPath(nextPath)) return nextPath; + const token = String(accessToken || "").trim(); + if (!token || typeof fetch !== "function") return TERMINAL_CHECKOUT_PATH; + try { + const response = await fetch("/api/auth/me?scope=entitlement", { + cache: "no-store", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) return nextPath; + const payload = await response.json(); + return payload?.subscription_active === false + ? TERMINAL_CHECKOUT_PATH + : nextPath; + } catch { + return nextPath; + } +} + export function LoginClient({ nextPath, initialError, initialMode }: LoginClientProps) { const router = useRouter(); const { locale } = useI18n(); @@ -214,7 +248,7 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient try { const supabase = getSupabaseBrowserClient(); if (mode === "login") { - const { error } = await supabase.auth.signInWithPassword({ + const { data, error } = await supabase.auth.signInWithPassword({ email: email.trim(), password, }); @@ -222,7 +256,11 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient setErrorText(error.message); return; } - router.replace(nextPath); + const redirectPath = await resolvePostLoginRedirect({ + accessToken: data.session?.access_token, + nextPath, + }); + router.replace(redirectPath); return; } @@ -241,7 +279,11 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient return; } if (data.session?.user) { - router.replace(nextPath); + const redirectPath = await resolvePostLoginRedirect({ + accessToken: data.session.access_token, + nextPath, + }); + router.replace(redirectPath); return; } setInfoText(copy.signupCheckEmail); diff --git a/frontend/components/auth/__tests__/terminalCheckoutRedirect.test.ts b/frontend/components/auth/__tests__/terminalCheckoutRedirect.test.ts new file mode 100644 index 00000000..40eaa53f --- /dev/null +++ b/frontend/components/auth/__tests__/terminalCheckoutRedirect.test.ts @@ -0,0 +1,38 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const loginClientSource = fs.readFileSync( + path.join(projectRoot, "components", "auth", "LoginClient.tsx"), + "utf8", + ); + const authCallbackSource = fs.readFileSync( + path.join(projectRoot, "app", "auth", "callback", "route.ts"), + "utf8", + ); + + assert( + loginClientSource.includes("TERMINAL_CHECKOUT_PATH") && + loginClientSource.includes('"/account?checkout=1"') && + loginClientSource.includes("resolvePostLoginRedirect") && + loginClientSource.includes("subscription_active === false") && + loginClientSource.includes("router.replace(redirectPath)") && + !loginClientSource.includes("router.replace(nextPath);\n return;"), + "email/password terminal login must send non-members to the checkout account page instead of blindly returning to /terminal", + ); + + assert( + authCallbackSource.includes("resolvePostAuthRedirect") && + authCallbackSource.includes("TERMINAL_CHECKOUT_PATH") && + authCallbackSource.includes('"/account?checkout=1"') && + authCallbackSource.includes("subscription_active === false") && + authCallbackSource.includes("NextResponse.redirect(redirectUrl)") && + authCallbackSource.includes("await resolvePostAuthRedirect"), + "OAuth terminal callback must send non-members to the checkout account page instead of blindly returning to /terminal", + ); +}