From fc24be26279667dd34c6d520a608129db1920fb0 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:52:40 +0700 Subject: [PATCH] fix(frontend): preserve login_method param to enable session re-authentication (#13310) --- frontend/__tests__/routes/login.test.tsx | 84 ++++++++++-- .../__tests__/routes/root-layout.test.tsx | 124 ++++++++++++++++++ frontend/src/routes/login.tsx | 11 +- frontend/src/routes/root-layout.tsx | 20 +-- 4 files changed, 216 insertions(+), 23 deletions(-) diff --git a/frontend/__tests__/routes/login.test.tsx b/frontend/__tests__/routes/login.test.tsx index 35751a591e..8158413dfc 100644 --- a/frontend/__tests__/routes/login.test.tsx +++ b/frontend/__tests__/routes/login.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createRoutesStub } from "react-router"; +import { createRoutesStub, useSearchParams } from "react-router"; import LoginPage from "#/routes/login"; import OptionService from "#/api/option-service/option-service.api"; import AuthService from "#/api/auth-service/auth-service.api"; @@ -80,6 +80,29 @@ const RouterStub = createRoutesStub([ }, ]); +function DestinationStub() { + const [params] = useSearchParams(); + const loginMethod = params.get("login_method"); + return ( +
+ {loginMethod && ( + {loginMethod} + )} +
+ ); +} + +const RouterStubWithDestination = createRoutesStub([ + { + Component: LoginPage, + path: "/login", + }, + { + Component: DestinationStub, + path: "/settings", + }, +]); + const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { @@ -282,7 +305,9 @@ describe("LoginPage", () => { await user.click(gitlabButton); // URL includes state parameter added by handleAuthRedirect - expect(window.location.href).toContain("https://gitlab.com/oauth/authorize"); + expect(window.location.href).toContain( + "https://gitlab.com/oauth/authorize", + ); }); it("should redirect to Bitbucket auth URL when Bitbucket button is clicked", async () => { @@ -347,6 +372,30 @@ describe("LoginPage", () => { ); }); + it("should preserve login_method param when redirecting authenticated users", async () => { + // Arrange + vi.spyOn(AuthService, "authenticate").mockResolvedValue(true); + + // Act + render( + , + { wrapper: createWrapper() }, + ); + + // Assert + await waitFor( + () => { + expect(screen.getByTestId("destination-page")).toBeInTheDocument(); + expect(screen.getByTestId("login-method-param")).toHaveTextContent( + "github", + ); + }, + { timeout: 2000 }, + ); + }); + it("should redirect OSS mode users to home", async () => { // @ts-expect-error - partial mock for testing vi.spyOn(OptionService, "getConfig").mockResolvedValue({ @@ -552,10 +601,12 @@ describe("LoginPage", () => { it("should pass buildOAuthStateData to LoginContent for OAuth state encoding", async () => { const user = userEvent.setup(); - const mockBuildOAuthStateData = vi.fn((baseState: Record) => ({ - ...baseState, - invitation_token: "inv-test-token-12345", - })); + const mockBuildOAuthStateData = vi.fn( + (baseState: Record) => ({ + ...baseState, + invitation_token: "inv-test-token-12345", + }), + ); useInvitationMock.mockReturnValue({ invitationToken: "inv-test-token-12345", @@ -585,10 +636,12 @@ describe("LoginPage", () => { it("should include invitation token in OAuth state when invitation is present", async () => { const user = userEvent.setup(); - const mockBuildOAuthStateData = vi.fn((baseState: Record) => ({ - ...baseState, - invitation_token: "inv-test-token-12345", - })); + const mockBuildOAuthStateData = vi.fn( + (baseState: Record) => ({ + ...baseState, + invitation_token: "inv-test-token-12345", + }), + ); useInvitationMock.mockReturnValue({ invitationToken: "inv-test-token-12345", @@ -634,9 +687,14 @@ describe("LoginPage", () => { clearInvitation: vi.fn(), }); - render(, { - wrapper: createWrapper(), - }); + render( + , + { + wrapper: createWrapper(), + }, + ); await waitFor(() => { expect(screen.getByText("AUTH$INVITATION_PENDING")).toBeInTheDocument(); diff --git a/frontend/__tests__/routes/root-layout.test.tsx b/frontend/__tests__/routes/root-layout.test.tsx index 0183553534..ba7327d0c4 100644 --- a/frontend/__tests__/routes/root-layout.test.tsx +++ b/frontend/__tests__/routes/root-layout.test.tsx @@ -366,6 +366,130 @@ describe("MainApp", () => { }); }); + describe("Re-authentication with stored login method", () => { + it("should show ReauthModal instead of redirecting to /login when login method exists", async () => { + // Arrange - user is unauthenticated but has a stored login method + vi.spyOn(AuthService, "authenticate").mockRejectedValue({ + response: { status: 401 }, + isAxiosError: true, + }); + + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => { + if (key === "openhands_login_method") { + return "github"; + } + return null; + }), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + + // Act + renderWithLoginStub(RouterStubWithLogin, ["/"]); + + // Assert - should show ReauthModal (with "Logging back in" text), not redirect to /login + await waitFor( + () => { + expect(screen.getByText("AUTH$LOGGING_BACK_IN")).toBeInTheDocument(); + }, + { timeout: 2000 }, + ); + + // Login page should NOT be shown when login method exists + expect(screen.queryByTestId("login-page")).not.toBeInTheDocument(); + }); + + it("should redirect to /login when no login method is stored", async () => { + // Arrange - user is unauthenticated and has no stored login method + vi.spyOn(AuthService, "authenticate").mockRejectedValue({ + response: { status: 401 }, + isAxiosError: true, + }); + + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + + // Act + renderWithLoginStub(RouterStubWithLogin, ["/"]); + + // Assert - should redirect to /login + await waitFor( + () => { + expect(screen.getByTestId("login-page")).toBeInTheDocument(); + }, + { timeout: 2000 }, + ); + }); + }); + + describe("Loading states", () => { + it("should show loading spinner while config is loading without redirecting", async () => { + // Arrange - config never resolves (loading state) + vi.spyOn(OptionService, "getConfig").mockImplementation( + () => new Promise(() => {}), + ); + + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => { + if (key === "openhands_login_method") { + return "github"; + } + return null; + }), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + + // Act + renderWithLoginStub(RouterStubWithLogin, ["/"]); + + // Assert - should show loading spinner + await waitFor(() => { + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + }); + + // Should NOT redirect to login while loading + expect(screen.queryByTestId("login-page")).not.toBeInTheDocument(); + }); + + it("should show loading spinner while auth is loading without redirecting", async () => { + // Arrange - auth never resolves (loading state) + vi.spyOn(AuthService, "authenticate").mockImplementation( + () => new Promise(() => {}), + ); + + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => { + if (key === "openhands_login_method") { + return "github"; + } + return null; + }), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + + // Act + renderWithLoginStub(RouterStubWithLogin, ["/"]); + + // Assert - should show loading spinner + await waitFor(() => { + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + }); + + // Should NOT redirect to login while loading + expect(screen.queryByTestId("login-page")).not.toBeInTheDocument(); + }); + }); + describe("Invitation URL Parameters", () => { beforeEach(() => { vi.spyOn(AuthService, "authenticate").mockRejectedValue({ diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index 0b357909b6..e8766d9500 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -40,11 +40,18 @@ export default function LoginPage() { }, [config.isLoading, config.data?.app_mode, navigate]); // Redirect authenticated users away from login page + // Preserve login_method param so useAuthCallback can store it for auto-login React.useEffect(() => { if (!isAuthLoading && isAuthed) { - navigate(returnTo, { replace: true }); + const loginMethod = searchParams.get("login_method"); + let destination = returnTo; + if (loginMethod) { + const separator = returnTo.includes("?") ? "&" : "?"; + destination = `${returnTo}${separator}login_method=${encodeURIComponent(loginMethod)}`; + } + navigate(destination, { replace: true }); } - }, [isAuthed, isAuthLoading, navigate, returnTo]); + }, [isAuthed, isAuthLoading, navigate, returnTo, searchParams]); if (isAuthLoading || config.isLoading) { return ( diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index 967d26eeea..28cf7eee21 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -173,14 +173,17 @@ export default function MainApp() { setLoginMethodExists(checkLoginMethodExists()); }, [isAuthed, checkLoginMethodExists]); + // Show loading spinner while config or auth is loading + const isLoading = config.isLoading || isAuthLoading; + + // Only decide to redirect AFTER loading completes const shouldRedirectToLogin = - config.isLoading || - isAuthLoading || - (!isAuthed && - !isAuthError && - !isOnIntermediatePage && - config.data?.app_mode === "saas" && - !loginMethodExists); + !isLoading && + !isAuthed && + !isAuthError && + !isOnIntermediatePage && + config.data?.app_mode === "saas" && + !loginMethodExists; React.useEffect(() => { if (shouldRedirectToLogin) { @@ -197,7 +200,8 @@ export default function MainApp() { } }, [shouldRedirectToLogin, pathname, searchParams, navigate]); - if (shouldRedirectToLogin) { + // Show loading spinner while loading OR when about to redirect + if (isLoading || shouldRedirectToLogin) { return (