feat(#416): add error display from URL query params on login page
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:
Jason Woltje
2026-02-16 11:50:33 -06:00
parent 1d7d5a9d01
commit 077bb042b7
2 changed files with 133 additions and 3 deletions

View File

@@ -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();
});
});
});