From 060f399789ec42821ee99374f7a32bf29f199862 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 02:35:34 -0500 Subject: [PATCH] MAESTRO: Implement Login page with form validation, error handling, and guest access link --- Auto Run Docs/02b-frontend-dashboard.md | 3 +- frontend/src/pages/LoginPage.test.tsx | 183 ++++++++++++++++++++++++ frontend/src/pages/LoginPage.tsx | 143 +++++++++++++++++- 3 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/LoginPage.test.tsx diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md index 9c67a88..c3cf2c4 100644 --- a/Auto Run Docs/02b-frontend-dashboard.md +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -5,7 +5,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil - [x] Implement the Setup page (frontend/src/pages/Setup.tsx). This is the first-boot experience: detect via /api/v1/auth/me whether an admin exists. If not, show a clean setup form with username + password + confirm password. On submit, call /api/v1/auth/setup. Redirect to Dashboard on success. Make this feel welcoming — this is the user's first impression. -- [ ] Implement the Login page (frontend/src/pages/Login.tsx). Simple form with username + password. Call /api/v1/auth/login, store JWT in memory (React context, NOT localStorage). Redirect to Dashboard. Include a subtle "guest access" link if the system has it enabled. Handle error states clearly. +- [x] Implement the Login page (frontend/src/pages/Login.tsx). Simple form with username + password. Call /api/v1/auth/login, store JWT in memory (React context, NOT localStorage). Redirect to Dashboard. Include a subtle "guest access" link if the system has it enabled. Handle error states clearly. + - [ ] Build the auth context provider (frontend/src/contexts/AuthContext.tsx). Manage JWT state, provide login/logout functions, expose current user info, handle token expiry with automatic redirect to login. Wrap the entire app in this provider. diff --git a/frontend/src/pages/LoginPage.test.tsx b/frontend/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..10f115f --- /dev/null +++ b/frontend/src/pages/LoginPage.test.tsx @@ -0,0 +1,183 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import LoginPage from "./LoginPage"; +import * as client from "../api/client"; + +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +function renderLogin() { + return render( + + + , + ); +} + +describe("LoginPage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockNavigate.mockReset(); + }); + + it("renders the login form", () => { + renderLogin(); + + expect(screen.getByText("Welcome Back")).toBeInTheDocument(); + expect(screen.getByLabelText("Username")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Sign In" }), + ).toBeInTheDocument(); + }); + + it("shows validation error when username is empty", async () => { + renderLogin(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Password"), "password123"); + await user.click(screen.getByRole("button", { name: "Sign In" })); + + expect(screen.getByRole("alert")).toHaveTextContent("Username is required"); + }); + + it("shows validation error when password is empty", async () => { + renderLogin(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.click(screen.getByRole("button", { name: "Sign In" })); + + expect(screen.getByRole("alert")).toHaveTextContent("Password is required"); + }); + + it("submits successfully and redirects to dashboard", async () => { + vi.spyOn(client.auth, "login").mockResolvedValue({ + access_token: "test-jwt-token", + token_type: "bearer", + }); + + renderLogin(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "password123"); + await user.click(screen.getByRole("button", { name: "Sign In" })); + + await waitFor(() => { + expect(client.auth.login).toHaveBeenCalledWith({ + username: "admin", + password: "password123", + }); + }); + expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); + }); + + it("shows error on invalid credentials (401)", async () => { + vi.spyOn(client.auth, "login").mockRejectedValue( + new client.ApiError(401, "Unauthorized", { detail: "Bad credentials" }), + ); + + renderLogin(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "wrong"); + await user.click(screen.getByRole("button", { name: "Sign In" })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Invalid username or password", + ); + }); + }); + + it("shows server error detail for non-401 API errors", async () => { + vi.spyOn(client.auth, "login").mockRejectedValue( + new client.ApiError(500, "Internal Server Error", { + detail: "Database down", + }), + ); + + renderLogin(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "password123"); + await user.click(screen.getByRole("button", { name: "Sign In" })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Login failed: Database down", + ); + }); + }); + + it("shows network error on fetch failure", async () => { + vi.spyOn(client.auth, "login").mockRejectedValue( + new Error("fetch failed"), + ); + + renderLogin(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "password123"); + await user.click(screen.getByRole("button", { name: "Sign In" })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent("Network error"); + }); + }); + + it("disables inputs and shows loading text while submitting", async () => { + let resolveLogin: (value: client.TokenResponse) => void; + vi.spyOn(client.auth, "login").mockImplementation( + () => + new Promise((resolve) => { + resolveLogin = resolve; + }), + ); + + renderLogin(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "password123"); + await user.click(screen.getByRole("button", { name: "Sign In" })); + + expect( + screen.getByRole("button", { name: "Signing in…" }), + ).toBeDisabled(); + expect(screen.getByLabelText("Username")).toBeDisabled(); + expect(screen.getByLabelText("Password")).toBeDisabled(); + + // Resolve to clean up + resolveLogin!({ access_token: "tok", token_type: "bearer" }); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + }); + + it("has a link to the setup page", () => { + renderLogin(); + + const setupLink = screen.getByText("First time? Create an account"); + expect(setupLink.closest("a")).toHaveAttribute("href", "/setup"); + }); + + it("has a guest access link", () => { + renderLogin(); + + const guestLink = screen.getByText("Continue as guest"); + expect(guestLink.closest("a")).toHaveAttribute("href", "/?guest=1"); + }); +}); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 924cc03..dc2bb7b 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,9 +1,144 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { auth, ApiError } from "../api/client"; + export default function LoginPage() { + const navigate = useNavigate(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (!username.trim()) { + setError("Username is required."); + return; + } + if (!password) { + setError("Password is required."); + return; + } + + setSubmitting(true); + try { + await auth.login({ username: username.trim(), password }); + navigate("/", { replace: true }); + } catch (err: unknown) { + if (err instanceof ApiError) { + if (err.status === 401) { + setError("Invalid username or password."); + } else { + const detail = + err.body && typeof err.body === "object" && "detail" in err.body + ? String((err.body as Record).detail) + : err.statusText; + setError(`Login failed: ${detail}`); + } + } else { + setError("Network error. Is the server running?"); + } + } finally { + setSubmitting(false); + } + } + return ( -
-
-

Sign In

-

Log in to PromptLooper.

+
+
+ {/* Logo / header area */} +
+
+ PL +
+

+ Welcome Back +

+

+ Sign in to PromptLooper +

+
+ + {/* Login form */} +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition" + placeholder="Your username" + disabled={submitting} + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition" + placeholder="Your password" + disabled={submitting} + /> +
+ + + + + + +
);