diff --git a/src/pages/Login.test.tsx b/src/pages/Login.test.tsx new file mode 100644 index 0000000..2307942 --- /dev/null +++ b/src/pages/Login.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { Login } from "./Login"; + +vi.mock("@/auth/useAuth", () => ({ useAuth: vi.fn() })); +import { useAuth } from "@/auth/useAuth"; + +function setup() { + const login = vi.fn(); + (useAuth as unknown as ReturnType).mockReturnValue({ + user: null, + status: "unauthenticated", + login, + logout: vi.fn(), + refresh: vi.fn(), + }); + return { login }; +} + +describe("Login page", () => { + beforeEach(() => { + vi.resetAllMocks(); + cleanup(); + }); + + it("submits username + password", async () => { + const { login } = setup(); + login.mockResolvedValue(undefined); + const { getByLabelText, getByRole } = render( + + + } /> + HOME} /> + + + ); + fireEvent.change(getByLabelText(/username/i), { + target: { value: "alice" }, + }); + fireEvent.change(getByLabelText(/password/i), { + target: { value: "hunter2hunter2" }, + }); + fireEvent.click(getByRole("button", { name: /sign in/i })); + await waitFor(() => + expect(login).toHaveBeenCalledWith("alice", "hunter2hunter2") + ); + }); + + it("shows error on failed login", async () => { + const { login } = setup(); + login.mockRejectedValue( + Object.assign(new Error("401"), { code: "invalid_credentials" }) + ); + const { getByLabelText, getByRole, getByText } = render( + + + } /> + + + ); + fireEvent.change(getByLabelText(/username/i), { + target: { value: "alice" }, + }); + fireEvent.change(getByLabelText(/password/i), { + target: { value: "wrong" }, + }); + fireEvent.click(getByRole("button", { name: /sign in/i })); + await waitFor(() => + expect(getByText(/username or password is incorrect/i)).toBeTruthy() + ); + }); + + it("redirects to `next` query param on success", async () => { + const { login } = setup(); + login.mockResolvedValue(undefined); + const { getByLabelText, getByRole, getByText } = render( + + + } /> + CLAIMS} /> + + + ); + fireEvent.change(getByLabelText(/username/i), { + target: { value: "alice" }, + }); + fireEvent.change(getByLabelText(/password/i), { + target: { value: "hunter2hunter2" }, + }); + fireEvent.click(getByRole("button", { name: /sign in/i })); + await waitFor(() => expect(getByText("CLAIMS")).toBeTruthy()); + }); +}); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..a40dfde --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,104 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate, useSearchParams, Navigate } from "react-router-dom"; +import { useAuth } from "@/auth/useAuth"; + +/** + * Sign-in screen. Mounted by the `/login` route in `App.tsx`, which + * sits outside `` so unauthenticated operators can reach + * it. The page is also reachable via the auto-redirect on a 401 from + * `authedFetch` — that path appends `?next=` so the operator + * lands back on the page they were trying to view. + * + * Error mapping is driven by the backend's `error` code on the + * `ApiError` thrown from `authApi.login`. The codes are defined in + * `backend/src/cyclone/auth/routes.py::login`. + */ +export function Login() { + const { user, login } = useAuth(); + const [params] = useSearchParams(); + const next = params.get("next") ?? "/"; + const navigate = useNavigate(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Already authenticated — bounce to the original destination (or + // dashboard) so we don't show a sign-in form to a logged-in user. + if (user) return ; + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + await login(username, password); + navigate(next, { replace: true }); + } catch (err) { + const code = (err as { code?: string })?.code ?? "error"; + if (code === "invalid_credentials") { + setError("Username or password is incorrect."); + } else if (code === "account_disabled") { + setError("Account is disabled. Contact your administrator."); + } else if (code === "rate_limited") { + setError("Too many attempts. Try again shortly."); + } else { + setError("Sign in failed. Try again."); + } + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

Cyclone

+

+ Sign in to continue. +

+ + + {error ? ( +
+ {error} +
+ ) : null} + +
+
+ ); +}