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;
+}