diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx index d87eb492..33219c7e 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx @@ -104,16 +104,19 @@ export default function GatherRulesPage() { // Fetch unrestrictedMode from tenant config (for validation indicators). // The display flag lives in the member-readable feature-flags endpoint so that // Operators/Viewers can load this page without 403'ing on the admin-only full config. - // GA-override path keeps using globalConfig.tenant (GA-only endpoint). + // The SAME route serves the GA-override path: MemberRead has a global-scope bypass and + // the route is RouteParam-scoped, so a GA reads the SELECTED tenant's flag. (The former + // override URL /api/global/config/{tenantId} does not exist — the swallowed 404 left the + // badge showing the GA's HOME-tenant mode for every foreign tenant.) useEffect(() => { if (!effectiveTenantId) return; + // Reset first so a failed/forbidden fetch never carries the previous tenant's mode over. + setUnrestrictedMode(false); + let stale = false; const fetchConfig = async () => { try { - const url = isGlobalOverride - ? api.globalConfig.tenant(effectiveTenantId) - : api.config.featureFlags(effectiveTenantId); - const response = await authenticatedFetch(url, getAccessToken); - if (response.ok) { + const response = await authenticatedFetch(api.config.featureFlags(effectiveTenantId), getAccessToken); + if (!stale && response.ok) { const data = await response.json(); setUnrestrictedMode(data.unrestrictedMode ?? false); } @@ -122,7 +125,8 @@ export default function GatherRulesPage() { } }; fetchConfig(); - }, [effectiveTenantId, isGlobalOverride, getAccessToken]); + return () => { stale = true; }; + }, [effectiveTenantId, getAccessToken]); const handleToggleRule = async (rule: GatherRule) => { setTogglingRule(rule.ruleId); diff --git a/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts b/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts index 5b5d1a73..1f5439ec 100644 --- a/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts +++ b/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts @@ -50,6 +50,17 @@ export function useAuthenticatedFetch( const optionsRef = useRef(options); optionsRef.current = options; + // Latest-wins guard: when execute() is called again while a previous request is still in + // flight (e.g. the tenant-scope selector seeds a persisted foreign tenant right after mount, + // firing an own-tenant fetch immediately followed by the override fetch), only the most + // recently started request may write data/error/loading. Without this the LAST-RESOLVED + // response wins and a stale tenant's data can overwrite the current selection's result. + // The stale call still returns its parsed result to its direct awaiter. + const requestSeqRef = useRef(0); + // The loading spinner is owned by the last-started NON-silent request (a later silent + // background refresh must not leave a superseded visible request's spinner stuck on). + const loadingSeqRef = useRef(0); + const clearError = useCallback(() => setError(null), []); const execute = useCallback( @@ -58,8 +69,11 @@ export function useAuthenticatedFetch( init?: RequestInit, executeOptions?: ExecuteOptions, ): Promise => { + const seq = ++requestSeqRef.current; + const isCurrent = () => requestSeqRef.current === seq; try { if (!executeOptions?.silent) { + loadingSeqRef.current = seq; setLoading(true); } setError(null); @@ -86,9 +100,16 @@ export function useAuthenticatedFetch( const result = executeOptions?.transform ? executeOptions.transform(json) : (json as T); - setData(result); + if (isCurrent()) { + setData(result); + } return result; } catch (err) { + // A superseded request's failure is irrelevant to the current view — the newer + // request reports its own outcome. Swallow it entirely (no state, no callbacks). + if (!isCurrent()) { + return null; + } const opts = optionsRef.current; if (err instanceof TokenExpiredError) { if (opts?.onTokenExpired) { @@ -112,7 +133,9 @@ export function useAuthenticatedFetch( setError(message); return null; } finally { - if (!executeOptions?.silent) { + // Only the spinner's owner may clear it — a superseded request finishing early + // must not hide the loading state of the one still in flight. + if (!executeOptions?.silent && loadingSeqRef.current === seq) { setLoading(false); } } diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 2f0ba2bc..e2e51d23 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -151,7 +151,9 @@ export const api = { // ── Global Config (global admin) ────────────────────────────────────────── globalConfig: { get: () => `${API_BASE_URL}/api/global/config`, - tenant: (tenantId: string) => `${API_BASE_URL}/api/global/config/${tenantId}`, + // NOTE: there is deliberately no per-tenant variant — /api/global/config/{tenantId} does + // not exist in the backend. A tenant's member-readable flags (incl. unrestrictedMode) + // come from config.featureFlags, which a GA may call cross-tenant (MemberRead bypass). }, // ── Tenants ───────────────────────────────────────────────────────────────