MAESTRO: Implement Login page with form validation, error handling, and guest access link
This commit is contained in:
parent
1050109777
commit
060f399789
3 changed files with 324 additions and 5 deletions
|
|
@ -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.
|
||||
<!-- Implemented in SetupPage.tsx (existing file convention). Checks auth on mount, shows form with validation, handles 409/network errors, redirects on success. 9 tests added. -->
|
||||
|
||||
- [ ] 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.
|
||||
<!-- Implemented in LoginPage.tsx (existing file convention). Username + password form, calls auth.login() which stores JWT in memory via client.ts, redirects to dashboard on success. Handles 401, server errors, and network errors. Includes guest access link and setup page link. 10 tests added. -->
|
||||
|
||||
- [ ] 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.
|
||||
|
||||
|
|
|
|||
183
frontend/src/pages/LoginPage.test.tsx
Normal file
183
frontend/src/pages/LoginPage.test.tsx
Normal file
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<LoginPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string | null>(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<string, unknown>).detail)
|
||||
: err.statusText;
|
||||
setError(`Login failed: ${detail}`);
|
||||
}
|
||||
} else {
|
||||
setError("Network error. Is the server running?");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow">
|
||||
<h1 className="mb-4 text-2xl font-bold">Sign In</h1>
|
||||
<p className="text-gray-600">Log in to PromptLooper.</p>
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo / header area */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-indigo-600 text-white text-2xl font-bold shadow-lg">
|
||||
PL
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
Welcome Back
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
Sign in to PromptLooper
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-xl bg-white dark:bg-slate-800 p-8 shadow-xl ring-1 ring-slate-200 dark:ring-slate-700"
|
||||
>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-6 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
{submitting ? "Signing in…" : "Sign In"}
|
||||
</button>
|
||||
|
||||
<div className="mt-4 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
<a
|
||||
href="/setup"
|
||||
className="font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500"
|
||||
>
|
||||
First time? Create an account
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<a
|
||||
href="/?guest=1"
|
||||
className="text-xs text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition"
|
||||
>
|
||||
Continue as guest
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue