MAESTRO: Implement AuthContext provider with JWT management, session validation, and protected route redirects
This commit is contained in:
parent
f60128604f
commit
0e6ae49b3c
5 changed files with 432 additions and 55 deletions
|
|
@ -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.
|
||||
<!-- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
|
|
@ -13,6 +20,12 @@ function renderWithRouter(route: string) {
|
|||
}
|
||||
|
||||
describe("App routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
client.clearToken();
|
||||
});
|
||||
|
||||
describe("public routes", () => {
|
||||
it("renders SetupPage at /setup", async () => {
|
||||
vi.spyOn(client.auth, "me").mockRejectedValue(
|
||||
new client.ApiError(401, "Unauthorized", {}),
|
||||
|
|
@ -21,46 +34,78 @@ describe("App routing", () => {
|
|||
await waitFor(() => {
|
||||
expect(screen.getByText("PromptLooper Setup")).toBeInTheDocument();
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders LoginPage at /login", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Projects")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders ExperimentPage at /experiments/:id", () => {
|
||||
it("renders ExperimentPage at /experiments/:id", async () => {
|
||||
renderWithRouter("/experiments/abc-123");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Experiment")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders LivePage at /live/:id", () => {
|
||||
it("renders LivePage at /live/:id", async () => {
|
||||
renderWithRouter("/live/abc-123");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders ComparePage at /compare", () => {
|
||||
it("renders ComparePage at /compare", async () => {
|
||||
renderWithRouter("/compare");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Compare")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders AdminPage at /admin", () => {
|
||||
it("renders AdminPage at /admin", async () => {
|
||||
renderWithRouter("/admin");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Admin")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects unknown routes to dashboard", () => {
|
||||
it("redirects unknown routes to dashboard", async () => {
|
||||
renderWithRouter("/nonexistent");
|
||||
await waitFor(() => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,6 +11,7 @@ import AdminPage from "./pages/AdminPage";
|
|||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
|
@ -21,5 +23,6 @@ export default function App() {
|
|||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
208
frontend/src/contexts/AuthContext.test.tsx
Normal file
208
frontend/src/contexts/AuthContext.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
120
frontend/src/contexts/AuthContext.tsx
Normal file
120
frontend/src/contexts/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue