From e600cfd2d058757d6565222556faec715834767f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Feb 2026 13:44:01 -0600 Subject: [PATCH] =?UTF-8?q?fix(#411):=20QA-007=20=E2=80=94=20explicit=20er?= =?UTF-8?q?ror=20state=20on=20login=20config=20fetch=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login page now shows error state with retry button when /auth/config fetch fails, instead of silently falling back to email-only config. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/(auth)/login/page.test.tsx | 54 ++++++++++++++++++--- apps/web/src/app/(auth)/login/page.tsx | 37 ++++++++++---- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/(auth)/login/page.test.tsx b/apps/web/src/app/(auth)/login/page.test.tsx index ba461aa..d76a2fb 100644 --- a/apps/web/src/app/(auth)/login/page.test.tsx +++ b/apps/web/src/app/(auth)/login/page.test.tsx @@ -179,25 +179,32 @@ describe("LoginPage", (): void => { expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument(); }); - it("falls back to email-only on fetch failure and shows unavailability message", async (): Promise => { + it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise => { mockFetchFailure(); render(); await waitFor((): void => { - expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByTestId("config-error-state")).toBeInTheDocument(); }); - expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + // Should NOT silently fall back to email form + expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); - // Should show the unavailability banner (fix #5) + // Should show the error banner with helpful message expect( - screen.getByText("Some sign-in options may be temporarily unavailable.") + screen.getByText( + "Unable to load sign-in options. Please refresh the page or try again in a moment." + ) ).toBeInTheDocument(); + + // Should show a retry button + expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument(); }); - it("falls back to email-only on non-ok response", async (): Promise => { + it("shows error state on non-ok response", async (): Promise => { mockFetchWithRetry.mockResolvedValueOnce({ ok: false, status: 500, @@ -205,11 +212,44 @@ describe("LoginPage", (): void => { render(); + await waitFor((): void => { + expect(screen.getByTestId("config-error-state")).toBeInTheDocument(); + }); + + // Should NOT silently fall back to email form + expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); + + // Should show retry button + expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument(); + }); + + it("retry button triggers re-fetch and recovers on success", async (): Promise => { + // First attempt: failure + mockFetchFailure(); + + render(); + + await waitFor((): void => { + expect(screen.getByTestId("config-error-state")).toBeInTheDocument(); + }); + + // Set up the second fetch to succeed + mockFetchConfig(EMAIL_ONLY_CONFIG); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /try again/i })); + + // Should eventually load the config and show the login form await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); - expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); + // Error state should be gone + expect(screen.queryByTestId("config-error-state")).not.toBeInTheDocument(); + + // fetchWithRetry should have been called twice (initial + retry) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2); }); it("calls signIn.oauth2 when OAuth button is clicked", async (): Promise => { diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index d09a531..4726ddc 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -14,16 +14,12 @@ import { LoginForm } from "@/components/auth/LoginForm"; import { AuthDivider } from "@/components/auth/AuthDivider"; import { AuthErrorBanner } from "@/components/auth/AuthErrorBanner"; -/** Fallback config when the backend is unreachable */ -const EMAIL_ONLY_CONFIG: AuthConfigResponse = { - providers: [{ id: "email", name: "Email", type: "credentials" }], -}; - export default function LoginPage(): ReactElement { const router = useRouter(); const searchParams = useSearchParams(); - const [config, setConfig] = useState(null); + const [config, setConfig] = useState(undefined); const [loadingConfig, setLoadingConfig] = useState(true); + const [retryCount, setRetryCount] = useState(0); const [oauthLoading, setOauthLoading] = useState(null); const [credentialsLoading, setCredentialsLoading] = useState(false); const [error, setError] = useState(null); @@ -59,8 +55,10 @@ export default function LoginPage(): ReactElement { } catch (err: unknown) { if (!cancelled) { console.error("[Auth] Failed to load auth config:", err); - setConfig(EMAIL_ONLY_CONFIG); - setUrlError("Some sign-in options may be temporarily unavailable."); + setConfig(null); + setUrlError( + "Unable to load sign-in options. Please refresh the page or try again in a moment." + ); } } finally { if (!cancelled) { @@ -74,7 +72,7 @@ export default function LoginPage(): ReactElement { return (): void => { cancelled = true; }; - }, []); + }, [retryCount]); const oauthProviders: AuthProviderConfig[] = config?.providers.filter((p) => p.type === "oauth") ?? []; @@ -123,6 +121,14 @@ export default function LoginPage(): ReactElement { [router] ); + const handleRetry = useCallback((): void => { + setConfig(undefined); + setLoadingConfig(true); + setUrlError(null); + setError(null); + setRetryCount((c) => c + 1); + }, []); + return (
@@ -145,6 +151,19 @@ export default function LoginPage(): ReactElement {
+ ) : config === null ? ( +
+ +
+ +
+
) : ( <> {urlError && (