chrysopedia/frontend/src/context/AuthContext.tsx
jlightner 4917fd3a32 feat: Added LightRAG /query/data as primary search engine with file_sou…
- "backend/config.py"
- "backend/search_service.py"

GSD-Task: S01/T01
2026-04-04 04:44:24 +00:00

154 lines
3.9 KiB
TypeScript

import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import {
AUTH_TOKEN_KEY,
authLogin,
authGetMe,
authRegister,
impersonateUser,
stopImpersonation as apiStopImpersonation,
ApiError,
type UserResponse,
type RegisterRequest,
} from "../api";
const ADMIN_TOKEN_KEY = "chrysopedia_admin_token";
interface AuthContextValue {
user: UserResponse | null;
token: string | null;
isAuthenticated: boolean;
isImpersonating: boolean;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: RegisterRequest) => Promise<UserResponse>;
logout: () => void;
startImpersonation: (userId: string) => Promise<void>;
exitImpersonation: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserResponse | null>(null);
const [token, setToken] = useState<string | null>(() => {
try {
return localStorage.getItem(AUTH_TOKEN_KEY);
} catch {
return null;
}
});
const [loading, setLoading] = useState(!!token);
// Rehydrate session from stored token on mount
useEffect(() => {
if (!token) return;
let cancelled = false;
authGetMe(token)
.then((u) => {
if (!cancelled) setUser(u);
})
.catch(() => {
// Token expired or invalid — clear it
if (!cancelled) {
localStorage.removeItem(AUTH_TOKEN_KEY);
setToken(null);
setUser(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const login = useCallback(async (email: string, password: string) => {
const resp = await authLogin(email, password);
localStorage.setItem(AUTH_TOKEN_KEY, resp.access_token);
setToken(resp.access_token);
const me = await authGetMe(resp.access_token);
setUser(me);
}, []);
const register = useCallback(async (data: RegisterRequest) => {
return authRegister(data);
}, []);
const logout = useCallback(() => {
localStorage.removeItem(AUTH_TOKEN_KEY);
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
setToken(null);
setUser(null);
}, []);
const startImpersonation = useCallback(async (userId: string) => {
if (!token) return;
// Save admin token so we can restore it later
sessionStorage.setItem(ADMIN_TOKEN_KEY, token);
const resp = await impersonateUser(token, userId);
localStorage.setItem(AUTH_TOKEN_KEY, resp.access_token);
setToken(resp.access_token);
const me = await authGetMe(resp.access_token);
setUser(me);
}, [token]);
const exitImpersonation = useCallback(async () => {
// Try to call stop endpoint for audit log
if (token) {
try {
await apiStopImpersonation(token);
} catch {
// Best effort — still restore admin session
}
}
// Restore admin token
const adminToken = sessionStorage.getItem(ADMIN_TOKEN_KEY);
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
if (adminToken) {
localStorage.setItem(AUTH_TOKEN_KEY, adminToken);
setToken(adminToken);
const me = await authGetMe(adminToken);
setUser(me);
} else {
// Fallback: just logout
logout();
}
}, [token, logout]);
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!user,
isImpersonating: !!user?.impersonating,
loading,
login,
register,
logout,
startImpersonation,
exitImpersonation,
}}
>
{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;
}
export { ApiError };