Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,14 @@
// tuple check is satisfied by our write and it never consults
// the hint, so a cmd+click from an AI tab silently boots the new
// tab into Classic.
if (readAppModeSession()?.mode) {
// A returning tab (a `'manual'` or `'resolver'` tuple from a prior
// resolve) or a fresh tab that inherits an active hint from a
// sibling — leave both alone. `useResolvedAppMode` treats these as
// sticky and returns without rewriting. A `'boot'` tuple from an
// earlier auth cycle is NOT sticky and should be re-resolved, so
// don't skip on that.
const existingSession = readAppModeSession();
if (existingSession?.mode && existingSession.source !== 'boot') {
return;
}
const hint = readAppModeHint();
Expand All @@ -188,7 +195,18 @@

const userPref =
derivePreferencesFromList(prefsRes.preferences).appMode ?? null;
writeAppMode(resolveEffectiveAppMode(userPref, null, appDefault));

// Provisional boot write — persona isn't known synchronously (its
// docStore doc is fetched by `useResolvedAppMode`), so we compute
// the best guess from what IS available (userPref, appDefault) and
// mark it `source: 'boot'`. The async resolver is allowed to
// override this tuple once it has the persona-doc result and the
// route registry has settled. The `writeHint` call inside
// `writeAppMode` is skipped for `'boot'` writes so a provisional
// guess doesn't leak to sibling tabs as an authoritative hint.
writeAppMode(resolveEffectiveAppMode(userPref, null, appDefault), null, {
source: 'boot',
});
};

let requestInterceptor: number | null = null;
Expand Down Expand Up @@ -259,7 +277,7 @@

const clientType = authConfig?.clientType ?? ClientType.Public;

const onLoginHandler = () => {

Check warning on line 280 in openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

The 'onLoginHandler' function makes the dependencies of useMemo Hook (at line 918) change on every render. Move it inside the useMemo callback. Alternatively, wrap the definition of 'onLoginHandler' in its own useCallback() Hook
setApplicationLoading(true);

let attempts = 0;
Expand Down Expand Up @@ -336,7 +354,7 @@

// Upon logout, redirect to the login page
navigate(ROUTES.SIGNIN);
}, [timeoutId]);

Check warning on line 357 in openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

React Hook useCallback has missing dependencies: 'navigate', 'setApplicationLoading', 'setCurrentUser', and 'setIsAuthenticated'. Either include them or remove the dependency array

const handledVerifiedUser = () => {
if (!applicationRoutesClass.isProtectedRoute(location.pathname)) {
Expand Down Expand Up @@ -394,7 +412,7 @@
});
}, []);

const resetUserDetails = (forceLogout = false) => {

Check warning on line 415 in openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

The 'resetUserDetails' function makes the dependencies of useMemo Hook (at line 918) change on every render. To fix this, wrap the definition of 'resetUserDetails' in its own useCallback() Hook
setCurrentUser({} as User);
clearOidcToken();
setIsAuthenticated(false);
Expand Down Expand Up @@ -476,7 +494,7 @@
// After every refresh success, start timer again
tokenService.current.updateRefreshSuccessCallback(startTokenExpiryTimer);
}
}, [authenticatorRef.current?.renewIdToken]);

Check warning on line 497 in openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

React Hook useEffect has a missing dependency: 'startTokenExpiryTimer'. Either include it or remove the dependency array

// When the tab becomes visible after being backgrounded, browsers may have
// throttled or suspended the proactive renewal timer. Check token freshness
Expand Down Expand Up @@ -519,7 +537,7 @@
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);

Check warning on line 540 in openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

React Hook useEffect has a missing dependency: 'startTokenExpiryTimer'. Either include it or remove the dependency array

