fix(#411): QA-007 — explicit error state on login config fetch failure
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 <noreply@anthropic.com>
This commit is contained in:
@@ -179,25 +179,32 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument();
|
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<void> => {
|
it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise<void> => {
|
||||||
mockFetchFailure();
|
mockFetchFailure();
|
||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
await waitFor((): void => {
|
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();
|
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(
|
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();
|
).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<void> => {
|
it("shows error state on non-ok response", async (): Promise<void> => {
|
||||||
mockFetchWithRetry.mockResolvedValueOnce({
|
mockFetchWithRetry.mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -205,11 +212,44 @@ describe("LoginPage", (): void => {
|
|||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
// First attempt: failure
|
||||||
|
mockFetchFailure();
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
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 => {
|
await waitFor((): void => {
|
||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
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<void> => {
|
it("calls signIn.oauth2 when OAuth button is clicked", async (): Promise<void> => {
|
||||||
|
|||||||
@@ -14,16 +14,12 @@ import { LoginForm } from "@/components/auth/LoginForm";
|
|||||||
import { AuthDivider } from "@/components/auth/AuthDivider";
|
import { AuthDivider } from "@/components/auth/AuthDivider";
|
||||||
import { AuthErrorBanner } from "@/components/auth/AuthErrorBanner";
|
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 {
|
export default function LoginPage(): ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [config, setConfig] = useState<AuthConfigResponse | null>(null);
|
const [config, setConfig] = useState<AuthConfigResponse | null | undefined>(undefined);
|
||||||
const [loadingConfig, setLoadingConfig] = useState(true);
|
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
||||||
const [credentialsLoading, setCredentialsLoading] = useState(false);
|
const [credentialsLoading, setCredentialsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -59,8 +55,10 @@ export default function LoginPage(): ReactElement {
|
|||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error("[Auth] Failed to load auth config:", err);
|
console.error("[Auth] Failed to load auth config:", err);
|
||||||
setConfig(EMAIL_ONLY_CONFIG);
|
setConfig(null);
|
||||||
setUrlError("Some sign-in options may be temporarily unavailable.");
|
setUrlError(
|
||||||
|
"Unable to load sign-in options. Please refresh the page or try again in a moment."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -74,7 +72,7 @@ export default function LoginPage(): ReactElement {
|
|||||||
return (): void => {
|
return (): void => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [retryCount]);
|
||||||
|
|
||||||
const oauthProviders: AuthProviderConfig[] =
|
const oauthProviders: AuthProviderConfig[] =
|
||||||
config?.providers.filter((p) => p.type === "oauth") ?? [];
|
config?.providers.filter((p) => p.type === "oauth") ?? [];
|
||||||
@@ -123,6 +121,14 @@ export default function LoginPage(): ReactElement {
|
|||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRetry = useCallback((): void => {
|
||||||
|
setConfig(undefined);
|
||||||
|
setLoadingConfig(true);
|
||||||
|
setUrlError(null);
|
||||||
|
setError(null);
|
||||||
|
setRetryCount((c) => c + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
@@ -145,6 +151,19 @@ export default function LoginPage(): ReactElement {
|
|||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
|
||||||
<span className="sr-only">Loading authentication options</span>
|
<span className="sr-only">Loading authentication options</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : config === null ? (
|
||||||
|
<div className="space-y-4" data-testid="config-error-state">
|
||||||
|
<AuthErrorBanner message={urlError ?? "Unable to load sign-in options."} />
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{urlError && (
|
{urlError && (
|
||||||
|
|||||||
Reference in New Issue
Block a user