fix redirect loop (again) (#227)
This commit is contained in:
parent
4a5a1eb523
commit
e7eb895d46
57
src/client/features/auth/useHostedAuthRouteGuard.ts
Normal file
57
src/client/features/auth/useHostedAuthRouteGuard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user