Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

118 lines
3.6 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import type { Capability } from "@/lib/capability-routes";
import { apiFetch, apiUrl } from "@/lib/api";
import { listLLMOptions } from "@/lib/llm-options";
type CapabilityAccessValue = {
/** True until the first access probe resolves. */
loading: boolean;
/** Admins manage the catalog directly and are never gated. */
isAdmin: boolean;
/** Whether the current user has at least one usable LLM model. */
hasLlm: boolean;
/** Whether the current user may use a gated capability. */
has: (capability: Capability) => boolean;
/** Re-probe access (used on tab focus to pick up mid-session grant changes). */
refresh: () => Promise<void>;
};
// Default value keeps the hook safe outside a provider (e.g. shared components
// rendered in the admin tree): everything reads as available, and the backend
// remains the real enforcement boundary.
const DEFAULT_VALUE: CapabilityAccessValue = {
loading: false,
isAdmin: true,
hasLlm: true,
has: () => true,
refresh: async () => {},
};
const CapabilityAccessContext =
createContext<CapabilityAccessValue>(DEFAULT_VALUE);
export function useCapabilityAccess(): CapabilityAccessValue {
return useContext(CapabilityAccessContext);
}
export function CapabilityAccessProvider({
children,
}: {
children: ReactNode;
}) {
// Optimistic until the first probe resolves so we never flash a locked UI
// (admins and users-with-access stay unlocked; the backend enforces anyway).
const [isAdmin, setIsAdmin] = useState(true);
const [hasLlm, setHasLlm] = useState(true);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
// The settings payload only exposes the catalog to admins, so its
// presence is our admin signal — admins are never gated.
const res = await apiFetch(apiUrl("/api/v1/settings"));
if (!res.ok) return;
const payload = (await res.json()) as { catalog?: unknown };
if (payload.catalog) {
setIsAdmin(true);
setHasLlm(true);
return;
}
setIsAdmin(false);
// Non-admins: their grant-filtered LLM options decide access.
const { options } = await listLLMOptions();
setHasLlm(options.length > 0);
} catch {
// Keep last known state; the backend still enforces access on every call.
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Re-check when the user returns to the tab. This is what surfaces a
// mid-session revocation (admin removed the grant while the tab was open)
// without any polling — the next focus re-reads access and locks the UI.
useEffect(() => {
const onFocus = () => void refresh();
const onVisible = () => {
if (document.visibilityState === "visible") void refresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisible);
};
}, [refresh]);
const has = useCallback(
(_capability: Capability): boolean => {
// Only LLM is gated today. Admins and the pre-load window are never gated.
if (isAdmin || loading) return true;
return hasLlm;
},
[isAdmin, loading, hasLlm],
);
return (
<CapabilityAccessContext.Provider
value={{ loading, isAdmin, hasLlm, has, refresh }}
>
{children}
</CapabilityAccessContext.Provider>
);
}