/**
* Performs cleanup around timers
Expand All @@ -529,7 +547,7 @@
clearTimeout(timeoutId);
}, [timeoutId]);

const handleFailedLogin = () => {

Check warning on line 550 in openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

The 'handleFailedLogin' function makes the dependencies of useMemo Hook (at line 918) change on every render. Move it inside the useMemo callback. Alternatively, wrap the definition of 'handleFailedLogin' in its own useCallback() Hook
setIsSigningUp(false);
setIsAuthenticated(false);
setApplicationLoading(false);
Expand Down Expand Up @@ -587,7 +605,7 @@
setApplicationLoading(false);
}
},
[

Check warning on line 608 in openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

React Hook useCallback has missing dependencies: 'authConfig?.provider', 'handledVerifiedUser', 'navigate', 'resetUserDetails', and 'startTokenExpiryTimer'. Either include them or remove the dependency array
authConfig?.enableSelfSignup,
clientType,
authorizerConfig?.principalDomain,
Expand Down Expand Up @@ -632,7 +650,7 @@
* Initialize Axios interceptors to intercept every request and response
* to handle appropriately. This should be called only when security is enabled.
*/
const initializeAxiosInterceptors = async () => {

Check warning on line 653 in openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

The 'initializeAxiosInterceptors' function makes the dependencies of useMemo Hook (at line 918) change on every render. To fix this, wrap the definition of 'initializeAxiosInterceptors' in its own useCallback() Hook
// Axios Request interceptor to add Bearer tokens in Header
if (requestInterceptor != null) {
axiosClient.interceptors.request.eject(requestInterceptor);
Expand Down Expand Up @@ -700,7 +718,7 @@
}
isRefreshDriverActive = true;

const drainPendingRequests = (hasNewToken: boolean) => {

Check warning on line 721 in openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

Refactor this code to not nest functions more than 4 levels deep
const queued = pendingRequests;
pendingRequests = [];
isRefreshDriverActive = false;
Expand All @@ -719,7 +737,7 @@

tokenService.current
.refreshToken()
.then(async (token) => {

Check warning on line 740 in openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

Refactor this code to not nest functions more than 4 levels deep
if (token) {
await initializeAxiosInterceptors();
drainPendingRequests(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ describe('writeAppMode', () => {
expect(JSON.parse(raw ?? '')).toEqual({
personaAppMode: 'ai',
mode: 'ai',
source: 'manual',
});
});

Expand All @@ -123,6 +124,7 @@ describe('writeAppMode', () => {
expect(JSON.parse(raw ?? '')).toEqual({
personaAppMode: 'ai',
mode: DEFAULT_APP_MODE,
source: 'manual',
});
});

Expand All @@ -134,6 +136,7 @@ describe('writeAppMode', () => {
expect(JSON.parse(raw ?? '')).toEqual({
personaAppMode: null,
mode: 'ai',
source: 'manual',
});
});

Expand All @@ -146,9 +149,42 @@ describe('writeAppMode', () => {
expect(JSON.parse(raw ?? '')).toEqual({
personaAppMode: null,
mode: 'ai',
source: 'manual',
});
});

it('records the source when `options.source` is passed', () => {
writeAppMode('ai', null, { source: 'boot' });

const raw = globalThis.window.sessionStorage.getItem(APP_MODE_SESSION_KEY);

expect(JSON.parse(raw ?? '')).toEqual({
personaAppMode: null,
mode: 'ai',
source: 'boot',
});
});

it('does NOT write the cross-tab hint for `source: "boot"` writes', () => {
writeAppMode('ai', null, { source: 'boot' });

// Boot-provisional writes must not leak to sibling tabs as an
// authoritative "user chose this" hint — the hint only carries
// deliberate user choices (`manual`) and resolver conclusions
// (`resolver`), not synchronous best-guesses.
expect(
globalThis.window.localStorage.getItem(APP_MODE_HINT_STORAGE_KEY)
).toBeNull();
});

it('writes the cross-tab hint for `source: "resolver"` writes', () => {
writeAppMode('ai', null, { source: 'resolver' });

expect(
globalThis.window.localStorage.getItem(APP_MODE_HINT_STORAGE_KEY)
).not.toBeNull();
});

it('writes the cross-tab hint to localStorage', () => {
writeAppMode('ai');

Expand Down
100 changes: 88 additions & 12 deletions openmetadata-ui/src/main/resources/ui/src/hooks/useAppMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,30 @@ import { usePersistentStorage } from './currentUserStore/useCurrentUserStore';
/**
* Payload persisted in `sessionStorage[APP_MODE_SESSION_KEY]`.
* `personaAppMode` is the value the resolver saw from the persona doc
* when this tuple was last written. `useResolvedAppMode` compares its
* current view of the persona's `appMode` against this snapshot to
* decide whether the persona has something new to say (invalidate the
* session) or not (respect the tab's chosen mode).
* when this tuple was last written.
*
* `source` records WHO wrote the tuple:
* - `'manual'` — a UI toggle (profile dropdown, switcher popover,
* plugin click). The user's active choice for this tab.
* - `'resolver'` — `useResolvedAppMode` after it has full context
* (persona doc, registered routes). The authoritative resolve.
* - `'boot'` — `AuthProvider.hydrateAndResolveAppMode`'s pre-persona
* write. **Provisional** — the resolver is allowed to override
* when it has better info (persona / registered routes).
* - `undefined` — legacy tuples from before this field existed;
* treated as `'manual'` (respect them). Backward-compatible.
*
* `useResolvedAppMode`'s "keep the current session" branch only fires
* when `source !== 'boot'`. Boot-provisional tuples are cleared and
* the resolver runs the full precedence chain with the async context
* it has now.
*/
export type AppModeSessionSource = 'manual' | 'resolver' | 'boot';

export interface AppModeSession {
personaAppMode: string | null;
mode: string;
source?: AppModeSessionSource;
}

/**
Expand Down Expand Up @@ -80,8 +96,19 @@ const readSession = (): AppModeSession | null => {
const tuple = parsed as AppModeSession;
const personaAppMode =
typeof tuple.personaAppMode === 'string' ? tuple.personaAppMode : null;

return { personaAppMode, mode: tuple.mode };
// Preserve `source` when it's one of the known values. Legacy
// tuples written before this field existed omit it — treat that
// as "manual" via the resolver's `source !== 'boot'` check.
const source: AppModeSessionSource | undefined =
tuple.source === 'manual' ||
tuple.source === 'resolver' ||
tuple.source === 'boot'
? tuple.source
: undefined;

return source
? { personaAppMode, mode: tuple.mode, source }
: { personaAppMode, mode: tuple.mode };
}
} catch {
// fall through — malformed payloads are treated as absent
Expand Down Expand Up @@ -259,21 +286,38 @@ export const useAppMode = (): string =>
* resolver saw from the persona doc at the moment of write. Callers
* that don't know the persona value (the switcher, the desktop lock)
* omit it and the current tuple's `personaAppMode` is preserved.
*
* `source` records who is writing:
* - `'manual'` (default) — a UI toggle; sticky against resolver
* override.
* - `'resolver'` — `useResolvedAppMode`'s authoritative write; sticky.
* - `'boot'` — `AuthProvider.hydrateAndResolveAppMode`'s pre-persona
* provisional write; overrideable by the async resolver once it
* has persona / route registry info.
*
* All existing callers (UI switches) default to `'manual'`, so this
* is a backward-compatible signature change.
*/
export const writeAppMode = (
mode: string,
personaAppMode?: string | null
personaAppMode?: string | null,
options?: { source?: AppModeSessionSource }
): void => {
const nextPersonaAppMode =
personaAppMode === undefined
? readSession()?.personaAppMode ?? null
: personaAppMode;
const source: AppModeSessionSource = options?.source ?? 'manual';

useAppModeStore.getState().setMode(mode);
writeSession({ personaAppMode: nextPersonaAppMode, mode });
writeSession({ personaAppMode: nextPersonaAppMode, mode, source });
// Cross-tab hint: sibling / newly-opened tabs read this at boot when
// their sessionStorage is empty (see APP_MODE_HINT_STORAGE_KEY docs).
writeHint(mode);
// Do NOT write the hint for `'boot'` writes: a boot-provisional
// tuple shouldn't leak to sibling tabs as a "user chose this" hint.
if (source !== 'boot') {
writeHint(mode);
}
};

export const clearAppMode = (): void => {
Expand All @@ -295,6 +339,24 @@ export const clearAppModeSessionOnly = (): void => {
removeSession();
};

/**
* Remove only the sessionStorage tuple; DO NOT touch the in-memory store
* or the cross-tab hint. Used by the resolver's boot-provisional override
* — the boot write has already set the store to a best-guess mode, and
* the resolver is about to overwrite the store via `writeAppMode(candidate)`
* once it has full context. Zustand notifies subscribers synchronously on
* every `set`, so calling `.reset()` between the boot write and the
* resolver's write forces a transient `currentMode === DEFAULT_APP_MODE`
* render that flips a non-default route ('/ai-automations',
* '/observability/*', etc.) through the Classic route tree's catch-all
* and redirects it to `/404` before the resolver's write lands. Leaving
* the store on boot's value means the only mode change subscribers see
* is the final resolver value, not an intermediate default.
*/
export const removeAppModeSession = (): void => {
removeSession();
};

/**
* Read the current session tuple. Exposed for the resolver, which needs
* to compare `personaAppMode` snapshots and decide whether the persisted
Expand Down Expand Up @@ -393,15 +455,29 @@ export const translateWireMode = (
wireMode ? CONFIG_MODE_TO_RUNTIME[wireMode] ?? null : null;

/**
* Resolve the effective app mode at boot from the fallback chain, highest
* precedence first:
* Canonical precedence for the "no valid session tuple, no fresh hint"
* case. Used by BOTH `AuthProvider.hydrateAndResolveAppMode` (boot-time
* pre-session write) and `useResolvedAppMode` (async resolver), so the
* two entry points can never encode different fallback chains.
*
* Signals, highest precedence first:
*
* 1. `userPref` — the user's own stored preference (`user_preferences`).
* 1. `userPref` — the user's own stored preference (`user_preferences`,
* aka the "remember" server-side toggle). A
* persistent user choice made through the switcher.
* 2. `personaMode` — the admin-curated persona app mode, when known.
* The group-level default for this user's persona.
* 3. `appDefault` — the tenant-wide "first impression" default
* (`appConfiguration.defaultAppMode`, translated).
* 4. `DEFAULT_APP_MODE` — the hardcoded constant, last resort.
*
* Not included here (higher-priority signals handled by callers):
* - Session tuple (`sessionStorage['omAppMode']`) — the manual in-tab
* switch, wins unconditionally when valid. See
* `useResolvedAppMode`'s `validSession` guard.
* - Fresh cross-tab hint (`localStorage['omAppModeHint']`) — sits
* between session and the chain above. See `useResolvedAppMode`.
*
* Pure — no side effects, no storage reads. Callers are responsible for
* supplying each signal (see `AuthProvider`'s bootstrap wiring).
*/
Expand Down
Loading
Loading