154 lines
3.9 KiB
TypeScript
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 };
|