From 1815f4ff7a00f3e59b620bc7ca158118d86fee55 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Tue, 12 May 2026 12:25:10 -0500 Subject: [PATCH 01/18] feat: Update settings pages to conditionally hide Wallet and Preferences sections for agent accounts --- src/pages/settings/InitialSettingsPage.tsx | 46 +++-- tests/ui/InitialSettingsPageTest.tsx | 230 +++++++++++++++++++++ 2 files changed, 258 insertions(+), 18 deletions(-) create mode 100644 tests/ui/InitialSettingsPageTest.tsx diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 7d1e8fe47f08..a2e13232aeac 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -44,6 +44,7 @@ import {hasPendingExpensifyCardAction} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import useIsSidebarRouteActive from '@libs/Navigation/helpers/useIsSidebarRouteActive'; import Navigation from '@libs/Navigation/Navigation'; +import {useIsAgentAccount} from '@libs/SessionUtils'; import {getFreeTrialText, hasSubscriptionRedDotError} from '@libs/SubscriptionUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {shouldHideOldAppRedirect} from '@libs/TryNewDotUtils'; @@ -170,6 +171,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT); const isLoadingTryNewDot = isLoadingOnyxValue(tryNewDotMetadata); const {isBetaEnabled} = usePermissions(); + const isAgentAccount = useIsAgentAccount(); const freeTrialText = getFreeTrialText(currentUserPersonalDetails.accountID, translate, policies, introSelected, firstDayFreeTrial, lastDayFreeTrial); @@ -261,15 +263,19 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr sentryLabel: CONST.SENTRY_LABEL.ACCOUNT.PROFILE, action: () => Navigation.navigate(ROUTES.SETTINGS_PROFILE.getRoute()), }, - { - translationKey: 'common.wallet', - icon: icons.Wallet, - screenName: SCREENS.SETTINGS.WALLET.ROOT, - brickRoadIndicator: walletBrickRoadIndicator, - sentryLabel: CONST.SENTRY_LABEL.ACCOUNT.WALLET, - action: () => Navigation.navigate(ROUTES.SETTINGS_WALLET), - badgeText: hasActivatedWallet ? convertToDisplayString(userWallet?.currentBalance, CONST.CURRENCY.USD) : undefined, - }, + ...(!isAgentAccount + ? [ + { + translationKey: 'common.wallet' as const, + icon: icons.Wallet, + screenName: SCREENS.SETTINGS.WALLET.ROOT, + brickRoadIndicator: walletBrickRoadIndicator, + sentryLabel: CONST.SENTRY_LABEL.ACCOUNT.WALLET, + action: () => Navigation.navigate(ROUTES.SETTINGS_WALLET), + badgeText: hasActivatedWallet ? convertToDisplayString(userWallet?.currentBalance, CONST.CURRENCY.USD) : undefined, + }, + ] + : []), { translationKey: 'expenseRulesPage.title', icon: icons.Bolt, @@ -277,13 +283,17 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr sentryLabel: CONST.SENTRY_LABEL.ACCOUNT.RULES, action: () => Navigation.navigate(ROUTES.SETTINGS_RULES), }, - { - translationKey: 'common.preferences', - icon: icons.Gear, - screenName: SCREENS.SETTINGS.PREFERENCES.ROOT, - sentryLabel: CONST.SENTRY_LABEL.ACCOUNT.PREFERENCES, - action: () => Navigation.navigate(ROUTES.SETTINGS_PREFERENCES), - }, + ...(!isAgentAccount + ? [ + { + translationKey: 'common.preferences' as const, + icon: icons.Gear, + screenName: SCREENS.SETTINGS.PREFERENCES.ROOT, + sentryLabel: CONST.SENTRY_LABEL.ACCOUNT.PREFERENCES, + action: () => Navigation.navigate(ROUTES.SETTINGS_PREFERENCES), + }, + ] + : []), { translationKey: 'delegate.copilot', icon: icons.Users, @@ -301,7 +311,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr }, ]; - if (isBetaEnabled(CONST.BETAS.CUSTOM_AGENT)) { + if (!isAgentAccount && isBetaEnabled(CONST.BETAS.CUSTOM_AGENT)) { const rulesIndex = accountItems.findIndex((item) => item.screenName === SCREENS.SETTINGS.RULES.ROOT); accountItems.splice(rulesIndex + 1, 0, { translationKey: 'agentsPage.title', @@ -314,7 +324,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr }); } - if (subscriptionPlan || (amountOwed ?? 0) > 0) { + if (!isAgentAccount && (subscriptionPlan || (amountOwed ?? 0) > 0)) { accountItems.splice(1, 0, { translationKey: 'allSettingsScreen.subscription', icon: icons.CreditCard, diff --git a/tests/ui/InitialSettingsPageTest.tsx b/tests/ui/InitialSettingsPageTest.tsx new file mode 100644 index 000000000000..8d1edc95c180 --- /dev/null +++ b/tests/ui/InitialSettingsPageTest.tsx @@ -0,0 +1,230 @@ +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render, screen, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import usePermissions from '@hooks/usePermissions'; +import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; +import {navigationRef} from '@libs/Navigation/Navigation'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {SettingsSplitNavigatorParamList} from '@libs/Navigation/types'; +import InitialSettingsPage from '@pages/settings/InitialSettingsPage'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + getActiveRoute: jest.fn(() => ''), + clearPreloadedRoutes: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useRoute: jest.fn(() => ({params: {}})), + createNavigationContainerRef: () => ({ + getState: () => jest.fn(), + }), + usePreventRemove: jest.fn(), + }; +}); + +jest.mock('@userActions/Wallet', () => ({ + openInitialSettingsPage: jest.fn(), +})); + +jest.mock('@userActions/App', () => ({ + confirmReadyToOpenApp: jest.fn(), + setLocale: jest.fn(), +})); + +jest.mock('@libs/Navigation/helpers/useIsSidebarRouteActive', () => jest.fn(() => false)); + +jest.mock('@hooks/useSubscriptionPlan', () => jest.fn(() => null)); + +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => false}))); + +jest.mock('@components/AccountSwitcher', () => { + function MockAccountSwitcher() { + return null; + } + MockAccountSwitcher.displayName = 'AccountSwitcher'; + return MockAccountSwitcher; +}); + +jest.mock('@components/AccountSwitcherSkeletonView', () => { + function MockAccountSwitcherSkeletonView() { + return null; + } + MockAccountSwitcherSkeletonView.displayName = 'AccountSwitcherSkeletonView'; + return MockAccountSwitcherSkeletonView; +}); + +jest.mock('@components/Navigation/TabBarBottomContent', () => { + function MockTabBarBottomContent() { + return null; + } + MockTabBarBottomContent.displayName = 'TabBarBottomContent'; + return MockTabBarBottomContent; +}); + +jest.mock('@components/Navigation/TopBarWithLoadingBar', () => { + function MockTopBarWithLoadingBar() { + return null; + } + MockTopBarWithLoadingBar.displayName = 'TopBarWithLoadingBar'; + return MockTopBarWithLoadingBar; +}); + +jest.mock('@components/MenuItem', () => { + const ReactMock = require('react') as typeof React; + const {Text} = require('react-native') as {Text: React.ComponentType<{testID: string; children?: React.ReactNode}>}; + return ({title}: {title: string}) => ReactMock.createElement(Text, {testID: `menu-item-${String(title)}`}, title); +}); + +const mockUsePermissions = jest.mocked(usePermissions); +const mockUseSubscriptionPlan = jest.mocked(useSubscriptionPlan); + +const Stack = createPlatformStackNavigator(); + +function renderPage() { + return render( + + + + + + + + + , + ); +} + +describe('InitialSettingsPage - agent account', () => { + const accountID = 123; + + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, 'en' as const); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + jest.clearAllMocks(); + mockUsePermissions.mockImplementation(() => ({isBetaEnabled: () => false})); + mockUseSubscriptionPlan.mockImplementation(() => null); + }); + + async function setupUser(email: string) { + await TestHelper.signInWithTestUser(accountID, email); + + const personalDetails: PersonalDetailsList = { + [accountID]: { + accountID, + login: email, + displayName: email, + avatar: 'https://example.com/avatar.png', + avatarThumbnail: 'https://example.com/avatar.png', + } as PersonalDetails, + }; + + await act(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + await waitForBatchedUpdatesWithAct(); + } + + it('hides Wallet and Preferences for agent account', async () => { + await setupUser('agent_123@expensify.ai'); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.queryByTestId('menu-item-Wallet')).toBeNull(); + expect(screen.queryByTestId('menu-item-Preferences')).toBeNull(); + }); + }); + + it('shows Wallet and Preferences for non-agent account', async () => { + await setupUser('user@expensify.com'); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('menu-item-Wallet')).toBeDefined(); + expect(screen.getByTestId('menu-item-Preferences')).toBeDefined(); + }); + }); + + it('hides Subscription for agent account', async () => { + mockUseSubscriptionPlan.mockReturnValue(CONST.POLICY.TYPE.TEAM); + await setupUser('agent_123@expensify.ai'); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.queryByTestId('menu-item-Subscription')).toBeNull(); + }); + }); + + it('shows Subscription for non-agent account', async () => { + mockUseSubscriptionPlan.mockReturnValue(CONST.POLICY.TYPE.TEAM); + await setupUser('user@expensify.com'); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('menu-item-Subscription')).toBeDefined(); + }); + }); + + it('hides Agents for agent account when CUSTOM_AGENT beta is enabled', async () => { + mockUsePermissions.mockReturnValue({isBetaEnabled: (beta: string) => beta === CONST.BETAS.CUSTOM_AGENT}); + await setupUser('agent_123@expensify.ai'); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.queryByTestId('menu-item-Agents')).toBeNull(); + }); + }); + + it('shows Agents for non-agent account when CUSTOM_AGENT beta is enabled', async () => { + mockUsePermissions.mockReturnValue({isBetaEnabled: (beta: string) => beta === CONST.BETAS.CUSTOM_AGENT}); + await setupUser('user@expensify.com'); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('menu-item-Agents')).toBeDefined(); + }); + }); +}); From 4d29bc2ce52a5dd7ce99fbb1ec9527f49ce8f68e Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Tue, 12 May 2026 14:31:57 -0500 Subject: [PATCH 02/18] feat: Add AGENT access restriction to DelegateNoAccessWrapper across various settings pages --- src/CONST/index.ts | 1 + src/components/DelegateNoAccessWrapper.tsx | 19 ++-- src/pages/settings/Agents/AgentsPage.tsx | 85 +++++++++--------- .../Profile/Contacts/ContactMethodsPage.tsx | 87 ++++++++++--------- src/pages/settings/Profile/PronounsPage.tsx | 55 ++++++------ .../settings/Profile/TimezoneInitialPage.tsx | 83 +++++++++--------- .../settings/Profile/TimezoneSelectPage.tsx | 43 ++++----- 7 files changed, 199 insertions(+), 174 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a13684b46d0a..771fc3f9a806 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6421,6 +6421,7 @@ const CONST = { DENIED_ACCESS_VARIANTS: { DELEGATE: 'delegate', SUBMITTER: 'submitter', + AGENT: 'agent', }, }, DELEGATE_ROLE_HELP_DOT_ARTICLE_LINK: 'https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/', diff --git a/src/components/DelegateNoAccessWrapper.tsx b/src/components/DelegateNoAccessWrapper.tsx index 8ba97901ca56..e2c6e65ee41b 100644 --- a/src/components/DelegateNoAccessWrapper.tsx +++ b/src/components/DelegateNoAccessWrapper.tsx @@ -4,18 +4,26 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import AccountUtils from '@libs/AccountUtils'; import Navigation from '@libs/Navigation/Navigation'; +import {isAgentEmail} from '@libs/SessionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Account} from '@src/types/onyx'; +import type {Account, Session} from '@src/types/onyx'; import callOrReturn from '@src/types/utils/callOrReturn'; import FullPageNotFoundView from './BlockingViews/FullPageNotFoundView'; +type AccessContext = { + account: OnyxEntry; + session: OnyxEntry; +}; + const DENIED_ACCESS_VARIANTS = { // To Restrict All Delegates From Accessing The Page. - [CONST.DELEGATE.DENIED_ACCESS_VARIANTS.DELEGATE]: (account: OnyxEntry) => isDelegate(account), + [CONST.DELEGATE.DENIED_ACCESS_VARIANTS.DELEGATE]: ({account}: AccessContext) => isDelegate(account), // To Restrict Only Limited Access Delegates From Accessing The Page. - [CONST.DELEGATE.DENIED_ACCESS_VARIANTS.SUBMITTER]: (account: OnyxEntry) => isSubmitter(account), -} as const satisfies Record) => boolean>; + [CONST.DELEGATE.DENIED_ACCESS_VARIANTS.SUBMITTER]: ({account}: AccessContext) => isSubmitter(account), + // To Restrict Agent Accounts From Accessing The Page. + [CONST.DELEGATE.DENIED_ACCESS_VARIANTS.AGENT]: ({session}: AccessContext) => isAgentEmail(session?.email), +} as const satisfies Record boolean>; type AccessDeniedVariants = keyof typeof DENIED_ACCESS_VARIANTS; @@ -38,9 +46,10 @@ function isSubmitter(account: OnyxEntry) { function DelegateNoAccessWrapper({accessDeniedVariants = [], shouldForceFullScreen, onBackButtonPress, ...props}: DelegateNoAccessWrapperProps) { const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [session] = useOnyx(ONYXKEYS.SESSION); const isPageAccessDenied = accessDeniedVariants.reduce((acc, variant) => { const accessDeniedFunction = DENIED_ACCESS_VARIANTS[variant]; - return acc || accessDeniedFunction(account); + return acc || accessDeniedFunction({account, session}); }, false); const {shouldUseNarrowLayout} = useResponsiveLayout(); diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx index dbfd4b64cc41..2da655ed3771 100644 --- a/src/pages/settings/Agents/AgentsPage.tsx +++ b/src/pages/settings/Agents/AgentsPage.tsx @@ -1,6 +1,7 @@ import React, {useEffect} from 'react'; import {FlatList, View} from 'react-native'; import Button from '@components/Button'; +import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -116,48 +117,50 @@ function AgentsPage() { } return ( - - Navigation.goBack()} - shouldShowBackButton={shouldUseNarrowLayout} - shouldUseHeadlineHeader - shouldDisplaySearchRouter - shouldDisplayHelpButton - title={translate('agentsPage.title')} + + - {!shouldUseNarrowLayout && newAgentButton} - - {shouldUseNarrowLayout && {newAgentButton}} - {hasAgents ? ( - <> - {translate('agentsPage.subtitle')} - - - ) : ( - - - - )} - + Navigation.goBack()} + shouldShowBackButton={shouldUseNarrowLayout} + shouldUseHeadlineHeader + shouldDisplaySearchRouter + shouldDisplayHelpButton + title={translate('agentsPage.title')} + > + {!shouldUseNarrowLayout && newAgentButton} + + {shouldUseNarrowLayout && {newAgentButton}} + {hasAgents ? ( + <> + {translate('agentsPage.subtitle')} + + + ) : ( + + + + )} + + ); } diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index 43feb1062dc8..44b53e04984e 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; @@ -60,48 +61,50 @@ function ContactMethodsPage({route}: ContactMethodsPageProps) { }, [navigateBackTo, isActingAsDelegate, showDelegateNoAccessModal, isAccountLocked, isUserValidated, showLockedAccountModal]); return ( - - Navigation.goBack()} - /> - - - - - {options.map( - (option) => - !!option && ( - - Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(option.partnerUserID, navigateBackTo))} - brickRoadIndicator={option.indicator} - shouldShowBasicTitle - shouldShowRightIcon - disabled={!!option.pendingAction} - /> - - ), - )} - -