Skip to content
Draft
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
83 changes: 49 additions & 34 deletions src/libs/Navigation/AppNavigator/withAgentAccessDenied.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Session} from '@src/types/onyx';

import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback} from 'react';
import {useFocusEffect, useIsFocused} from '@react-navigation/native';
import React, {useCallback, useEffect} from 'react';

const sessionEmailSelector = (session: Session | undefined) => session?.email;

Expand All @@ -22,47 +22,62 @@ function withAgentAccessDenied(getComponent: () => React.ComponentType): () => R
ProtectedComponent = (props) => {
const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: sessionEmailSelector});
const isAgent = isAgentEmail(sessionEmail);
const isFocused = useIsFocused();
const isAlreadyOnRedirectTarget = Navigation.isActiveRoute(ROUTES.SETTINGS_PROFILE.route);
const shouldRedirect = isAgent && !isAlreadyOnRedirectTarget;

// Redirect on every focus (not just the initial false->true transition) so navigating back
// onto a guarded screen that the split navigator keeps mounted (e.g. a stale agents route
// left over from the owner session) bounces the agent to a page they can access instead of
// rendering a blank pane.
useFocusEffect(
useCallback(() => {
if (!isAgent) {
const redirectAgentAway = useCallback(() => {
if (!isAgent) {
return;
}

// On a cold deep-link the effect can run before the NavigationContainer is ready, so the
// redirect is silently dropped and leaves a blank central pane. Wait for readiness before
// reading navigation state or dispatching.
Navigation.isNavigationReady().then(() => {
if (Navigation.isActiveRoute(ROUTES.SETTINGS_PROFILE.route)) {
return;
}

// On a cold deep-link the effect can run before the NavigationContainer is ready, so the
// redirect is silently dropped and leaves a blank central pane. Wait for readiness before
// reading navigation state or dispatching.
Navigation.isNavigationReady().then(() => {
if (Navigation.isActiveRoute(ROUTES.SETTINGS_PROFILE.route)) {
return;
}
// forceReplace REPLACEs the stale guarded central-pane route instead of PUSHing Profile on
// top of it, so back from Profile pops to the unguarded Account sidebar rather than the
// guarded route that would re-fire this redirect.
const redirectToProfile = () => Navigation.navigate(ROUTES.SETTINGS_PROFILE.getRoute(), {forceReplace: true});

// The guarded screen can be open inside a modal/RHP (e.g. the agent-edit page the owner was
// on when they tapped "Copilot into account"), or an unguarded RHP (e.g. the agent DM) can be
// sitting on top of this guarded central pane. Navigating straight to the tab-nested Profile
// route while an RHP is focused gets forced to PUSH (see linkTo), stacking Profile on top of
// the still-guarded route and trapping the user in a Profile <-> Profile loop on back. Dismiss
// the modal first, then redirect once it's closed (the underlying pane may be unguarded, so we
// can't rely on its guard to redirect).
if (Navigation.isTopmostRouteModalScreen()) {
Navigation.dismissModal({afterTransition: redirectToProfile});
return;
}

// forceReplace REPLACEs the stale guarded central-pane route instead of PUSHing Profile on
// top of it, so back from Profile pops to the unguarded Account sidebar rather than the
// guarded route that would re-fire this redirect.
const redirectToProfile = () => Navigation.navigate(ROUTES.SETTINGS_PROFILE.getRoute(), {forceReplace: true});
redirectToProfile();
});
}, [isAgent]);

// The guarded screen can be open inside a modal/RHP (e.g. the agent-edit page the owner was
// on when they tapped "Copilot into account"). Navigating straight to the tab-nested Profile
// route while an RHP is focused gets forced to PUSH (see linkTo), stacking Profile on top of
// the still-guarded route and trapping the user in a Profile <-> Profile loop on back. Dismiss
// the modal first, then redirect once it's closed (the underlying pane may be unguarded, so we
// can't rely on its guard to redirect).
if (Navigation.isTopmostRouteModalScreen()) {
Navigation.dismissModal({afterTransition: redirectToProfile});
return;
}
// Redirect on every focus (not just the initial false->true transition) so navigating back
// onto a guarded screen that the split navigator keeps mounted (e.g. a stale agents route
// left over from the owner session) bounces the agent to a page they can access instead of
// rendering a blank pane.
useFocusEffect(redirectAgentAway);

redirectToProfile();
});
}, [isAgent]),
);
// useFocusEffect only fires while this screen is focused. When the session flips to an agent while
// this guarded screen is mounted but NOT focused — e.g. the owner taps "Copilot into account" from
// an unguarded RHP (the agent DM) sitting over this guarded central pane — useFocusEffect never runs,
// so the pane renders null (blank background) until the RHP is closed. Drive the redirect off the
// isAgent transition here too so the background is corrected immediately. Skip when focused since
// useFocusEffect already covers that case.
useEffect(() => {
if (isFocused) {
return;
}
redirectAgentAway();
}, [isFocused, redirectAgentAway]);

if (shouldRedirect) {
return null;
Expand Down
38 changes: 37 additions & 1 deletion tests/unit/withAgentAccessDenied.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,25 @@ jest.mock('@libs/Navigation/Navigation', () => ({
isNavigationReady: jest.fn(() => Promise.resolve()),
}));

// Controls the simulated focus state of the guarded screen for both useFocusEffect (which only runs
// while focused) and useIsFocused. Defaults to focused; set to false to simulate a mounted-but-unfocused
// central pane (e.g. a guarded pane sitting behind an unguarded RHP).
let mockIsScreenFocused = true;

jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual<typeof NativeNavigation>('@react-navigation/native');
const react = jest.requireActual<typeof React>('react');
return {
...actualNav,
useFocusEffect: (effect: React.EffectCallback) => {
react.useEffect(effect, [effect]);
react.useEffect(() => {
if (!mockIsScreenFocused) {
return;
}
return effect();
}, [effect]);
},
useIsFocused: () => mockIsScreenFocused,
};
});

