231c5040a5
Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
110 lines
3.0 KiB
TypeScript
110 lines
3.0 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { adminApi, ApiError, setCsrfToken } from "../../api/client";
|
|
import type { AdminRole, AuthState } from "../../api/types";
|
|
|
|
interface AuthContextValue {
|
|
auth: AuthState;
|
|
checking: boolean;
|
|
login: (username: string, password: string, totpCode: string) => Promise<void>;
|
|
logout: () => Promise<void>;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const [auth, setAuth] = useState<AuthState>({ status: "anonymous" });
|
|
const [checking, setChecking] = useState(true);
|
|
|
|
const expireSession = useCallback(() => {
|
|
setCsrfToken();
|
|
setAuth({ status: "anonymous" });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
void adminApi
|
|
.session()
|
|
.then((session) => {
|
|
if (
|
|
active &&
|
|
session.authenticated &&
|
|
session.operatorName &&
|
|
isAdminRole(session.role)
|
|
) {
|
|
setAuth({
|
|
status: "authenticated",
|
|
operatorName: session.operatorName,
|
|
role: session.role,
|
|
});
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
window.dispatchEvent(
|
|
new CustomEvent("admin:toast", {
|
|
detail: { message: "暂时无法确认登录状态", tone: "error" },
|
|
}),
|
|
);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (active) setChecking(false);
|
|
});
|
|
|
|
window.addEventListener("admin:unauthorized", expireSession);
|
|
return () => {
|
|
active = false;
|
|
window.removeEventListener("admin:unauthorized", expireSession);
|
|
};
|
|
}, [expireSession]);
|
|
|
|
const login = useCallback(
|
|
async (username: string, password: string, totpCode: string) => {
|
|
const response = await adminApi.login(username, password, totpCode);
|
|
setCsrfToken(response.csrfToken);
|
|
setAuth({
|
|
status: "authenticated",
|
|
operatorName: response.operatorName,
|
|
role: response.role,
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const logout = useCallback(async () => {
|
|
try {
|
|
await adminApi.logout();
|
|
} catch {
|
|
// 即使服务端退出失败,也立即清理前端认证状态。
|
|
} finally {
|
|
expireSession();
|
|
window.history.replaceState(null, "", window.location.pathname);
|
|
}
|
|
}, [expireSession]);
|
|
|
|
const value = useMemo(
|
|
() => ({ auth, checking, login, logout }),
|
|
[auth, checking, login, logout],
|
|
);
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
export function useAuth(): AuthContextValue {
|
|
const context = useContext(AuthContext);
|
|
if (!context) throw new Error("useAuth 必须在 AuthProvider 中使用");
|
|
return context;
|
|
}
|
|
|
|
function isAdminRole(role?: AdminRole): role is AdminRole {
|
|
return role === "SUPER_ADMIN" || role === "SUPPORT" || role === "ANALYST";
|
|
}
|