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 { 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,
]);
}

View File

@ -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(

View File

@ -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)) {

View File

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

View File

@ -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<string | null>(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({

View File

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

View File

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

View File

@ -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 (
<AuthenticatedAppLayout
projectId={projectId}
banner={isHostedClientAuthMode() ? <FreePlanBanner /> : undefined}
banner={authGate.isHostedMode ? <FreePlanBanner /> : undefined}
>
<Outlet />
</AuthenticatedAppLayout>

View File

@ -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) {