mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user