fix(web): tenant-aware unrestricted badge + latest-wins fetch guard on rules pages

GA override on gather-rules called GET /api/global/config/{tenantId}, a route
that does not exist in the backend; the swallowed 404 left the unrestricted
badge showing the GA's HOME tenant's mode for every foreign tenant. Both paths
now read the member-readable config/{tenantId}/feature-flags (MemberRead has a
global-scope bypass and the route is RouteParam-scoped), with a reset-on-switch
so a failed fetch never carries the previous tenant's mode over. The dead
globalConfig.tenant entry is removed from the URL register.

useAuthenticatedFetch now enforces latest-wins: the scope hook seeds a
persisted foreign tenant only after mount, so an own-tenant fetch races the
override fetch and the last-RESOLVED response used to win, leaving the rules
list on the home tenant while the selector showed the override. Stale requests
no longer write data/error/loading; the spinner is owned by the last-started
non-silent request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-27 12:26:01 +02:00
parent 372f0c7c98
commit 82934a3719
3 changed files with 39 additions and 10 deletions
@@ -104,16 +104,19 @@ export default function GatherRulesPage() {
// Fetch unrestrictedMode from tenant config (for validation indicators). // Fetch unrestrictedMode from tenant config (for validation indicators).
// The display flag lives in the member-readable feature-flags endpoint so that // 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. // 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(() => { useEffect(() => {
if (!effectiveTenantId) return; 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 () => { const fetchConfig = async () => {
try { try {
const url = isGlobalOverride const response = await authenticatedFetch(api.config.featureFlags(effectiveTenantId), getAccessToken);
? api.globalConfig.tenant(effectiveTenantId) if (!stale && response.ok) {
: api.config.featureFlags(effectiveTenantId);
const response = await authenticatedFetch(url, getAccessToken);
if (response.ok) {
const data = await response.json(); const data = await response.json();
setUnrestrictedMode(data.unrestrictedMode ?? false); setUnrestrictedMode(data.unrestrictedMode ?? false);
} }
@@ -122,7 +125,8 @@ export default function GatherRulesPage() {
} }
}; };
fetchConfig(); fetchConfig();
}, [effectiveTenantId, isGlobalOverride, getAccessToken]); return () => { stale = true; };
}, [effectiveTenantId, getAccessToken]);
const handleToggleRule = async (rule: GatherRule) => { const handleToggleRule = async (rule: GatherRule) => {
setTogglingRule(rule.ruleId); setTogglingRule(rule.ruleId);
@@ -50,6 +50,17 @@ export function useAuthenticatedFetch<T = unknown>(
const optionsRef = useRef(options); const optionsRef = useRef(options);
optionsRef.current = 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 clearError = useCallback(() => setError(null), []);
const execute = useCallback( const execute = useCallback(
@@ -58,8 +69,11 @@ export function useAuthenticatedFetch<T = unknown>(
init?: RequestInit, init?: RequestInit,
executeOptions?: ExecuteOptions<T>, executeOptions?: ExecuteOptions<T>,
): Promise<T | null> => { ): Promise<T | null> => {
const seq = ++requestSeqRef.current;
const isCurrent = () => requestSeqRef.current === seq;
try { try {
if (!executeOptions?.silent) { if (!executeOptions?.silent) {
loadingSeqRef.current = seq;
setLoading(true); setLoading(true);
} }
setError(null); setError(null);
@@ -86,9 +100,16 @@ export function useAuthenticatedFetch<T = unknown>(
const result = executeOptions?.transform const result = executeOptions?.transform
? executeOptions.transform(json) ? executeOptions.transform(json)
: (json as T); : (json as T);
setData(result); if (isCurrent()) {
setData(result);
}
return result; return result;
} catch (err) { } 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; const opts = optionsRef.current;
if (err instanceof TokenExpiredError) { if (err instanceof TokenExpiredError) {
if (opts?.onTokenExpired) { if (opts?.onTokenExpired) {
@@ -112,7 +133,9 @@ export function useAuthenticatedFetch<T = unknown>(
setError(message); setError(message);
return null; return null;
} finally { } 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); setLoading(false);
} }
} }
+3 -1
View File
@@ -151,7 +151,9 @@ export const api = {
// ── Global Config (global admin) ────────────────────────────────────────── // ── Global Config (global admin) ──────────────────────────────────────────
globalConfig: { globalConfig: {
get: () => `${API_BASE_URL}/api/global/config`, 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 ─────────────────────────────────────────────────────────────── // ── Tenants ───────────────────────────────────────────────────────────────