feat(auth): AuthProvider context + useAuth hook
This commit is contained in:
@@ -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 }) => (
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("AuthProvider", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads user on mount", async () => {
|
||||||
|
(authApi.me as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("401")
|
||||||
|
);
|
||||||
|
(
|
||||||
|
authApi.login as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).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<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
username: "alice",
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
(
|
||||||
|
authApi.logout as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<AuthStatus>("loading");
|
||||||
|
const [user, setUser] = useState<User | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current auth context. Throws when used outside of an
|
||||||
|
* `<AuthProvider>` 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 <AuthProvider>");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user