diff --git a/apps/web/src/app/(auth)/login/page.test.tsx b/apps/web/src/app/(auth)/login/page.test.tsx index 683514c..4007c3b 100644 --- a/apps/web/src/app/(auth)/login/page.test.tsx +++ b/apps/web/src/app/(auth)/login/page.test.tsx @@ -8,16 +8,20 @@ import LoginPage from "./page"; /* Hoisted mocks */ /* ------------------------------------------------------------------ */ -const { mockOAuth2, mockSignInEmail, mockPush } = vi.hoisted(() => ({ +const { mockOAuth2, mockSignInEmail, mockPush, mockReplace, mockSearchParams } = vi.hoisted(() => ({ mockOAuth2: vi.fn(), mockSignInEmail: vi.fn(), mockPush: vi.fn(), + mockReplace: vi.fn(), + mockSearchParams: new URLSearchParams(), })); vi.mock("next/navigation", () => ({ - useRouter: (): { push: Mock } => ({ + useRouter: (): { push: Mock; replace: Mock } => ({ push: mockPush, + replace: mockReplace, }), + useSearchParams: (): URLSearchParams => mockSearchParams, })); vi.mock("@/lib/auth-client", () => ({ @@ -69,6 +73,8 @@ describe("LoginPage", (): void => { beforeEach((): void => { vi.clearAllMocks(); global.fetch = vi.fn(); + // Reset search params to empty for each test + mockSearchParams.delete("error"); }); it("renders loading state initially", (): void => { @@ -276,4 +282,88 @@ describe("LoginPage", (): void => { ).toBeInTheDocument(); }); }); + + /* ------------------------------------------------------------------ */ + /* URL error param tests */ + /* ------------------------------------------------------------------ */ + + describe("URL error query param", (): void => { + it("shows PDA-friendly banner for ?error=access_denied", async (): Promise => { + mockSearchParams.set("error", "access_denied"); + mockFetchConfig(EMAIL_ONLY_CONFIG); + + render(); + + await waitFor((): void => { + expect( + screen.getByText("Authentication paused. Please try again when ready.") + ).toBeInTheDocument(); + }); + + expect(mockReplace).toHaveBeenCalledWith("/login"); + }); + + it("shows correct message for ?error=invalid_credentials", async (): Promise => { + mockSearchParams.set("error", "invalid_credentials"); + mockFetchConfig(EMAIL_ONLY_CONFIG); + + render(); + + await waitFor((): void => { + expect( + screen.getByText("The email and password combination wasn't recognized.") + ).toBeInTheDocument(); + }); + }); + + it("shows default message for unknown error code", async (): Promise => { + mockSearchParams.set("error", "some_unknown_code"); + mockFetchConfig(EMAIL_ONLY_CONFIG); + + render(); + + await waitFor((): void => { + expect( + screen.getByText("Authentication didn't complete. Please try again when ready.") + ).toBeInTheDocument(); + }); + }); + + it("error banner is dismissible", async (): Promise => { + mockSearchParams.set("error", "access_denied"); + mockFetchConfig(EMAIL_ONLY_CONFIG); + const user = userEvent.setup(); + + render(); + + await waitFor((): void => { + expect( + screen.getByText("Authentication paused. Please try again when ready.") + ).toBeInTheDocument(); + }); + + // Clear the mock search params so the effect doesn't re-set the error on re-render + mockSearchParams.delete("error"); + + await user.click(screen.getByRole("button", { name: /dismiss/i })); + + await waitFor((): void => { + expect( + screen.queryByText("Authentication paused. Please try again when ready.") + ).not.toBeInTheDocument(); + }); + }); + + it("does not show error banner when no ?error param is present", async (): Promise => { + mockFetchConfig(EMAIL_ONLY_CONFIG); + + render(); + + await waitFor((): void => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 5cf34ae..0ebc9f9 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react"; import type { ReactElement } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { Loader2 } from "lucide-react"; import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared"; import { API_BASE_URL } from "@/lib/config"; @@ -17,13 +17,44 @@ const EMAIL_ONLY_CONFIG: AuthConfigResponse = { providers: [{ id: "email", name: "Email", type: "credentials" }], }; +/** Maps URL error codes to PDA-friendly messages (no alarming language). */ +const ERROR_CODE_MESSAGES: Record = { + access_denied: "Authentication paused. Please try again when ready.", + invalid_credentials: "The email and password combination wasn't recognized.", + server_error: "The service is taking a break. Please try again in a moment.", + network_error: "Unable to connect. Check your network and try again.", + rate_limited: "You've tried a few times. Take a moment and try again shortly.", + session_expired: "Your session ended. Please sign in again when ready.", +}; + +const DEFAULT_ERROR_MESSAGE = "Authentication didn't complete. Please try again when ready."; + +function mapErrorCodeToMessage(code: string): string { + return ERROR_CODE_MESSAGES[code] ?? DEFAULT_ERROR_MESSAGE; +} + export default function LoginPage(): ReactElement { const router = useRouter(); + const searchParams = useSearchParams(); const [config, setConfig] = useState(null); const [loadingConfig, setLoadingConfig] = useState(true); const [oauthLoading, setOauthLoading] = useState(null); const [credentialsLoading, setCredentialsLoading] = useState(false); const [error, setError] = useState(null); + const [urlError, setUrlError] = useState(null); + + /* Read ?error= query param on mount and map to PDA-friendly message */ + useEffect(() => { + const errorCode = searchParams.get("error"); + if (errorCode) { + setUrlError(mapErrorCodeToMessage(errorCode)); + // Clean up the URL by removing the error param without triggering navigation + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete("error"); + const query = nextParams.toString(); + router.replace(query ? `/login?${query}` : "/login"); + } + }, [searchParams, router]); useEffect(() => { let cancelled = false; @@ -115,6 +146,15 @@ export default function LoginPage(): ReactElement { ) : ( <> + {urlError && ( + { + setUrlError(null); + }} + /> + )} + {error && !hasCredentials && (