diff --git a/apps/web/src/app/(auth)/login/page.test.tsx b/apps/web/src/app/(auth)/login/page.test.tsx index 8e0797e..8c73545 100644 --- a/apps/web/src/app/(auth)/login/page.test.tsx +++ b/apps/web/src/app/(auth)/login/page.test.tsx @@ -35,19 +35,34 @@ vi.mock("@/lib/config", () => ({ API_BASE_URL: "http://localhost:3001", })); +// Mock fetchWithRetry to behave like fetch for test purposes +const { mockFetchWithRetry } = vi.hoisted(() => ({ + mockFetchWithRetry: vi.fn(), +})); + +vi.mock("@/lib/auth/fetch-with-retry", () => ({ + fetchWithRetry: mockFetchWithRetry, +})); + +// Mock parseAuthError to use the real implementation +vi.mock("@/lib/auth/auth-errors", async () => { + const actual = await vi.importActual("@/lib/auth/auth-errors"); + return actual; +}); + /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function mockFetchConfig(config: AuthConfigResponse): void { - (global.fetch as Mock).mockResolvedValueOnce({ + mockFetchWithRetry.mockResolvedValueOnce({ ok: true, json: (): Promise => Promise.resolve(config), }); } function mockFetchFailure(): void { - (global.fetch as Mock).mockRejectedValueOnce(new Error("Network error")); + mockFetchWithRetry.mockRejectedValueOnce(new Error("Network error")); } const OAUTH_ONLY_CONFIG: AuthConfigResponse = { @@ -72,15 +87,16 @@ const BOTH_PROVIDERS_CONFIG: AuthConfigResponse = { describe("LoginPage", (): void => { beforeEach((): void => { vi.clearAllMocks(); - global.fetch = vi.fn(); // Reset search params to empty for each test mockSearchParams.delete("error"); + // Default: OAuth2 returns a resolved promise (fire-and-forget redirect) + mockOAuth2.mockResolvedValue(undefined); }); it("renders loading state initially", (): void => { // Never resolve fetch so it stays in loading state // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise to test loading state - (global.fetch as Mock).mockReturnValueOnce(new Promise(() => {})); + mockFetchWithRetry.mockReturnValueOnce(new Promise(() => {})); render(); @@ -105,13 +121,13 @@ describe("LoginPage", (): void => { expect(main).toHaveClass("flex", "min-h-screen"); }); - it("fetches /auth/config on mount", async (): Promise => { + it("fetches /auth/config on mount using fetchWithRetry", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { - expect(global.fetch).toHaveBeenCalledWith("http://localhost:3001/auth/config"); + expect(mockFetchWithRetry).toHaveBeenCalledWith("http://localhost:3001/auth/config"); }); }); @@ -164,7 +180,7 @@ describe("LoginPage", (): void => { expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument(); }); - it("falls back to email-only on fetch failure", async (): Promise => { + it("falls back to email-only on fetch failure and shows unavailability message", async (): Promise => { mockFetchFailure(); render(); @@ -175,10 +191,13 @@ describe("LoginPage", (): void => { expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); + + // Should show the unavailability banner (fix #5) + expect(screen.getByText("Some sign-in options may be temporarily unavailable.")).toBeInTheDocument(); }); it("falls back to email-only on non-ok response", async (): Promise => { - (global.fetch as Mock).mockResolvedValueOnce({ + mockFetchWithRetry.mockResolvedValueOnce({ ok: false, status: 500, }); @@ -210,6 +229,26 @@ describe("LoginPage", (): void => { }); }); + it("shows error when OAuth sign-in fails", async (): Promise => { + mockFetchConfig(OAUTH_ONLY_CONFIG); + mockOAuth2.mockRejectedValueOnce(new Error("Provider unavailable")); + const user = userEvent.setup(); + + render(); + + await waitFor((): void => { + expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /continue with authentik/i })); + + await waitFor((): void => { + expect( + screen.getByText("Unable to connect to the sign-in provider. Please try again in a moment.") + ).toBeInTheDocument(); + }); + }); + it("calls signIn.email and redirects on success", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); mockSignInEmail.mockResolvedValueOnce({ data: { user: {} } }); @@ -261,9 +300,9 @@ describe("LoginPage", (): void => { expect(mockPush).not.toHaveBeenCalled(); }); - it("shows generic error on unexpected sign-in exception", async (): Promise => { + it("shows parseAuthError message on unexpected sign-in exception", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); - mockSignInEmail.mockRejectedValueOnce(new Error("Network failure")); + mockSignInEmail.mockRejectedValueOnce(new TypeError("Failed to fetch")); const user = userEvent.setup(); render(); @@ -277,8 +316,9 @@ describe("LoginPage", (): void => { await user.click(screen.getByRole("button", { name: /continue/i })); await waitFor((): void => { + // parseAuthError maps TypeError("Failed to fetch") to network_error message expect( - screen.getByText("Something went wrong. Please try again in a moment.") + screen.getByText("Unable to connect. Check your network and try again.") ).toBeInTheDocument(); }); }); @@ -333,7 +373,7 @@ describe("LoginPage", (): void => { it("loading spinner has role=status", (): void => { // Never resolve fetch so it stays in loading state // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise - (global.fetch as Mock).mockReturnValueOnce(new Promise(() => {})); + mockFetchWithRetry.mockReturnValueOnce(new Promise(() => {})); render(); diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index e978f11..1c59c08 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -7,6 +7,8 @@ import { Loader2 } from "lucide-react"; import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared"; import { API_BASE_URL } from "@/lib/config"; import { signIn } from "@/lib/auth-client"; +import { fetchWithRetry } from "@/lib/auth/fetch-with-retry"; +import { parseAuthError } from "@/lib/auth/auth-errors"; import { OAuthButton } from "@/components/auth/OAuthButton"; import { LoginForm } from "@/components/auth/LoginForm"; import { AuthDivider } from "@/components/auth/AuthDivider"; @@ -17,22 +19,6 @@ 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(); @@ -47,7 +33,8 @@ export default function LoginPage(): ReactElement { useEffect(() => { const errorCode = searchParams.get("error"); if (errorCode) { - setUrlError(mapErrorCodeToMessage(errorCode)); + const parsed = parseAuthError(errorCode); + setUrlError(parsed.message); // Clean up the URL by removing the error param without triggering navigation const nextParams = new URLSearchParams(searchParams.toString()); nextParams.delete("error"); @@ -61,7 +48,7 @@ export default function LoginPage(): ReactElement { async function fetchConfig(): Promise { try { - const response = await fetch(`${API_BASE_URL}/auth/config`); + const response = await fetchWithRetry(`${API_BASE_URL}/auth/config`); if (!response.ok) { throw new Error("Failed to fetch auth config"); } @@ -69,9 +56,13 @@ export default function LoginPage(): ReactElement { if (!cancelled) { setConfig(data); } - } catch { + } catch (err: unknown) { if (!cancelled) { + if (process.env.NODE_ENV === "development") { + console.error("[Auth] Failed to load auth config:", err); + } setConfig(EMAIL_ONLY_CONFIG); + setUrlError("Some sign-in options may be temporarily unavailable."); } } finally { if (!cancelled) { @@ -98,7 +89,14 @@ export default function LoginPage(): ReactElement { const handleOAuthLogin = useCallback((providerId: string): void => { setOauthLoading(providerId); setError(null); - void signIn.oauth2({ providerId, callbackURL: "/" }); + signIn.oauth2({ providerId, callbackURL: "/" }).catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + if (process.env.NODE_ENV === "development") { + console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message); + } + setError("Unable to connect to the sign-in provider. Please try again in a moment."); + setOauthLoading(null); + }); }, []); const handleCredentialsLogin = useCallback( @@ -118,8 +116,12 @@ export default function LoginPage(): ReactElement { } else { router.push("/tasks"); } - } catch { - setError("Something went wrong. Please try again in a moment."); + } catch (err: unknown) { + const parsed = parseAuthError(err); + if (process.env.NODE_ENV === "development") { + console.error("[Auth] Credentials sign-in failed:", err); + } + setError(parsed.message); } finally { setCredentialsLoading(false); } diff --git a/apps/web/src/lib/auth/auth-context.tsx b/apps/web/src/lib/auth/auth-context.tsx index 57875ea..29a45ec 100644 --- a/apps/web/src/lib/auth/auth-context.tsx +++ b/apps/web/src/lib/auth/auth-context.tsx @@ -129,7 +129,8 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E try { await apiPost("/auth/sign-out"); } catch (error) { - console.error("Sign out error:", error); + logAuthError("Sign out request did not complete", error); + setAuthError("network"); } finally { setUser(null); expiresAtRef.current = null; diff --git a/apps/web/src/lib/auth/fetch-with-retry.test.ts b/apps/web/src/lib/auth/fetch-with-retry.test.ts index 1e79e60..5e46bb2 100644 --- a/apps/web/src/lib/auth/fetch-with-retry.test.ts +++ b/apps/web/src/lib/auth/fetch-with-retry.test.ts @@ -40,7 +40,6 @@ function mockResponse(status: number, ok?: boolean): Response { describe("fetchWithRetry", (): void => { const originalFetch = global.fetch; - const originalEnv = process.env.NODE_ENV; const sleepMock = vi.mocked(sleep); beforeEach((): void => { @@ -52,7 +51,6 @@ describe("fetchWithRetry", (): void => { afterEach((): void => { vi.restoreAllMocks(); global.fetch = originalFetch; - process.env.NODE_ENV = originalEnv; }); it("should succeed on first attempt without retrying", async (): Promise => { @@ -203,8 +201,7 @@ describe("fetchWithRetry", (): void => { expect(recordedDelays).toEqual([1000, 2000, 4000]); }); - it("should log retry attempts in development mode", async (): Promise => { - process.env.NODE_ENV = "development"; + it("should log retry attempts in all environments", async (): Promise => { const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {}); const okResponse = mockResponse(200); @@ -222,18 +219,22 @@ describe("fetchWithRetry", (): void => { warnSpy.mockRestore(); }); - it("should NOT log retry attempts in production mode", async (): Promise => { - process.env.NODE_ENV = "production"; + it("should log retry attempts for HTTP errors", async (): Promise => { const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {}); + const serverError = mockResponse(500); const okResponse = mockResponse(200); + vi.mocked(global.fetch) - .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(serverError) .mockResolvedValueOnce(okResponse); await fetchWithRetry("https://api.example.com/auth/config"); - expect(warnSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("[Auth] Retry 1/3 after HTTP 500"), + ); warnSpy.mockRestore(); }); diff --git a/apps/web/src/lib/auth/fetch-with-retry.ts b/apps/web/src/lib/auth/fetch-with-retry.ts index 3ef6522..1ef6445 100644 --- a/apps/web/src/lib/auth/fetch-with-retry.ts +++ b/apps/web/src/lib/auth/fetch-with-retry.ts @@ -80,9 +80,7 @@ export async function fetchWithRetry( lastResponse = response; const delay = computeDelay(attempt, baseDelayMs, backoffFactor); - if (process.env.NODE_ENV === "development") { - console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after HTTP ${response.status}, waiting ${delay}ms...`); - } + console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after HTTP ${response.status}, waiting ${delay}ms...`); await sleep(delay); } catch (error: unknown) { @@ -96,9 +94,7 @@ export async function fetchWithRetry( lastError = error; const delay = computeDelay(attempt, baseDelayMs, backoffFactor); - if (process.env.NODE_ENV === "development") { - console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after ${parsed.code}, waiting ${delay}ms...`); - } + console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after ${parsed.code}, waiting ${delay}ms...`); await sleep(delay); }