Redirect terminal non-members to checkout after login
This commit is contained in:
@@ -4,6 +4,7 @@ import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/
|
|||||||
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
||||||
|
|
||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
const TERMINAL_CHECKOUT_PATH = "/account?checkout=1";
|
||||||
|
|
||||||
function normalizeNextPath(input: string | null) {
|
function normalizeNextPath(input: string | null) {
|
||||||
const fallback = "/";
|
const fallback = "/";
|
||||||
@@ -23,6 +24,14 @@ function normalizeAuthError(input: string | null) {
|
|||||||
return raw.slice(0, 240);
|
return raw.slice(0, 240);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTerminalNextPath(pathname: string) {
|
||||||
|
return pathname === "/terminal" || pathname.startsWith("/terminal/");
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthProfilePayload = {
|
||||||
|
subscription_active?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
function redirectToLoginWithError({
|
function redirectToLoginWithError({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
error,
|
error,
|
||||||
@@ -39,9 +48,9 @@ function redirectToLoginWithError({
|
|||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function warmSignupTrial(accessToken: string) {
|
async function warmSignupTrial(accessToken: string): Promise<AuthProfilePayload | null> {
|
||||||
const token = String(accessToken || "").trim();
|
const token = String(accessToken || "").trim();
|
||||||
if (!API_BASE || !token) return;
|
if (!API_BASE || !token) return null;
|
||||||
|
|
||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
@@ -55,18 +64,42 @@ async function warmSignupTrial(accessToken: string) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 2500);
|
const timeoutId = setTimeout(() => controller.abort(), 2500);
|
||||||
try {
|
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",
|
cache: "no-store",
|
||||||
headers,
|
headers,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return { subscription_active: false };
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
// The account/terminal bootstrap will retry. Callback must not strand login.
|
// The account/terminal bootstrap will retry. Callback must not strand login.
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const siteUrl = getConfiguredSiteUrl();
|
const siteUrl = getConfiguredSiteUrl();
|
||||||
if (siteUrl) {
|
if (siteUrl) {
|
||||||
@@ -122,7 +155,12 @@ export async function GET(request: NextRequest) {
|
|||||||
nextPath,
|
nextPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await warmSignupTrial(session?.access_token || "");
|
const finalRedirectUrl = await resolvePostAuthRedirect({
|
||||||
|
accessToken: session.access_token,
|
||||||
|
baseUrl,
|
||||||
|
nextPath,
|
||||||
|
});
|
||||||
|
response.headers.set("Location", finalRedirectUrl.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -27,6 +27,40 @@ type LoginClientProps = {
|
|||||||
initialMode?: Mode;
|
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) {
|
export function LoginClient({ nextPath, initialError, initialMode }: LoginClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
@@ -214,7 +248,7 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
|
|||||||
try {
|
try {
|
||||||
const supabase = getSupabaseBrowserClient();
|
const supabase = getSupabaseBrowserClient();
|
||||||
if (mode === "login") {
|
if (mode === "login") {
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
@@ -222,7 +256,11 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
|
|||||||
setErrorText(error.message);
|
setErrorText(error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(nextPath);
|
const redirectPath = await resolvePostLoginRedirect({
|
||||||
|
accessToken: data.session?.access_token,
|
||||||
|
nextPath,
|
||||||
|
});
|
||||||
|
router.replace(redirectPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +279,11 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.session?.user) {
|
if (data.session?.user) {
|
||||||
router.replace(nextPath);
|
const redirectPath = await resolvePostLoginRedirect({
|
||||||
|
accessToken: data.session.access_token,
|
||||||
|
nextPath,
|
||||||
|
});
|
||||||
|
router.replace(redirectPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setInfoText(copy.signupCheckEmail);
|
setInfoText(copy.signupCheckEmail);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user