MAESTRO: Implement AuthContext provider with JWT management, session validation, and protected route redirects

This commit is contained in:
John Lightner 2026-04-07 02:38:23 -05:00
parent f60128604f
commit 0e6ae49b3c
5 changed files with 432 additions and 55 deletions

View file

@ -8,7 +8,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
- [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. - [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. --> <!-- 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. - [x] 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.
<!-- AuthProvider wraps app in App.tsx. Manages JWT via client.ts token functions, validates session on mount via auth.me(), auto-redirects to /login on expired/missing token, exposes useAuth() hook with user/isAuthenticated/isLoading/login/logout. Public paths (/login, /setup) bypass redirect. 10 tests added. App.test.tsx updated for auth-aware routing. -->
- [ ] Implement the Projects page (frontend/src/pages/Projects.tsx). Card grid showing all projects with name, description, experiment count, last activity timestamp, and a progress indicator showing best score across all experiments. Include a "New Project" button that opens a creation modal. Click a card to navigate to its experiments. - [ ] Implement the Projects page (frontend/src/pages/Projects.tsx). Card grid showing all projects with name, description, experiment count, last activity timestamp, and a progress indicator showing best score across all experiments. Include a "New Project" button that opens a creation modal. Click a card to navigate to its experiments.

View file

@ -1,9 +1,16 @@
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import App from "./App"; import App from "./App";
import * as client from "./api/client"; import * as client from "./api/client";
const fakeUser: client.UserResponse = {
id: "u1",
username: "admin",
is_admin: true,
created_at: "2026-01-01T00:00:00Z",
};
function renderWithRouter(route: string) { function renderWithRouter(route: string) {
return render( return render(
<MemoryRouter initialEntries={[route]}> <MemoryRouter initialEntries={[route]}>
@ -13,6 +20,12 @@ function renderWithRouter(route: string) {
} }
describe("App routing", () => { describe("App routing", () => {
beforeEach(() => {
vi.restoreAllMocks();
client.clearToken();
});
describe("public routes", () => {
it("renders SetupPage at /setup", async () => { it("renders SetupPage at /setup", async () => {
vi.spyOn(client.auth, "me").mockRejectedValue( vi.spyOn(client.auth, "me").mockRejectedValue(
new client.ApiError(401, "Unauthorized", {}), new client.ApiError(401, "Unauthorized", {}),
@ -21,46 +34,78 @@ describe("App routing", () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("PromptLooper Setup")).toBeInTheDocument(); expect(screen.getByText("PromptLooper Setup")).toBeInTheDocument();
}); });
vi.restoreAllMocks();
}); });
it("renders LoginPage at /login", () => { it("renders LoginPage at /login", async () => {
renderWithRouter("/login"); renderWithRouter("/login");
await waitFor(() => {
expect(screen.getByText("Sign In")).toBeInTheDocument(); expect(screen.getByText("Sign In")).toBeInTheDocument();
}); });
});
it("renders DashboardPage at /", () => {
renderWithRouter("/");
expect(screen.getByText("Dashboard")).toBeInTheDocument();
}); });
it("renders ProjectsPage at /projects", () => { describe("protected routes (authenticated)", () => {
beforeEach(() => {
client.setToken("valid-token");
vi.spyOn(client.auth, "me").mockResolvedValue(fakeUser);
});
it("renders DashboardPage at /", async () => {
renderWithRouter("/");
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
});
it("renders ProjectsPage at /projects", async () => {
renderWithRouter("/projects"); renderWithRouter("/projects");
await waitFor(() => {
expect(screen.getByText("Projects")).toBeInTheDocument(); expect(screen.getByText("Projects")).toBeInTheDocument();
}); });
});
it("renders ExperimentPage at /experiments/:id", () => { it("renders ExperimentPage at /experiments/:id", async () => {
renderWithRouter("/experiments/abc-123"); renderWithRouter("/experiments/abc-123");
await waitFor(() => {
expect(screen.getByText("Experiment")).toBeInTheDocument(); expect(screen.getByText("Experiment")).toBeInTheDocument();
}); });
});
it("renders LivePage at /live/:id", () => { it("renders LivePage at /live/:id", async () => {
renderWithRouter("/live/abc-123"); renderWithRouter("/live/abc-123");
await waitFor(() => {
expect(screen.getByText("Live")).toBeInTheDocument(); expect(screen.getByText("Live")).toBeInTheDocument();
}); });
});
it("renders ComparePage at /compare", () => { it("renders ComparePage at /compare", async () => {
renderWithRouter("/compare"); renderWithRouter("/compare");
await waitFor(() => {
expect(screen.getByText("Compare")).toBeInTheDocument(); expect(screen.getByText("Compare")).toBeInTheDocument();
}); });
it("renders AdminPage at /admin", () => {
renderWithRouter("/admin");
expect(screen.getByText("Admin")).toBeInTheDocument();
}); });
it("redirects unknown routes to dashboard", () => { it("renders AdminPage at /admin", async () => {
renderWithRouter("/admin");
await waitFor(() => {
expect(screen.getByText("Admin")).toBeInTheDocument();
});
});
it("redirects unknown routes to dashboard", async () => {
renderWithRouter("/nonexistent"); renderWithRouter("/nonexistent");
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument(); expect(screen.getByText("Dashboard")).toBeInTheDocument();
}); });
}); });
});
describe("unauthenticated access to protected routes", () => {
it("redirects to login when visiting / without auth", async () => {
renderWithRouter("/");
await waitFor(() => {
expect(screen.getByText("Sign In")).toBeInTheDocument();
});
});
});
});

View file

@ -1,4 +1,5 @@
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import SetupPage from "./pages/SetupPage"; import SetupPage from "./pages/SetupPage";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage"; import DashboardPage from "./pages/DashboardPage";
@ -10,6 +11,7 @@ import AdminPage from "./pages/AdminPage";
export default function App() { export default function App() {
return ( return (
<AuthProvider>
<Routes> <Routes>
<Route path="/setup" element={<SetupPage />} /> <Route path="/setup" element={<SetupPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
@ -21,5 +23,6 @@ export default function App() {
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</AuthProvider>
); );
} }

View file

@ -0,0 +1,208 @@
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, useLocation } from "react-router-dom";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AuthProvider, useAuth } from "./AuthContext";
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,
};
});
/** Renders children inside AuthProvider + MemoryRouter */
function renderWithAuth(ui: React.ReactElement, initialPath = "/") {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<AuthProvider>{ui}</AuthProvider>
</MemoryRouter>,
);
}
/** A component that exposes auth state for testing */
function AuthConsumer() {
const { user, isAuthenticated, isLoading, login, logout } = useAuth();
return (
<div>
<span data-testid="loading">{String(isLoading)}</span>
<span data-testid="authenticated">{String(isAuthenticated)}</span>
<span data-testid="username">{user?.username ?? "none"}</span>
<button onClick={() => login({ username: "admin", password: "pass" })}>
Login
</button>
<button onClick={() => logout()}>Logout</button>
</div>
);
}
const fakeUser: client.UserResponse = {
id: "u1",
username: "admin",
is_admin: true,
created_at: "2026-01-01T00:00:00Z",
};
describe("AuthContext", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockNavigate.mockReset();
client.clearToken();
});
it("resolves to unauthenticated when no token exists", async () => {
renderWithAuth(<AuthConsumer />, "/login");
await waitFor(() => {
expect(screen.getByTestId("loading")).toHaveTextContent("false");
});
expect(screen.getByTestId("authenticated")).toHaveTextContent("false");
expect(screen.getByTestId("username")).toHaveTextContent("none");
});
it("shows loading state while checking auth with existing token", async () => {
client.setToken("some-token");
let resolveMeCall: (value: client.UserResponse) => void;
vi.spyOn(client.auth, "me").mockImplementation(
() => new Promise((resolve) => { resolveMeCall = resolve; }),
);
renderWithAuth(<AuthConsumer />);
// Loading while me() is pending
expect(screen.getByTestId("loading")).toHaveTextContent("true");
expect(screen.getByTestId("authenticated")).toHaveTextContent("false");
// Resolve the me() call
await act(async () => {
resolveMeCall!(fakeUser);
});
expect(screen.getByTestId("loading")).toHaveTextContent("false");
expect(screen.getByTestId("authenticated")).toHaveTextContent("true");
});
it("checks auth on mount when token exists and sets user", async () => {
client.setToken("valid-token");
vi.spyOn(client.auth, "me").mockResolvedValue(fakeUser);
renderWithAuth(<AuthConsumer />);
await waitFor(() => {
expect(screen.getByTestId("loading")).toHaveTextContent("false");
});
expect(screen.getByTestId("authenticated")).toHaveTextContent("true");
expect(screen.getByTestId("username")).toHaveTextContent("admin");
});
it("clears state when auth.me() fails (expired token)", async () => {
client.setToken("expired-token");
vi.spyOn(client.auth, "me").mockRejectedValue(
new client.ApiError(401, "Unauthorized", {}),
);
renderWithAuth(<AuthConsumer />, "/login");
await waitFor(() => {
expect(screen.getByTestId("loading")).toHaveTextContent("false");
});
expect(screen.getByTestId("authenticated")).toHaveTextContent("false");
expect(client.getToken()).toBeNull();
});
it("login() calls auth.login + auth.me and sets user", async () => {
vi.spyOn(client.auth, "login").mockResolvedValue({
access_token: "new-token",
token_type: "bearer",
});
vi.spyOn(client.auth, "me").mockResolvedValue(fakeUser);
renderWithAuth(<AuthConsumer />, "/login");
const user = userEvent.setup();
await waitFor(() => {
expect(screen.getByTestId("loading")).toHaveTextContent("false");
});
await user.click(screen.getByText("Login"));
await waitFor(() => {
expect(screen.getByTestId("authenticated")).toHaveTextContent("true");
});
expect(screen.getByTestId("username")).toHaveTextContent("admin");
expect(client.auth.login).toHaveBeenCalledWith({
username: "admin",
password: "pass",
});
});
it("logout() clears user and redirects to /login", async () => {
client.setToken("valid-token");
vi.spyOn(client.auth, "me").mockResolvedValue(fakeUser);
renderWithAuth(<AuthConsumer />);
const user = userEvent.setup();
await waitFor(() => {
expect(screen.getByTestId("authenticated")).toHaveTextContent("true");
});
await user.click(screen.getByText("Logout"));
expect(screen.getByTestId("authenticated")).toHaveTextContent("false");
expect(screen.getByTestId("username")).toHaveTextContent("none");
expect(client.getToken()).toBeNull();
expect(mockNavigate).toHaveBeenCalledWith("/login", { replace: true });
});
it("redirects to /login when unauthenticated on a protected path", async () => {
renderWithAuth(<AuthConsumer />, "/projects");
await waitFor(() => {
expect(screen.getByTestId("loading")).toHaveTextContent("false");
});
expect(mockNavigate).toHaveBeenCalledWith("/login", { replace: true });
});
it("does NOT redirect when on /login path while unauthenticated", async () => {
renderWithAuth(<AuthConsumer />, "/login");
await waitFor(() => {
expect(screen.getByTestId("loading")).toHaveTextContent("false");
});
expect(mockNavigate).not.toHaveBeenCalled();
});
it("does NOT redirect when on /setup path while unauthenticated", async () => {
renderWithAuth(<AuthConsumer />, "/setup");
await waitFor(() => {
expect(screen.getByTestId("loading")).toHaveTextContent("false");
});
expect(mockNavigate).not.toHaveBeenCalled();
});
it("throws when useAuth is used outside AuthProvider", () => {
function BadConsumer() {
useAuth();
return null;
}
// Suppress React error boundary console noise
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() =>
render(
<MemoryRouter>
<BadConsumer />
</MemoryRouter>,
),
).toThrow("useAuth must be used within an AuthProvider");
spy.mockRestore();
});
});

View file

@ -0,0 +1,120 @@
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useMemo,
} from "react";
import type { ReactNode } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
auth,
setToken,
clearToken,
getToken,
ApiError,
} from "../api/client";
import type { UserResponse, LoginRequest } from "../api/client";
export interface AuthContextValue {
user: UserResponse | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (data: LoginRequest) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
const PUBLIC_PATHS = ["/login", "/setup"];
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const navigate = useNavigate();
const location = useLocation();
const isAuthenticated = user !== null;
// Check current session on mount
useEffect(() => {
let cancelled = false;
async function checkAuth() {
const token = getToken();
if (!token) {
setIsLoading(false);
return;
}
try {
const me = await auth.me();
if (!cancelled) {
setUser(me);
}
} catch (err: unknown) {
if (!cancelled) {
// Token invalid or expired — clear it
clearToken();
setUser(null);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
checkAuth();
return () => {
cancelled = true;
};
}, []);
// Redirect to login when not authenticated and not on a public page
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated && !PUBLIC_PATHS.includes(location.pathname)) {
navigate("/login", { replace: true });
}
}, [isLoading, isAuthenticated, location.pathname, navigate]);
const login = useCallback(
async (data: LoginRequest) => {
const tokenResp = await auth.login(data);
const me = await auth.me();
setUser(me);
},
[],
);
const logout = useCallback(() => {
auth.logout();
setUser(null);
navigate("/login", { replace: true });
}, [navigate]);
const value = useMemo<AuthContextValue>(
() => ({
user,
isAuthenticated,
isLoading,
login,
logout,
}),
[user, isAuthenticated, isLoading, login, logout],
);
return (
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}