diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51798fe53..3df104d8a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+- Preserved search and Ask URLs through login redirects. [#1650](https://github.com/sourcebot-dev/sourcebot/pull/1650)
+
## [5.1.12] - 2026-09-10
### Changed
diff --git a/packages/web/src/app/(app)/chat/components/shareChatPopover/shareSettings.tsx b/packages/web/src/app/(app)/chat/components/shareChatPopover/shareSettings.tsx
index 9dd0a6668..9bccbc315 100644
--- a/packages/web/src/app/(app)/chat/components/shareChatPopover/shareSettings.tsx
+++ b/packages/web/src/app/(app)/chat/components/shareChatPopover/shareSettings.tsx
@@ -17,7 +17,8 @@ import { ChatVisibility } from "@sourcebot/db";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Info, Link2Icon, Loader2, Lock, X } from "lucide-react";
import Link from "next/link";
-import { usePathname } from "next/navigation";
+import { usePathname, useSearchParams } from "next/navigation";
+import { createLoginUrl } from "@/lib/authRedirect";
import { useCallback, useState } from "react";
import { captureEvent } from "@/hooks/useCaptureEvent";
@@ -48,6 +49,8 @@ export const ShareSettings = ({
const [removingUserIds, setRemovingUserIds] = useState>(new Set());
const { toast } = useToast();
const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const loginHref = createLoginUrl(`${pathname}?${searchParams.toString()}`);
const isAuthenticated = !!currentUser;
const handleCopyLink = useCallback(async () => {
@@ -217,7 +220,7 @@ export const ShareSettings = ({
{!isAuthenticated && (
- Sign in to change chat visibility.
+ Sign in to change chat visibility.
)}
diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx
index 435678ef5..fa0d3762e 100644
--- a/packages/web/src/app/(app)/layout.tsx
+++ b/packages/web/src/app/(app)/layout.tsx
@@ -38,6 +38,7 @@ import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server";
import { NavigationGuardProvider } from "next-navigation-guard";
import { getRepositorySyncCounts } from "@/features/repos/repositorySyncCounts.server";
import { getConnectionSyncCounts } from "@/features/connections/connectionSyncCounts.server";
+import { createLoginUrl, normalizeCallbackUrl, REQUEST_PATH_HEADER } from "@/lib/authRedirect";
interface LayoutProps {
children: React.ReactNode;
@@ -60,6 +61,7 @@ export default async function Layout(props: LayoutProps) {
const session = await auth();
const anonymousAccessEnabled = await isAnonymousAccessEnabled();
+ const requestHeaders = await headers();
let role: OrgRole | null = null;
@@ -111,9 +113,9 @@ export default async function Layout(props: LayoutProps) {
if (!anonymousAccessEnabled) {
const ssoEntitlement = await hasEntitlement("sso");
if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
- return ;
+ return ;
} else {
- redirect('/login');
+ redirect(createLoginUrl(requestHeaders.get(REQUEST_PATH_HEADER)));
}
}
}
@@ -144,16 +146,18 @@ export default async function Layout(props: LayoutProps) {
return (
-
+
)
}
}
}
- const headersList = await headers();
const cookieStore = await cookies()
- const userAgent = headersList.get('user-agent');
+ const userAgent = requestHeaders.get('user-agent');
const { isMobile } = userAgent ? getSelectorsByUserAgent(userAgent) : { isMobile: false };
if (isMobile && !cookieStore.has(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME)) {
diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx
index d285b8ca9..de2b37b29 100644
--- a/packages/web/src/app/components/authMethodSelector.tsx
+++ b/packages/web/src/app/components/authMethodSelector.tsx
@@ -10,6 +10,7 @@ import { ProviderButton } from "@/app/components/providerButton";
import { AuthSecurityNotice } from "@/app/components/authSecurityNotice";
import Link from "next/link";
import { useIdentityProviders } from "@/features/auth/useIdentityProviders";
+import { normalizeCallbackUrl } from "@/lib/authRedirect";
interface AuthMethodSelectorProps {
callbackUrl?: string;
@@ -27,6 +28,7 @@ export const AuthMethodSelector = ({
hideSecurityNotice = false
}: AuthMethodSelectorProps) => {
const providers = useIdentityProviders();
+ const safeCallbackUrl = normalizeCallbackUrl(callbackUrl);
const onSignInWithOauth = useCallback((provider: string) => {
// Call the optional analytics callback first
@@ -35,10 +37,10 @@ export const AuthMethodSelector = ({
signIn(
provider,
{
- redirectTo: callbackUrl ?? "/",
+ redirectTo: safeCallbackUrl,
}
);
- }, [callbackUrl, onProviderClick]);
+ }, [onProviderClick, safeCallbackUrl]);
// Separate OAuth providers from special auth methods
const oauthProviders = providers.filter(p => p.purpose === "sso" &&
@@ -80,13 +82,13 @@ export const AuthMethodSelector = ({
] : []),
...(hasMagicLink ? [
-
+
] : []),
...(hasCredentials ? [
-
+
] : [])
]}
/>
>
);
-};
\ No newline at end of file
+};
diff --git a/packages/web/src/app/login/components/loginForm.tsx b/packages/web/src/app/login/components/loginForm.tsx
index 6274ab34e..a29910f7f 100644
--- a/packages/web/src/app/login/components/loginForm.tsx
+++ b/packages/web/src/app/login/components/loginForm.tsx
@@ -7,6 +7,7 @@ import { AuthMethodSelector } from "@/app/components/authMethodSelector";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useIdentityProviders } from "@/features/auth/useIdentityProviders";
import Link from "next/link";
+import { normalizeCallbackUrl } from "@/lib/authRedirect";
interface LoginFormProps {
callbackUrl?: string;
@@ -20,14 +21,7 @@ export const LoginForm = ({ callbackUrl, error, context, isAnonymousAccessEnable
const captureEvent = useCaptureEvent();
const providers = useIdentityProviders();
- const safeCallbackUrl = useMemo(() => {
- if (!callbackUrl) return "/";
- // Allow only relative paths that start with "/" but not "//" (protocol-relative URLs)
- if (callbackUrl.startsWith("/") && !callbackUrl.startsWith("//")) {
- return callbackUrl;
- }
- return "/";
- }, [callbackUrl]);
+ const safeCallbackUrl = useMemo(() => normalizeCallbackUrl(callbackUrl), [callbackUrl]);
const errorMessage = useMemo(() => {
if (!error) {
@@ -90,7 +84,7 @@ export const LoginForm = ({ callbackUrl, error, context, isAnonymousAccessEnable
)}
{context === "login" ?
<>
- Don't have an account? Sign up
+ Don't have an account? Sign up
>
:
<>
- Already have an account? Sign in
+ Already have an account? Sign in
>
}
diff --git a/packages/web/src/app/login/components/magicLinkForm.tsx b/packages/web/src/app/login/components/magicLinkForm.tsx
index d731436a2..83cc455ae 100644
--- a/packages/web/src/app/login/components/magicLinkForm.tsx
+++ b/packages/web/src/app/login/components/magicLinkForm.tsx
@@ -11,6 +11,7 @@ import { useState } from "react";
import { Loader2 } from "lucide-react";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useRouter } from "next/navigation";
+import { normalizeCallbackUrl } from "@/lib/authRedirect";
const magicLinkSchema = z.object({
email: z.string().email(),
@@ -25,6 +26,7 @@ export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => {
const captureEvent = useCaptureEvent();
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
+ const safeCallbackUrl = normalizeCallbackUrl(callbackUrl);
const magicLinkForm = useForm>({
resolver: zodResolver(magicLinkSchema),
@@ -37,11 +39,11 @@ export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => {
setIsLoading(true);
captureEvent("wa_login_with_magic_link", {});
- signIn("nodemailer", { email: values.email, redirect: false, redirectTo: callbackUrl ?? "/" })
+ signIn("nodemailer", { email: values.email, redirect: false, redirectTo: safeCallbackUrl })
.then(() => {
setIsLoading(false);
- router.push("/login/verify?email=" + encodeURIComponent(values.email));
+ router.push(`/login/verify?email=${encodeURIComponent(values.email)}&callbackUrl=${encodeURIComponent(safeCallbackUrl)}`);
})
.catch((error) => {
console.error("Error signing in", error);
@@ -82,4 +84,4 @@ export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => {
)
-}
\ No newline at end of file
+}
diff --git a/packages/web/src/app/login/page.tsx b/packages/web/src/app/login/page.tsx
index e84b31754..1ce898806 100644
--- a/packages/web/src/app/login/page.tsx
+++ b/packages/web/src/app/login/page.tsx
@@ -6,6 +6,7 @@ import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
import { __unsafePrisma } from "@/prisma";
import { env } from "@sourcebot/shared";
import { isAnonymousAccessEnabled } from "@/lib/entitlements";
+import { normalizeCallbackUrl } from "@/lib/authRedirect";
interface LoginProps {
searchParams: Promise<{
@@ -16,9 +17,10 @@ interface LoginProps {
export default async function Login(props: LoginProps) {
const searchParams = await props.searchParams;
+ const callbackUrl = normalizeCallbackUrl(searchParams.callbackUrl);
const session = await auth();
if (session) {
- return redirect("/");
+ return redirect(callbackUrl);
}
const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } });
@@ -32,7 +34,7 @@ export default async function Login(props: LoginProps) {
;
+}
+
+export default async function VerifyPage({ searchParams }: VerifyPageProps) {
+ const { callbackUrl: rawCallbackUrl } = await searchParams;
+ const callbackUrl = normalizeCallbackUrl(rawCallbackUrl);
const session = await auth();
if (session) {
- return redirect("/");
+ return redirect(callbackUrl);
}
return ;
diff --git a/packages/web/src/app/login/verify/verifyForm.tsx b/packages/web/src/app/login/verify/verifyForm.tsx
index 5f945a9c7..f86e4b168 100644
--- a/packages/web/src/app/login/verify/verifyForm.tsx
+++ b/packages/web/src/app/login/verify/verifyForm.tsx
@@ -14,12 +14,14 @@ import useCaptureEvent from "@/hooks/useCaptureEvent"
import { Footer } from "@/app/components/footer"
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"
import { Redirect } from "@/app/components/redirect"
+import { normalizeCallbackUrl } from "@/lib/authRedirect"
function VerifyPageContent() {
const [value, setValue] = useState("")
const [isVerifying, setIsVerifying] = useState(false)
const searchParams = useSearchParams()
const email = searchParams.get("email")
+ const callbackUrl = normalizeCallbackUrl(searchParams.get("callbackUrl"))
const captureEvent = useCaptureEvent();
const handleSubmit = useCallback((code: string) => {
@@ -31,11 +33,12 @@ function VerifyPageContent() {
const url = new URL("/api/auth/callback/nodemailer", window.location.origin)
url.searchParams.set("token", code)
url.searchParams.set("email", email)
+ url.searchParams.set("callbackUrl", callbackUrl)
// Use a full-page navigation (not router.push) so the auth callback's
// session cookie + 302 redirect are applied by the browser, and the
// one-time token isn't consumed twice by a client-side RSC navigation.
window.location.href = url.toString()
- }, [email, isVerifying])
+ }, [callbackUrl, email, isVerifying])
// Auto-submit once the full 6-digit code is entered. Pass the new value
// directly rather than reading `value`, which hasn't been committed yet.
diff --git a/packages/web/src/app/signup/page.tsx b/packages/web/src/app/signup/page.tsx
index b5fe96d4c..311a95ae1 100644
--- a/packages/web/src/app/signup/page.tsx
+++ b/packages/web/src/app/signup/page.tsx
@@ -6,6 +6,7 @@ import { createLogger, env } from "@sourcebot/shared";
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
import { __unsafePrisma } from "@/prisma";
import { isAnonymousAccessEnabled } from "@/lib/entitlements";
+import { normalizeCallbackUrl } from "@/lib/authRedirect";
const logger = createLogger('signup-page');
@@ -18,10 +19,11 @@ interface LoginProps {
export default async function Signup(props: LoginProps) {
const searchParams = await props.searchParams;
+ const callbackUrl = normalizeCallbackUrl(searchParams.callbackUrl);
const session = await auth();
if (session) {
logger.info("Session found in signup page, redirecting to home");
- return redirect("/");
+ return redirect(callbackUrl);
}
const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } });
@@ -35,7 +37,7 @@ export default async function Signup(props: LoginProps) {
{
const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const loginHref = createLoginUrl(`${pathname}?${searchParams.toString()}`);
const [isDismissed, setIsDismissed] = useState(true); // Start as true to avoid flash
const [hasDisplayedEventFired, setHasDisplayedEventFired] = useState(false);
@@ -76,7 +79,7 @@ export const SignInPromptBanner = ({
asChild
onClick={handleSignInClick}
>
-
+
Sign in
diff --git a/packages/web/src/ee/features/chat/mcp/components/connectorsMenu.tsx b/packages/web/src/ee/features/chat/mcp/components/connectorsMenu.tsx
index 4896d535b..1ba0eee37 100644
--- a/packages/web/src/ee/features/chat/mcp/components/connectorsMenu.tsx
+++ b/packages/web/src/ee/features/chat/mcp/components/connectorsMenu.tsx
@@ -22,7 +22,8 @@ import { isServiceError } from "@/lib/utils";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangleIcon, CableIcon, Loader2Icon, PlusCircleIcon, PlusIcon, RefreshCwIcon, SettingsIcon, SparklesIcon } from "lucide-react";
import Link from "next/link";
-import { usePathname, useRouter } from "next/navigation";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { createLoginUrl } from "@/lib/authRedirect";
import { useEffect, useRef, useState } from "react";
import { useSlate } from "slate-react";
import { Editor } from "slate";
@@ -118,9 +119,10 @@ export const ConnectorsMenu = ({
const queryClient = useQueryClient();
const router = useRouter();
const pathname = usePathname();
+ const searchParams = useSearchParams();
const { toast } = useToast();
const isOwner = useRole() === OrgRole.OWNER;
- const loginHref = `/login?callbackUrl=${encodeURIComponent(pathname)}`;
+ const loginHref = createLoginUrl(`${pathname}?${searchParams.toString()}`);
const { data: servers = [], error, isError, isLoading, refetch } = useQuery({
queryKey: mcpQueryKeys.serversWithStatus,
diff --git a/packages/web/src/features/chat/components/chatBox/loginDialog.tsx b/packages/web/src/features/chat/components/chatBox/loginDialog.tsx
index 9fc83d257..d1c2a629e 100644
--- a/packages/web/src/features/chat/components/chatBox/loginDialog.tsx
+++ b/packages/web/src/features/chat/components/chatBox/loginDialog.tsx
@@ -8,7 +8,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { AuthMethodSelector } from "@/app/components/authMethodSelector";
-import { usePathname } from "next/navigation";
+import { usePathname, useSearchParams } from "next/navigation";
interface LoginDialogProps {
isOpen: boolean;
@@ -20,6 +20,8 @@ export const LoginDialog = ({
onOpenChange,
}: LoginDialogProps) => {
const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const callbackUrl = `${pathname}?${searchParams.toString()}`;
return (