From e7eb895d4655dc46746c87a52b999c54a392863e Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 27 May 2026 00:08:59 -0400 Subject: [PATCH] fix redirect loop (again) (#227) --- .../features/auth/useHostedAuthRouteGuard.ts | 57 +++++++++++++++++++ .../onboarding/useOnboardingRedirect.ts | 5 +- src/lib/auth-redirect.test.ts | 13 +++++ src/lib/auth-redirect.ts | 10 ++++ src/routes/_app/route.tsx | 30 ++-------- src/routes/_auth.sign-up.tsx | 39 ++++++------- src/routes/_auth.tsx | 24 ++------ src/routes/_authenticated.tsx | 24 ++------ src/routes/_project/p/$projectId/route.tsx | 9 ++- src/routes/verify-email.tsx | 19 ++++++- 10 files changed, 137 insertions(+), 93 deletions(-) create mode 100644 src/client/features/auth/useHostedAuthRouteGuard.ts diff --git a/src/client/features/auth/useHostedAuthRouteGuard.ts b/src/client/features/auth/useHostedAuthRouteGuard.ts new file mode 100644 index 0000000..f7fb62e --- /dev/null +++ b/src/client/features/auth/useHostedAuthRouteGuard.ts @@ -0,0 +1,57 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { useSession } from "@/lib/auth-client"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { + getCurrentAuthRedirectFromHref, + getSignInSearch, + getVerifyEmailSearch, +} from "@/lib/auth-redirect"; + +export function useHostedAuthRouteGuard() { + const navigate = useNavigate(); + const { data: session, isPending } = useSession(); + const isHostedMode = isHostedClientAuthMode(); + + useEffect(() => { + if (isPending || !isHostedMode) { + return; + } + + const redirectTo = getCurrentAuthRedirectFromHref(window.location.href); + + if (!session?.user?.id) { + void navigate({ + to: "/sign-in", + search: getSignInSearch(redirectTo), + replace: true, + }); + return; + } + + if (!session.user.emailVerified) { + void navigate({ + to: "/verify-email", + search: getVerifyEmailSearch(session.user.email, redirectTo), + replace: true, + }); + } + }, [ + isPending, + isHostedMode, + session?.user?.email, + session?.user?.emailVerified, + session?.user?.id, + navigate, + ]); + + const hasVerifiedHostedSession = + !isPending && + Boolean(session?.user?.id) && + session?.user?.emailVerified === true; + + return { + isHostedMode, + canRenderAuthenticatedContent: !isHostedMode || hasVerifiedHostedSession, + }; +} diff --git a/src/client/features/onboarding/useOnboardingRedirect.ts b/src/client/features/onboarding/useOnboardingRedirect.ts index 5580981..fd80843 100644 --- a/src/client/features/onboarding/useOnboardingRedirect.ts +++ b/src/client/features/onboarding/useOnboardingRedirect.ts @@ -9,15 +9,17 @@ export function useOnboardingRedirect() { const navigate = useNavigate(); const { data: session } = useSession(); const isHostedMode = isHostedClientAuthMode(); + const isEmailVerified = session?.user?.emailVerified === true; const onboardingQuery = useQuery({ ...onboardingAnswersQueryOptions(), - enabled: isHostedMode && Boolean(session?.user?.id), + enabled: isHostedMode && Boolean(session?.user?.id) && isEmailVerified, }); useEffect(() => { if ( !isHostedMode || !session?.user?.id || + !isEmailVerified || onboardingQuery.isLoading || onboardingQuery.isError || onboardingQuery.data?.completedAt || @@ -33,6 +35,7 @@ export function useOnboardingRedirect() { onboardingQuery.data?.completedAt, onboardingQuery.isError, onboardingQuery.isLoading, + isEmailVerified, session?.user?.id, ]); } diff --git a/src/lib/auth-redirect.test.ts b/src/lib/auth-redirect.test.ts index a532d4f..859069a 100644 --- a/src/lib/auth-redirect.test.ts +++ b/src/lib/auth-redirect.test.ts @@ -5,6 +5,7 @@ import { getOAuthAuthorizeRedirectFromSearch, getOAuthSignedQuery, getSignInHref, + getVerifyEmailSearch, normalizeAuthRedirect, } from "./auth-redirect"; @@ -46,6 +47,18 @@ describe("auth redirect helpers", () => { ); }); + it("builds verify-email search params without a root redirect", () => { + expect(getVerifyEmailSearch("ben@example.com", "/")).toEqual({ + email: "ben@example.com", + }); + expect( + getVerifyEmailSearch("ben@example.com", "/onboarding?step=0"), + ).toEqual({ + email: "ben@example.com", + redirect: "/onboarding?step=0", + }); + }); + it("extracts the current path, query, and hash from hrefs", () => { expect( getCurrentAuthRedirectFromHref( diff --git a/src/lib/auth-redirect.ts b/src/lib/auth-redirect.ts index b5b5c8b..a36bb04 100644 --- a/src/lib/auth-redirect.ts +++ b/src/lib/auth-redirect.ts @@ -69,6 +69,16 @@ export function getSignInSearch(redirectTo: string) { return redirectTo === "/" ? {} : { redirect: redirectTo }; } +export function getVerifyEmailSearch( + email: string | undefined, + redirectTo: string, +) { + const search: { email?: string; redirect?: string } = {}; + if (email) search.email = email; + if (redirectTo !== "/") search.redirect = redirectTo; + return search; +} + export function getSignInHref(redirectTo: string) { const search = getSignInSearch(redirectTo); if (!("redirect" in search)) { diff --git a/src/routes/_app/route.tsx b/src/routes/_app/route.tsx index f32524f..15582ab 100644 --- a/src/routes/_app/route.tsx +++ b/src/routes/_app/route.tsx @@ -1,12 +1,6 @@ -import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; -import { useSession } from "@/lib/auth-client"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; -import { - getCurrentAuthRedirectFromHref, - getSignInSearch, -} from "@/lib/auth-redirect"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; export const Route = createFileRoute("/_app")({ @@ -14,26 +8,10 @@ export const Route = createFileRoute("/_app")({ }); function AppRouteLayout() { - const navigate = useNavigate(); - const { data: session, isPending } = useSession(); - const isHostedMode = isHostedClientAuthMode(); + const authGate = useHostedAuthRouteGuard(); useOnboardingRedirect(); - useEffect(() => { - if (isPending || !isHostedMode || session?.user?.id) { - return; - } - - void navigate({ - to: "/sign-in", - search: getSignInSearch( - getCurrentAuthRedirectFromHref(window.location.href), - ), - replace: true, - }); - }, [isPending, isHostedMode, session?.user?.id, navigate]); - - if (isHostedMode && (isPending || !session?.user?.id)) { + if (!authGate.canRenderAuthenticatedContent) { return null; } diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx index ac5c826..5f015f9 100644 --- a/src/routes/_auth.sign-up.tsx +++ b/src/routes/_auth.sign-up.tsx @@ -11,7 +11,7 @@ import { } from "@/client/features/auth/AuthPage"; import { captureClientEvent } from "@/client/lib/posthog"; import { authClient } from "@/lib/auth-client"; -import { getSignInSearch } from "@/lib/auth-redirect"; +import { getSignInSearch, getVerifyEmailSearch } from "@/lib/auth-redirect"; import { HOSTED_PASSWORD_MAX_LENGTH, HOSTED_PASSWORD_MIN_LENGTH, @@ -52,8 +52,6 @@ function SignUpPage() { const [showEmailForm, setShowEmailForm] = useState(false); const [isStartingGoogle, setIsStartingGoogle] = useState(false); const [socialError, setSocialError] = useState(null); - const bypassEmailVerification = - import.meta.env.BYPASS_EMAIL_VERIFICATION === "true"; const form = useForm({ defaultValues: { @@ -73,23 +71,25 @@ function SignUpPage() { }); const resolvedName = value.name.trim() || email.split("@")[0] || "OpenSEO User"; + const verificationCallbackURL = new URL( + "/verify-email", + window.location.origin, + ); + const verificationSearch = getVerifyEmailSearch( + undefined, + postSignupRedirect, + ); + if (verificationSearch.redirect) { + verificationCallbackURL.searchParams.set( + "redirect", + verificationSearch.redirect, + ); + } const result = await authClient.signUp.email({ name: resolvedName, email, password: value.password, - callbackURL: (() => { - if (bypassEmailVerification) { - return new URL( - postSignupRedirect, - window.location.origin, - ).toString(); - } - const url = new URL("/verify-email", window.location.origin); - if (postSignupRedirect !== "/") { - url.searchParams.set("redirect", postSignupRedirect); - } - return url.toString(); - })(), + callbackURL: verificationCallbackURL.toString(), }); if (result.error) { @@ -105,13 +105,10 @@ function SignUpPage() { captureClientEvent("auth:sign_up_success", { redirect_to: redirectTo, }); - if (bypassEmailVerification) { - window.location.replace(postSignupRedirect); - return; - } void navigate({ to: "/verify-email", - search: { email, ...getSignInSearch(postSignupRedirect) }, + search: getVerifyEmailSearch(email, postSignupRedirect), + replace: true, }); } catch { formApi.setErrorMap({ diff --git a/src/routes/_auth.tsx b/src/routes/_auth.tsx index 59e9765..fcbb468 100644 --- a/src/routes/_auth.tsx +++ b/src/routes/_auth.tsx @@ -25,27 +25,11 @@ function AuthPageLayout() { return; } - if (isHostedMode && !session.user.emailVerified) { - void navigate({ - to: "/verify-email", - search: { - email: session.user.email, - redirect: redirectTo, - }, - replace: true, - }); - return; - } - + // Already authenticated: hand off to the destination. If the user is + // unverified, that route's useHostedAuthRouteGuard bounces them to + // /verify-email — this layout doesn't duplicate that rule. void navigate({ href: redirectTo, replace: true }); - }, [ - isHostedMode, - navigate, - redirectTo, - session?.user?.email, - session?.user?.emailVerified, - session?.user?.id, - ]); + }, [navigate, redirectTo, session?.user?.id]); if (isHostedMode && (isPending || session?.user?.id)) { return null; diff --git a/src/routes/_authenticated.tsx b/src/routes/_authenticated.tsx index 503f6e7..32b8bb1 100644 --- a/src/routes/_authenticated.tsx +++ b/src/routes/_authenticated.tsx @@ -1,31 +1,15 @@ -import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; import { AuthPageShell } from "@/client/features/auth/AuthPage"; -import { useSession } from "@/lib/auth-client"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard"; export const Route = createFileRoute("/_authenticated")({ component: AuthenticatedShellLayout, }); function AuthenticatedShellLayout() { - const navigate = useNavigate(); - const { data: session, isPending } = useSession(); - const isHostedMode = isHostedClientAuthMode(); + const authGate = useHostedAuthRouteGuard(); - useEffect(() => { - if (isPending || !isHostedMode) return; - if (!session?.user?.id) { - void navigate({ - to: "/sign-in", - search: { - redirect: `${window.location.pathname}${window.location.search}`, - }, - }); - } - }, [isPending, isHostedMode, session?.user?.id, navigate]); - - if (!isHostedMode || isPending || !session?.user?.id) { + if (!authGate.isHostedMode || !authGate.canRenderAuthenticatedContent) { return null; } diff --git a/src/routes/_project/p/$projectId/route.tsx b/src/routes/_project/p/$projectId/route.tsx index 1b2d7fb..bd04235 100644 --- a/src/routes/_project/p/$projectId/route.tsx +++ b/src/routes/_project/p/$projectId/route.tsx @@ -1,9 +1,9 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard"; import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; import { getErrorCode } from "@/client/lib/error-messages"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getCurrentAuthRedirectFromHref, getSignInSearch, @@ -34,12 +34,17 @@ export const Route = createFileRoute("/_project/p/$projectId")({ function ProjectLayout() { const { projectId } = Route.useParams(); + const authGate = useHostedAuthRouteGuard(); useOnboardingRedirect(); + if (!authGate.canRenderAuthenticatedContent) { + return null; + } + return ( : undefined} + banner={authGate.isHostedMode ? : undefined} > diff --git a/src/routes/verify-email.tsx b/src/routes/verify-email.tsx index d1d3b3a..e139053 100644 --- a/src/routes/verify-email.tsx +++ b/src/routes/verify-email.tsx @@ -109,7 +109,11 @@ function VerifyEmailPage() { ? verificationIssueSchema.parse(search.error) : null; const email = search.email; - const isWaiting = !errorMessage && !session?.user?.emailVerified && !!email; + const isWaiting = + !errorMessage && + !bypassEmailVerification && + !session?.user?.emailVerified && + !!email; const [isResending, setIsResending] = useState(false); const isVerified = !!session?.user?.emailVerified; const pageCopy = getVerifyEmailPageCopy({ @@ -122,7 +126,10 @@ function VerifyEmailPage() { }); useEffect(() => { - if (!isVerified && !bypassEmailVerification) { + if ( + isPending || + (!isVerified && !(bypassEmailVerification && session?.user?.id)) + ) { return; } @@ -138,7 +145,13 @@ function VerifyEmailPage() { // hit the server before updated handlers are ready, causing // "action is not a function" errors). window.location.replace(redirectTo); - }, [bypassEmailVerification, isVerified, redirectTo]); + }, [ + bypassEmailVerification, + isPending, + isVerified, + redirectTo, + session?.user?.id, + ]); useEffect(() => { if (!verificationIssueType) {