feat(#416): add error display from URL query params on login page
Some checks failed
ci/woodpecker/push/web Pipeline failed
Some checks failed
ci/woodpecker/push/web Pipeline failed
Maps error codes to PDA-friendly messages (no alarming language). Dismissible error banner with URL param cleanup. Refs #416 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> => {
|
||||
mockSearchParams.set("error", "access_denied");
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
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<void> => {
|
||||
mockSearchParams.set("error", "invalid_credentials");
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
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<void> => {
|
||||
mockSearchParams.set("error", "some_unknown_code");
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(
|
||||
screen.getByText("Authentication didn't complete. Please try again when ready.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("error banner is dismissible", async (): Promise<void> => {
|
||||
mockSearchParams.set("error", "access_denied");
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
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<void> => {
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<AuthConfigResponse | null>(null);
|
||||
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
||||
const [credentialsLoading, setCredentialsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [urlError, setUrlError] = useState<string | null>(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 {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{urlError && (
|
||||
<AuthErrorBanner
|
||||
message={urlError}
|
||||
onDismiss={(): void => {
|
||||
setUrlError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && !hasCredentials && (
|
||||
<AuthErrorBanner
|
||||
message={error}
|
||||
|
||||
Reference in New Issue
Block a user