Expand Down Expand Up @@ -71,6 +82,7 @@ describe('withAgentAccessDenied', () => {
});

beforeEach(() => {
mockIsScreenFocused = true;
jest.mocked(Navigation.navigate).mockClear();
jest.mocked(Navigation.dismissModal).mockClear();
jest.mocked(Navigation.isActiveRoute).mockReturnValue(false);
Expand Down Expand Up @@ -118,6 +130,30 @@ describe('withAgentAccessDenied', () => {
expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.getRoute(), {forceReplace: true});
});

it('redirects a mounted-but-unfocused guarded pane when the session flips to an agent (copilot from an unguarded RHP)', async () => {
// Reproduces the deploy blocker: the owner taps "Copilot into account" from the agent DM, which lives in
// an unguarded RHP sitting over the guarded Agents central pane. That pane is mounted but NOT focused, so
// useFocusEffect never fires and it would render null (blank background). The focus-independent effect must
// still redirect. The agent DM RHP is the topmost modal, so it is dismissed first and the redirect deferred.
mockIsScreenFocused = false;
jest.mocked(Navigation.isTopmostRouteModalScreen).mockReturnValue(true);
await TestHelper.signInWithTestUser(1, 'agent_123@expensify.ai');
await waitForBatchedUpdatesWithAct();

renderComponent();
await waitForBatchedUpdatesWithAct();

await waitFor(() => {
expect(screen.queryByTestId('protected-content')).toBeNull();
expect(Navigation.dismissModal).toHaveBeenCalled();
expect(Navigation.navigate).not.toHaveBeenCalled();
});

const afterTransition = jest.mocked(Navigation.dismissModal).mock.calls.at(0)?.at(0)?.afterTransition;
act(() => afterTransition?.());
expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.getRoute(), {forceReplace: true});
});

it('shows access denied view instead of redirecting when agent is already on the redirect target', async () => {
jest.mocked(Navigation.isActiveRoute).mockReturnValue(true);
await TestHelper.signInWithTestUser(1, 'agent_123@expensify.ai');
Expand Down
Loading