feat(frontend): login page — POST /api/session + error UI

This commit is contained in:
Claude
2026-08-28 16:12:15 +00:00
parent e0a2447d23
commit 1f317564f5
2 changed files with 97 additions and 1 deletions

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import userEvent from "@testing-library/user-event";
import { screen, waitFor } from "@testing-library/react";
import { Route, Routes } from "react-router-dom";
import { renderWithProviders } from "@/testing/renderWithProviders";
import { mswServer, http, HttpResponse } from "@/testing/msw";
import LoginPage from "./LoginPage";
describe("LoginPage", () => {
it("posts /api/session and redirects to /workflows on success", async () => {
mswServer.use(
http.post("/api/session", async ({ request }) => {
const body = await request.json() as { password: string };
if (body.password === "hunter2") return new HttpResponse(null, { status: 204 });
return new HttpResponse(null, { status: 401 });
}),
http.get("/api/session/me", () =>
HttpResponse.json({ authenticated: true, user: "admin" })),
);
renderWithProviders(
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/workflows" element={<div>Workflows here</div>} />
</Routes>,
{ route: "/login" },
);
await userEvent.type(screen.getByLabelText(/password/i), "hunter2");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText("Workflows here")).toBeInTheDocument();
});
});
it("shows an error when the API returns 401", async () => {
mswServer.use(
http.post("/api/session", () => new HttpResponse(null, { status: 401 })),
);
renderWithProviders(<LoginPage />, { route: "/login" });
await userEvent.type(screen.getByLabelText(/password/i), "wrong");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() =>
expect(screen.getByText(/invalid password/i)).toBeInTheDocument(),
);
});
});

View File

@@ -1,3 +1,51 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { UnauthorizedError } from "@/api/client";
import { useLogin } from "@/auth/session";
export default function LoginPage() {
return <div className="p-8">Login stub — replaced Task 8.</div>;
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const login = useLogin();
const nav = useNavigate();
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
try {
await login.mutateAsync(password);
nav("/workflows", { replace: true });
} catch (err) {
setError(err instanceof UnauthorizedError ? "Invalid password" : "Login failed");
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-950">
<form
onSubmit={onSubmit}
className="w-80 rounded-lg border border-slate-800 bg-slate-900 p-6 space-y-4"
>
<h1 className="text-lg font-semibold text-brand-500">VolcanicCore</h1>
<label className="block text-sm space-y-1">
<span className="text-slate-300">Password</span>
<input
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 outline-none focus:border-brand-500"
/>
</label>
{error && <p className="text-red-400 text-sm" role="alert">{error}</p>}
<button
type="submit"
disabled={login.isPending}
className="w-full rounded bg-brand-600 hover:bg-brand-700 disabled:opacity-50 py-2 text-sm font-medium"
>
Sign in
</button>
</form>
</div>
);
}