From d895854dcc6b30f97ff1dd75c1b2682f946495b5 Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 22 Jun 2026 15:11:02 -0600 Subject: [PATCH] feat(auth): AuthProvider context + useAuth hook --- src/auth/AuthProvider.test.tsx | 78 ++++++++++++++++++++++++++++++++++ src/auth/AuthProvider.tsx | 58 +++++++++++++++++++++++++ src/auth/useAuth.ts | 56 ++++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 src/auth/AuthProvider.test.tsx create mode 100644 src/auth/AuthProvider.tsx create mode 100644 src/auth/useAuth.ts diff --git a/src/auth/AuthProvider.test.tsx b/src/auth/AuthProvider.test.tsx new file mode 100644 index 0000000..78c386d --- /dev/null +++ b/src/auth/AuthProvider.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; + +vi.mock("./api", () => ({ + authApi: { + me: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + }, +})); + +import { authApi } from "./api"; +import { AuthProvider, useAuth } from "./AuthProvider"; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("AuthProvider", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("loads user on mount", async () => { + (authApi.me as unknown as ReturnType).mockResolvedValue({ + id: 1, + username: "alice", + role: "user", + }); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("authenticated")); + expect(result.current.user?.username).toBe("alice"); + }); + + it("status is unauthenticated when /me fails", async () => { + (authApi.me as unknown as ReturnType).mockRejectedValue( + new Error("401") + ); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("unauthenticated")); + expect(result.current.user).toBeNull(); + }); + + it("login() updates state on success", async () => { + (authApi.me as unknown as ReturnType).mockRejectedValue( + new Error("401") + ); + ( + authApi.login as unknown as ReturnType + ).mockResolvedValue({ id: 1, username: "alice", role: "admin" }); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("unauthenticated")); + await act(async () => { + await result.current.login("alice", "pw"); + }); + expect(result.current.user?.username).toBe("alice"); + expect(result.current.status).toBe("authenticated"); + }); + + it("logout() clears state", async () => { + (authApi.me as unknown as ReturnType).mockResolvedValue({ + id: 1, + username: "alice", + role: "user", + }); + ( + authApi.logout as unknown as ReturnType + ).mockResolvedValue(undefined); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("authenticated")); + await act(async () => { + await result.current.logout(); + }); + expect(result.current.user).toBeNull(); + expect(result.current.status).toBe("unauthenticated"); + }); +}); diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..f51f46e --- /dev/null +++ b/src/auth/AuthProvider.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState, useCallback, type ReactNode } from "react"; +import { AuthContext, type AuthContextValue, type AuthStatus } from "./useAuth"; +import { authApi } from "./api"; +import type { User } from "@/types"; + +// Re-export useAuth so consumers can import both the context provider and +// the hook from the same module. The hook itself lives in `./useAuth` so +// the type definitions stay tree-shakeable independent of the provider. +export { useAuth } from "./useAuth"; + +/** + * Wraps the app and exposes the auth context. On mount it probes + * /api/auth/me to decide whether the existing `cyclone_session` + * cookie is still valid. Callers (route guard, sidebar, RoleGate, + * Login page) read the resulting `status` + `user` via `useAuth()`. + * + * - "loading" → while the probe is in flight + * - "authenticated" → probe succeeded; user populated + * - "unauthenticated"→ probe failed; user is null + * + * `login` and `logout` mutate local state directly so the UI flips + * without waiting for a second /me round-trip. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState("loading"); + const [user, setUser] = useState(null); + + const refresh = useCallback(async () => { + setStatus("loading"); + try { + const me = await authApi.me(); + setUser(me as User); + setStatus("authenticated"); + } catch { + setUser(null); + setStatus("unauthenticated"); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const login = useCallback(async (username: string, password: string) => { + const u = await authApi.login(username, password); + setUser(u as User); + setStatus("authenticated"); + }, []); + + const logout = useCallback(async () => { + await authApi.logout().catch(() => undefined); + setUser(null); + setStatus("unauthenticated"); + }, []); + + const value: AuthContextValue = { status, user, login, logout, refresh }; + return {children}; +} diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts new file mode 100644 index 0000000..aee1c54 --- /dev/null +++ b/src/auth/useAuth.ts @@ -0,0 +1,56 @@ +import { createContext, useContext } from "react"; +import type { User } from "@/types"; + +/** + * Tri-state lifecycle for the auth surface. + * + * - "loading" → we haven't asked /api/auth/me yet, or the result + * is still in flight. Route guards render a + * placeholder so we don't bounce the user to + * /login before the cookie has had a chance to + * prove itself. + * - "authenticated" → /me returned a real User. The rest of the app + * can safely render protected surfaces. + * - "unauthenticated"→ /me failed (no cookie, expired cookie, server + * down). Route guards redirect to /login. + */ +export type AuthStatus = "loading" | "authenticated" | "unauthenticated"; + +export interface AuthContextValue { + status: AuthStatus; + user: User | null; + /** + * Authenticate against /api/auth/login. Throws `ApiError` on failure + * (with `code` = "invalid_credentials" | "account_disabled" | + * "rate_limited" | "error"). On success, status flips to + * "authenticated" and `user` is populated. + */ + login: (username: string, password: string) => Promise; + /** + * Hit /api/auth/logout and clear local state. Always succeeds + * locally — even if the server is unreachable, the operator is + * effectively signed out from the SPA's perspective. + */ + logout: () => Promise; + /** + * Re-run the /api/auth/me probe. Useful after a profile edit on the + * admin pages so the sidebar reflects the new role without a + * full reload. + */ + refresh: () => Promise; +} + +export const AuthContext = createContext(null); + +/** + * Read the current auth context. Throws when used outside of an + * `` so consumers fail loudly during development instead + * of silently rendering the unauthenticated branch. + */ +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used inside "); + } + return ctx; +}