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 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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user