Add StoreKit history and modernize admin console
Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user