From a809faace763816bd575af4fd4d67a0f7baf7bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Martin?= Date: Mon, 2 Mar 2026 14:41:29 +0100 Subject: [PATCH] fix(auth): handle login redirects for search urls Use full href-based post-login navigation and sanitize redirect params so unauthenticated search redirects reliably land on login and return to the original search URL without 404 loops. Made-with: Cursor --- src/routes/_authed.tsx | 2 +- src/routes/login.tsx | 19 +++++++++++++++- src/routes/login/-components/login-form.tsx | 24 ++++++++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/routes/_authed.tsx b/src/routes/_authed.tsx index 2b00f90..84df1f0 100644 --- a/src/routes/_authed.tsx +++ b/src/routes/_authed.tsx @@ -36,7 +36,7 @@ export const Route = createFileRoute("/_authed")({ if (!session) { throw redirect({ to: "/login", - search: { redirect: location.pathname + location.searchStr }, + search: { redirect: location.href }, }); } diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 69986c2..4b06985 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -5,6 +5,23 @@ import { getSetupStatus } from "@/server/infrastructure/functions/setup"; const rootRoute = getRouteApi("__root__"); +function getSafeRedirect({ + redirect, +}: { + redirect: unknown; +}): string | undefined { + if (typeof redirect !== "string") { + return undefined; + } + + // Only allow in-app absolute paths. + if (!redirect.startsWith("/") || redirect.startsWith("//")) { + return undefined; + } + + return redirect; +} + export const Route = createFileRoute("/login")({ loader: async () => { const { setupRequired } = await getSetupStatus(); @@ -16,7 +33,7 @@ export const Route = createFileRoute("/login")({ return { setupComplete: true }; }, validateSearch: (search: Record) => ({ - redirect: typeof search.redirect === "string" ? search.redirect : undefined, + redirect: getSafeRedirect({ redirect: search.redirect }), }), head: () => ({ meta: [{ title: "Login" }], diff --git a/src/routes/login/-components/login-form.tsx b/src/routes/login/-components/login-form.tsx index 30fea44..022b314 100644 --- a/src/routes/login/-components/login-form.tsx +++ b/src/routes/login/-components/login-form.tsx @@ -1,4 +1,5 @@ import { useForm } from "@tanstack/react-form"; +import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useId, useState } from "react"; import { z } from "zod"; @@ -11,6 +12,7 @@ import { } from "@/client/components/ui/field"; import { Input } from "@/client/components/ui/input"; import { cn } from "@/client/utils"; +import { sessionQueryOptions } from "@/routes/_authed"; import { authClient } from "@/server/infrastructure/auth/client"; const loginSchema = z.object({ @@ -22,8 +24,21 @@ interface LoginFormProps extends React.ComponentProps<"form"> { redirectTo?: string; } +function getLoginRedirectHref({ redirectTo }: { redirectTo?: string }): string { + if (!redirectTo) { + return "/"; + } + + if (!redirectTo.startsWith("/") || redirectTo.startsWith("//")) { + return "/"; + } + + return redirectTo; +} + export function LoginForm({ className, redirectTo, ...props }: LoginFormProps) { const navigate = useNavigate(); + const queryClient = useQueryClient(); const [error, setError] = useState(null); const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); const emailId = useId(); @@ -49,7 +64,14 @@ export function LoginForm({ className, redirectTo, ...props }: LoginFormProps) { return; } - navigate({ to: redirectTo ?? "/", replace: true }); + await queryClient.invalidateQueries({ + queryKey: sessionQueryOptions.queryKey, + }); + + navigate({ + href: getLoginRedirectHref({ redirectTo }), + replace: true, + }); }, });