Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -48,6 +49,8 @@ export const ShareSettings = ({
const [removingUserIds, setRemovingUserIds] = useState<Set<string>>(new Set());
const { toast } = useToast();
const pathname = usePathname();
const searchParams = useSearchParams();
const loginHref = createLoginUrl(`${pathname}?${searchParams.toString()}`);
const isAuthenticated = !!currentUser;

const handleCopyLink = useCallback(async () => {
Expand Down Expand Up @@ -217,7 +220,7 @@ export const ShareSettings = ({
</Select>
{!isAuthenticated && (
<p className="text-xs text-muted-foreground mt-2">
<Link href={`/login?callbackUrl=${encodeURIComponent(pathname)}`} className="underline">Sign in</Link> to change chat visibility.
<Link href={loginHref} className="underline">Sign in</Link> to change chat visibility.
</p>
)}
<Separator className="-mx-4 w-auto my-4" />
Expand Down
14 changes: 9 additions & 5 deletions packages/web/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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 <GcpIapAuth callbackUrl="/" />;
return <GcpIapAuth callbackUrl={normalizeCallbackUrl(requestHeaders.get(REQUEST_PATH_HEADER))} />;
} else {
redirect('/login');
redirect(createLoginUrl(requestHeaders.get(REQUEST_PATH_HEADER)));
}
}
}
Expand Down Expand Up @@ -144,16 +146,18 @@ export default async function Layout(props: LayoutProps) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<ConnectAccountsCard linkedAccounts={linkedAccounts} callbackUrl="/" />
<ConnectAccountsCard
linkedAccounts={linkedAccounts}
callbackUrl={normalizeCallbackUrl(requestHeaders.get(REQUEST_PATH_HEADER))}
/>
</div>
)
}
}
}

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)) {
Expand Down
12 changes: 7 additions & 5 deletions packages/web/src/app/components/authMethodSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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" &&
Expand Down Expand Up @@ -80,13 +82,13 @@ export const AuthMethodSelector = ({
</div>
] : []),
...(hasMagicLink ? [
<MagicLinkForm key="magic-link" callbackUrl={callbackUrl} context={context} />
<MagicLinkForm key="magic-link" callbackUrl={safeCallbackUrl} context={context} />
] : []),
...(hasCredentials ? [
<CredentialsForm key="credentials" callbackUrl={callbackUrl} context={context} />
<CredentialsForm key="credentials" callbackUrl={safeCallbackUrl} context={context} />
] : [])
]}
/>
</>
);
};
};
16 changes: 5 additions & 11 deletions packages/web/src/app/login/components/loginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -90,7 +84,7 @@ export const LoginForm = ({ callbackUrl, error, context, isAnonymousAccessEnable
</div>
)}
<AuthMethodSelector
callbackUrl={callbackUrl}
callbackUrl={safeCallbackUrl}
context={context}
onProviderClick={handleProviderClick}
securityNoticeClosable={true}
Expand All @@ -99,11 +93,11 @@ export const LoginForm = ({ callbackUrl, error, context, isAnonymousAccessEnable
<p className="text-sm text-muted-foreground mt-8">
{context === "login" ?
<>
Don&apos;t have an account? <Link className="underline" href={callbackUrl ? `/signup?callbackUrl=${encodeURIComponent(callbackUrl)}` : "/signup"}>Sign up</Link>
Don&apos;t have an account? <Link className="underline" href={`/signup?callbackUrl=${encodeURIComponent(safeCallbackUrl)}`}>Sign up</Link>
</>
:
<>
Already have an account? <Link className="underline" href={callbackUrl ? `/login?callbackUrl=${encodeURIComponent(callbackUrl)}` : "/login"}>Sign in</Link>
Already have an account? <Link className="underline" href={`/login?callbackUrl=${encodeURIComponent(safeCallbackUrl)}`}>Sign in</Link>
</>
}
</p>
Expand Down
8 changes: 5 additions & 3 deletions packages/web/src/app/login/components/magicLinkForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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<z.infer<typeof magicLinkSchema>>({
resolver: zodResolver(magicLinkSchema),
Expand All @@ -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);
Expand Down Expand Up @@ -82,4 +84,4 @@ export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => {
</form>
</Form>
)
}
}
6 changes: 4 additions & 2 deletions packages/web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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 } });
Expand All @@ -32,7 +34,7 @@ export default async function Login(props: LoginProps) {
<div className="flex flex-col min-h-screen bg-backgroundSecondary">
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
<LoginForm
callbackUrl={searchParams.callbackUrl}
callbackUrl={callbackUrl}
error={searchParams.error}
context="login"
isAnonymousAccessEnabled={anonymousAccessEnabled}
Expand Down
13 changes: 11 additions & 2 deletions packages/web/src/app/login/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { VerifyForm } from "./verifyForm";
import { normalizeCallbackUrl } from "@/lib/authRedirect";

export default async function VerifyPage() {
interface VerifyPageProps {
searchParams: Promise<{
callbackUrl?: string;
}>;
}

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 <VerifyForm />;
Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/app/login/verify/verifyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions packages/web/src/app/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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 } });
Expand All @@ -35,7 +37,7 @@ export default async function Signup(props: LoginProps) {
<div className="flex flex-col min-h-screen bg-backgroundSecondary">
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
<LoginForm
callbackUrl={searchParams.callbackUrl}
callbackUrl={callbackUrl}
error={searchParams.error}
context="signup"
isAnonymousAccessEnabled={anonymousAccessEnabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { Button } from '@/components/ui/button';
import { captureEvent } from '@/hooks/useCaptureEvent';
import { 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 { useState, useEffect } from 'react';

const DISMISSED_KEY = 'sb.chat-sign-in-prompt-dismissed';
Expand All @@ -25,6 +26,8 @@ export const SignInPromptBanner = ({
isTurnInProgress,
}: SignInPromptBannerProps) => {
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);

Expand Down Expand Up @@ -76,7 +79,7 @@ export const SignInPromptBanner = ({
asChild
onClick={handleSignInClick}
>
<Link href={`/login?callbackUrl=${encodeURIComponent(pathname)}`}>
<Link href={loginHref}>
Sign in
</Link>
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading