From 0e6ae49b3cea6e46749c30db0b1fbaa9f9e86956 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 02:38:23 -0500 Subject: [PATCH] MAESTRO: Implement AuthContext provider with JWT management, session validation, and protected route redirects --- Auto Run Docs/02b-frontend-dashboard.md | 3 +- frontend/src/App.test.tsx | 131 ++++++++----- frontend/src/App.tsx | 25 +-- frontend/src/contexts/AuthContext.test.tsx | 208 +++++++++++++++++++++ frontend/src/contexts/AuthContext.tsx | 120 ++++++++++++ 5 files changed, 432 insertions(+), 55 deletions(-) create mode 100644 frontend/src/contexts/AuthContext.test.tsx create mode 100644 frontend/src/contexts/AuthContext.tsx diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md index c3cf2c4..1ed6173 100644 --- a/Auto Run Docs/02b-frontend-dashboard.md +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -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. -- [ ] 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. + - [ ] 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. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 89bd72b..86204f5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,9 +1,16 @@ import { render, screen, waitFor } from "@testing-library/react"; 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 * 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) { return render( @@ -13,54 +20,92 @@ function renderWithRouter(route: string) { } describe("App routing", () => { - it("renders SetupPage at /setup", async () => { - vi.spyOn(client.auth, "me").mockRejectedValue( - new client.ApiError(401, "Unauthorized", {}), - ); - renderWithRouter("/setup"); - await waitFor(() => { - expect(screen.getByText("PromptLooper Setup")).toBeInTheDocument(); - }); + beforeEach(() => { vi.restoreAllMocks(); + client.clearToken(); }); - it("renders LoginPage at /login", () => { - renderWithRouter("/login"); - expect(screen.getByText("Sign In")).toBeInTheDocument(); + describe("public routes", () => { + it("renders SetupPage at /setup", async () => { + vi.spyOn(client.auth, "me").mockRejectedValue( + new client.ApiError(401, "Unauthorized", {}), + ); + renderWithRouter("/setup"); + await waitFor(() => { + expect(screen.getByText("PromptLooper Setup")).toBeInTheDocument(); + }); + }); + + it("renders LoginPage at /login", async () => { + renderWithRouter("/login"); + await waitFor(() => { + expect(screen.getByText("Sign In")).toBeInTheDocument(); + }); + }); }); - it("renders DashboardPage at /", () => { - renderWithRouter("/"); - expect(screen.getByText("Dashboard")).toBeInTheDocument(); + 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"); + await waitFor(() => { + expect(screen.getByText("Projects")).toBeInTheDocument(); + }); + }); + + it("renders ExperimentPage at /experiments/:id", async () => { + renderWithRouter("/experiments/abc-123"); + await waitFor(() => { + expect(screen.getByText("Experiment")).toBeInTheDocument(); + }); + }); + + it("renders LivePage at /live/:id", async () => { + renderWithRouter("/live/abc-123"); + await waitFor(() => { + expect(screen.getByText("Live")).toBeInTheDocument(); + }); + }); + + it("renders ComparePage at /compare", async () => { + renderWithRouter("/compare"); + await waitFor(() => { + expect(screen.getByText("Compare")).toBeInTheDocument(); + }); + }); + + it("renders AdminPage at /admin", async () => { + renderWithRouter("/admin"); + await waitFor(() => { + expect(screen.getByText("Admin")).toBeInTheDocument(); + }); + }); + + it("redirects unknown routes to dashboard", async () => { + renderWithRouter("/nonexistent"); + await waitFor(() => { + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + }); + }); }); - it("renders ProjectsPage at /projects", () => { - renderWithRouter("/projects"); - expect(screen.getByText("Projects")).toBeInTheDocument(); - }); - - it("renders ExperimentPage at /experiments/:id", () => { - renderWithRouter("/experiments/abc-123"); - expect(screen.getByText("Experiment")).toBeInTheDocument(); - }); - - it("renders LivePage at /live/:id", () => { - renderWithRouter("/live/abc-123"); - expect(screen.getByText("Live")).toBeInTheDocument(); - }); - - it("renders ComparePage at /compare", () => { - renderWithRouter("/compare"); - expect(screen.getByText("Compare")).toBeInTheDocument(); - }); - - it("renders AdminPage at /admin", () => { - renderWithRouter("/admin"); - expect(screen.getByText("Admin")).toBeInTheDocument(); - }); - - it("redirects unknown routes to dashboard", () => { - renderWithRouter("/nonexistent"); - 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(); + }); + }); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 04a14c1..afd2aa0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { Routes, Route, Navigate } from "react-router-dom"; +import { AuthProvider } from "./contexts/AuthContext"; import SetupPage from "./pages/SetupPage"; import LoginPage from "./pages/LoginPage"; import DashboardPage from "./pages/DashboardPage"; @@ -10,16 +11,18 @@ import AdminPage from "./pages/AdminPage"; export default function App() { return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); } diff --git a/frontend/src/contexts/AuthContext.test.tsx b/frontend/src/contexts/AuthContext.test.tsx new file mode 100644 index 0000000..865fc4e --- /dev/null +++ b/frontend/src/contexts/AuthContext.test.tsx @@ -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( + + {ui} + , + ); +} + +/** A component that exposes auth state for testing */ +function AuthConsumer() { + const { user, isAuthenticated, isLoading, login, logout } = useAuth(); + return ( +
+ {String(isLoading)} + {String(isAuthenticated)} + {user?.username ?? "none"} + + +
+ ); +} + +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(, "/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(); + + // 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(); + + 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(, "/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(, "/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(); + 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(, "/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(, "/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(, "/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( + + + , + ), + ).toThrow("useAuth must be used within an AuthProvider"); + spy.mockRestore(); + }); +}); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..ee5be59 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -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; + logout: () => void; +} + +const AuthContext = createContext(null); + +const PUBLIC_PATHS = ["/login", "/setup"]; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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( + () => ({ + user, + isAuthenticated, + isLoading, + login, + logout, + }), + [user, isAuthenticated, isLoading, login, logout], + ); + + return ( + {children} + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +}