fix redirect loop (again) (#227)

This commit is contained in:
Ben Senescu 2026-05-27 00:08:59 -04:00 committed by GitHub
parent 4a5a1eb523
commit e7eb895d46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 137 additions and 93 deletions

View File

@ -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,
};
}

View File

@ -9,15 +9,17 @@ export function useOnboardingRedirect() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: session } = useSession(); const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
const isEmailVerified = session?.user?.emailVerified === true;
const onboardingQuery = useQuery({ const onboardingQuery = useQuery({
...onboardingAnswersQueryOptions(), ...onboardingAnswersQueryOptions(),
enabled: isHostedMode && Boolean(session?.user?.id), enabled: isHostedMode && Boolean(session?.user?.id) && isEmailVerified,
}); });
useEffect(() => { useEffect(() => {
if ( if (
!isHostedMode || !isHostedMode ||
!session?.user?.id || !session?.user?.id ||
!isEmailVerified ||
onboardingQuery.isLoading || onboardingQuery.isLoading ||
onboardingQuery.isError || onboardingQuery.isError ||
onboardingQuery.data?.completedAt || onboardingQuery.data?.completedAt ||
@ -33,6 +35,7 @@ export function useOnboardingRedirect() {
onboardingQuery.data?.completedAt, onboardingQuery.data?.completedAt,
onboardingQuery.isError, onboardingQuery.isError,
onboardingQuery.isLoading, onboardingQuery.isLoading,
isEmailVerified,
session?.user?.id, session?.user?.id,
]); ]);
} }

View File

@ -5,6 +5,7 @@ import {
getOAuthAuthorizeRedirectFromSearch, getOAuthAuthorizeRedirectFromSearch,
getOAuthSignedQuery, getOAuthSignedQuery,
getSignInHref, getSignInHref,
getVerifyEmailSearch,
normalizeAuthRedirect, normalizeAuthRedirect,
} from "./auth-redirect"; } 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", () => { it("extracts the current path, query, and hash from hrefs", () => {
expect( expect(
getCurrentAuthRedirectFromHref( getCurrentAuthRedirectFromHref(

View File

@ -69,6 +69,16 @@ export function getSignInSearch(redirectTo: string) {
return redirectTo === "/" ? {} : { redirect: redirectTo }; 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) { export function getSignInHref(redirectTo: string) {
const search = getSignInSearch(redirectTo); const search = getSignInSearch(redirectTo);
if (!("redirect" in search)) { if (!("redirect" in search)) {

View File

@ -1,12 +1,6 @@
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react"; import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; 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"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
export const Route = createFileRoute("/_app")({ export const Route = createFileRoute("/_app")({
@ -14,26 +8,10 @@ export const Route = createFileRoute("/_app")({
}); });
function AppRouteLayout() { function AppRouteLayout() {
const navigate = useNavigate(); const authGate = useHostedAuthRouteGuard();
const { data: session, isPending } = useSession();
const isHostedMode = isHostedClientAuthMode();
useOnboardingRedirect(); useOnboardingRedirect();
useEffect(() => { if (!authGate.canRenderAuthenticatedContent) {
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)) {
return null; return null;
} }

View File

@ -11,7 +11,7 @@ import {
} from "@/client/features/auth/AuthPage"; } from "@/client/features/auth/AuthPage";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { getSignInSearch } from "@/lib/auth-redirect"; import { getSignInSearch, getVerifyEmailSearch } from "@/lib/auth-redirect";
import { import {
HOSTED_PASSWORD_MAX_LENGTH, HOSTED_PASSWORD_MAX_LENGTH,
HOSTED_PASSWORD_MIN_LENGTH, HOSTED_PASSWORD_MIN_LENGTH,
@ -52,8 +52,6 @@ function SignUpPage() {
const [showEmailForm, setShowEmailForm] = useState(false); const [showEmailForm, setShowEmailForm] = useState(false);
const [isStartingGoogle, setIsStartingGoogle] = useState(false); const [isStartingGoogle, setIsStartingGoogle] = useState(false);
const [socialError, setSocialError] = useState<string | null>(null); const [socialError, setSocialError] = useState<string | null>(null);
const bypassEmailVerification =
import.meta.env.BYPASS_EMAIL_VERIFICATION === "true";
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@ -73,23 +71,25 @@ function SignUpPage() {
}); });
const resolvedName = const resolvedName =
value.name.trim() || email.split("@")[0] || "OpenSEO User"; 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({ const result = await authClient.signUp.email({
name: resolvedName, name: resolvedName,
email, email,
password: value.password, password: value.password,
callbackURL: (() => { callbackURL: verificationCallbackURL.toString(),
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();
})(),
}); });
if (result.error) { if (result.error) {
@ -105,13 +105,10 @@ function SignUpPage() {
captureClientEvent("auth:sign_up_success", { captureClientEvent("auth:sign_up_success", {
redirect_to: redirectTo, redirect_to: redirectTo,
}); });
if (bypassEmailVerification) {
window.location.replace(postSignupRedirect);
return;
}
void navigate({ void navigate({
to: "/verify-email", to: "/verify-email",
search: { email, ...getSignInSearch(postSignupRedirect) }, search: getVerifyEmailSearch(email, postSignupRedirect),
replace: true,
}); });
} catch { } catch {
formApi.setErrorMap({ formApi.setErrorMap({

View File

@ -25,27 +25,11 @@ function AuthPageLayout() {
return; return;
} }
if (isHostedMode && !session.user.emailVerified) { // Already authenticated: hand off to the destination. If the user is
void navigate({ // unverified, that route's useHostedAuthRouteGuard bounces them to
to: "/verify-email", // /verify-email — this layout doesn't duplicate that rule.
search: {
email: session.user.email,
redirect: redirectTo,
},
replace: true,
});
return;
}
void navigate({ href: redirectTo, replace: true }); void navigate({ href: redirectTo, replace: true });
}, [ }, [navigate, redirectTo, session?.user?.id]);
isHostedMode,
navigate,
redirectTo,
session?.user?.email,
session?.user?.emailVerified,
session?.user?.id,
]);
if (isHostedMode && (isPending || session?.user?.id)) { if (isHostedMode && (isPending || session?.user?.id)) {
return null; return null;

View File

@ -1,31 +1,15 @@
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react";
import { AuthPageShell } from "@/client/features/auth/AuthPage"; import { AuthPageShell } from "@/client/features/auth/AuthPage";
import { useSession } from "@/lib/auth-client"; import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
export const Route = createFileRoute("/_authenticated")({ export const Route = createFileRoute("/_authenticated")({
component: AuthenticatedShellLayout, component: AuthenticatedShellLayout,
}); });
function AuthenticatedShellLayout() { function AuthenticatedShellLayout() {
const navigate = useNavigate(); const authGate = useHostedAuthRouteGuard();
const { data: session, isPending } = useSession();
const isHostedMode = isHostedClientAuthMode();
useEffect(() => { if (!authGate.isHostedMode || !authGate.canRenderAuthenticatedContent) {
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) {
return null; return null;
} }

View File

@ -1,9 +1,9 @@
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner"; import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
import { getErrorCode } from "@/client/lib/error-messages"; import { getErrorCode } from "@/client/lib/error-messages";
import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { import {
getCurrentAuthRedirectFromHref, getCurrentAuthRedirectFromHref,
getSignInSearch, getSignInSearch,
@ -34,12 +34,17 @@ export const Route = createFileRoute("/_project/p/$projectId")({
function ProjectLayout() { function ProjectLayout() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const authGate = useHostedAuthRouteGuard();
useOnboardingRedirect(); useOnboardingRedirect();
if (!authGate.canRenderAuthenticatedContent) {
return null;
}
return ( return (
<AuthenticatedAppLayout <AuthenticatedAppLayout
projectId={projectId} projectId={projectId}
banner={isHostedClientAuthMode() ? <FreePlanBanner /> : undefined} banner={authGate.isHostedMode ? <FreePlanBanner /> : undefined}
> >
<Outlet /> <Outlet />
</AuthenticatedAppLayout> </AuthenticatedAppLayout>

View File

@ -109,7 +109,11 @@ function VerifyEmailPage() {
? verificationIssueSchema.parse(search.error) ? verificationIssueSchema.parse(search.error)
: null; : null;
const email = search.email; 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 [isResending, setIsResending] = useState(false);
const isVerified = !!session?.user?.emailVerified; const isVerified = !!session?.user?.emailVerified;
const pageCopy = getVerifyEmailPageCopy({ const pageCopy = getVerifyEmailPageCopy({
@ -122,7 +126,10 @@ function VerifyEmailPage() {
}); });
useEffect(() => { useEffect(() => {
if (!isVerified && !bypassEmailVerification) { if (
isPending ||
(!isVerified && !(bypassEmailVerification && session?.user?.id))
) {
return; return;
} }
@ -138,7 +145,13 @@ function VerifyEmailPage() {
// hit the server before updated handlers are ready, causing // hit the server before updated handlers are ready, causing
// "action is not a function" errors). // "action is not a function" errors).
window.location.replace(redirectTo); window.location.replace(redirectTo);
}, [bypassEmailVerification, isVerified, redirectTo]); }, [
bypassEmailVerification,
isPending,
isVerified,
redirectTo,
session?.user?.id,
]);
useEffect(() => { useEffect(() => {
if (!verificationIssueType) { if (!verificationIssueType) {