From dc60b27f3d0d5347c8452e9716c3c48555c67cac Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Mon, 10 Aug 2026 09:31:10 +0000 Subject: [PATCH 1/9] Allow promoting the Authorized Payer to Workspace Admin Co-authored-by: Shridhar Goel --- src/components/WorkspaceMemberRoleList.tsx | 9 +++- .../members/WorkspaceMemberDetailsPage.tsx | 9 ++-- .../WorkspaceMemberDetailsRolePage.tsx | 11 +++- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 52 +++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index c27b37694980..0e1c85ad8042 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -34,9 +34,12 @@ type WorkspaceMemberRoleListProps = { navigateBackTo?: Route; isLoading?: boolean; onSelectRole?: (value: ListItemType) => void; + + /** When provided, restricts the selectable roles to this set (e.g. an Authorized Payer may only be promoted to Admin) */ + allowedRoles?: Array>; }; -function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}}: WorkspaceMemberRoleListProps) { +function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}, allowedRoles = undefined}: WorkspaceMemberRoleListProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); @@ -86,7 +89,9 @@ function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLo }, ]; - const availableRoleItems: ListItemType[] = workspaceRoles.filter((item) => canMemberAssignRole(policy, currentUserLogin, item.value)); + const availableRoleItems: ListItemType[] = workspaceRoles.filter( + (item) => canMemberAssignRole(policy, currentUserLogin, item.value) && (!allowedRoles || allowedRoles.includes(item.value)), + ); return ( <> diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 1b83cd170eeb..43232cfef383 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -147,6 +147,9 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; + // An Authorized Payer (reimburser) must stay an admin, so lock the Role row only once they are already an Admin — every + // remaining change would be a demotion that breaks reimbursements. A non-admin payer can still be promoted to Admin. + const isReimburserAdmin = isReimburser && member?.role === CONST.POLICY.ROLE.ADMIN; const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); @@ -380,9 +383,9 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM { if ( @@ -397,7 +400,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM } Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, accountID)); }} - hintText={isReimburser ? translate('common.roleCannotBeChanged', workspaceWorkflowsPageURL) : undefined} + hintText={isReimburserAdmin ? translate('common.roleCannotBeChanged', workspaceWorkflowsPageURL) : undefined} shouldRenderHintAsHTML /> {isControlPolicy(policy) && ( diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 2876830990d5..a754989b6edf 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -11,7 +11,7 @@ import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {canMemberAssignRole} from '@libs/PolicyUtils'; +import {canMemberAssignRole, getReimburserEmail} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -40,6 +40,10 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); + // The Authorized Payer (reimburser) must remain an admin, so they may only be promoted to Admin — never demoted to another role. + const reimburserEmail = getReimburserEmail(policy); + const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; + const allowedRoles = isReimburser ? [CONST.POLICY.ROLE.ADMIN] : undefined; useRedirectSubmitWorkspaceFeatureUpgrade({ policy, backTo: ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID), @@ -53,6 +57,10 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp if (!canMemberAssignRole(policy, currentUserLogin, value)) { return; } + // Guard the direct-navigation path: a reimburser can only be promoted to Admin, so reject any demotion. + if (isReimburser && value !== CONST.POLICY.ROLE.ADMIN) { + return; + } if (value !== CONST.POLICY.ROLE.ADMIN && isRuleBotEnforcingRules(accountID, policy)) { showRuleBotGuardModal('changeRole', policyID); return; @@ -76,6 +84,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp role={member?.role} policy={policy} onSelectRole={changeRole} + allowedRoles={allowedRoles} navigateBackTo={ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)} /> diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index 10ae88451ad7..ffedaa0050b7 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -67,6 +67,8 @@ describe('WorkspaceMemberDetailsPage', () => { const primaryAccountID = 7777; const primaryEmail = 'primary@example.com'; const secondaryEmail = 'secondary@example.com'; + const adminPayerAccountID = 8888; + const adminPayerEmail = 'adminpayer@example.com'; const policy = { ...LHNTestUtils.getFakePolicy(), @@ -83,6 +85,7 @@ describe('WorkspaceMemberDetailsPage', () => { [invitedEmail]: {email: invitedEmail, role: CONST.POLICY.ROLE.USER}, [phoneLogin]: {email: phoneLogin, role: CONST.POLICY.ROLE.USER}, [primaryEmail]: {email: primaryEmail, role: CONST.POLICY.ROLE.USER}, + [adminPayerEmail]: {email: adminPayerEmail, role: CONST.POLICY.ROLE.ADMIN}, }, }; @@ -102,6 +105,7 @@ describe('WorkspaceMemberDetailsPage', () => { [invitedAccountID]: TestHelper.buildPersonalDetails(invitedEmail, invitedAccountID, 'Invited'), [phoneAccountID]: TestHelper.buildPersonalDetails(phoneLogin, phoneAccountID, 'Phone'), [primaryAccountID]: TestHelper.buildPersonalDetails(primaryEmail, primaryAccountID, 'Primary'), + [adminPayerAccountID]: TestHelper.buildPersonalDetails(adminPayerEmail, adminPayerAccountID, 'AdminPayer'), }); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); }); @@ -223,6 +227,54 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); + it('should not lock the Role field for a non-admin Authorized Payer so they can be promoted to Admin', async () => { + // Make the invited member (a plain USER) the workspace Authorized Payer. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: invitedEmail, + }); + }); + + const {unmount} = renderPage({policyID: policy.id, accountID: String(invitedAccountID)}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('WorkspaceMemberDetailsPage')).toBeOnTheScreen(); + }); + + // The locked hint must NOT be shown — a non-admin payer can still be promoted to Admin. + expect(screen.queryByText(/Role can/)).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should lock the Role field for an Authorized Payer who is already an Admin', async () => { + // The admin member is the workspace Authorized Payer — every remaining role change would be a demotion. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: adminPayerEmail, + }); + }); + + const {unmount} = renderPage({policyID: policy.id, accountID: String(adminPayerAccountID)}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('WorkspaceMemberDetailsPage')).toBeOnTheScreen(); + }); + + // The locked hint IS shown — an admin payer cannot be demoted while they remain the payer. + await waitFor(() => { + expect(screen.getByText(/Role can/)).toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should show the not found page when the accountID matches no workspace member', async () => { const {unmount} = renderPage({policyID: policy.id, accountID: '999999'}); await waitForBatchedUpdatesWithAct(); From ada29a107694e29ad3da43d6ad7dd6d9bc634df7 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Mon, 10 Aug 2026 20:03:07 +0000 Subject: [PATCH 2/9] Reword Authorized Payer role-lock comment per review suggestion Co-authored-by: Shridhar Goel --- src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 43232cfef383..d45aa125b522 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -147,7 +147,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - // An Authorized Payer (reimburser) must stay an admin, so lock the Role row only once they are already an Admin — every + // An Authorized Payer (reimburser) must be either an admin or a payments admin. Lock the Role row only once they are already an Admin — every // remaining change would be a demotion that breaks reimbursements. A non-admin payer can still be promoted to Admin. const isReimburserAdmin = isReimburser && member?.role === CONST.POLICY.ROLE.ADMIN; const {isAccountLocked} = useLockedAccountState(); From d27cd9924d25b4174a898c9422520637e3a92709 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Mon, 10 Aug 2026 20:32:23 +0000 Subject: [PATCH 3/9] Allow the Authorized Payer to be Admin or Payments Admin, not Admin only Co-authored-by: Shridhar Goel --- src/components/WorkspaceMemberRoleList.tsx | 2 +- src/pages/workspace/WorkspaceMembersPage.tsx | 6 ++-- .../members/WorkspaceMemberDetailsPage.tsx | 12 ++----- .../WorkspaceMemberDetailsRolePage.tsx | 8 ++--- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 10 +++--- tests/ui/WorkspaceMembersTest.tsx | 34 ++++++++++++------- 6 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index 0e1c85ad8042..b569350f1969 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -35,7 +35,7 @@ type WorkspaceMemberRoleListProps = { isLoading?: boolean; onSelectRole?: (value: ListItemType) => void; - /** When provided, restricts the selectable roles to this set (e.g. an Authorized Payer may only be promoted to Admin) */ + /** When provided, restricts the selectable roles to this set (e.g. an Authorized Payer may only be an Admin or Payments Admin) */ allowedRoles?: Array>; }; diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 66c57827ec41..7548bd96d656 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -590,7 +590,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers options.push(memberOption); } - if (hasAtLeastOneNonAdminRole && !hasAtLeastOnePayer && canAssignElevatedRoles) { + // Admin is a valid payer role, so the payer may be promoted to Admin (Admin and Payments Admin are the two roles that can pay). + if (hasAtLeastOneNonAdminRole && canAssignElevatedRoles) { options.push(adminOption); } @@ -612,7 +613,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers options.push(peopleAdminOption); } - if (hasAtLeastOneNonPaymentsAdminRole && isControlPolicy(policy) && !hasAtLeastOnePayer && canAssignElevatedRoles) { + // Payments Admin is a valid payer role, so the payer may be changed to Payments Admin (Admin and Payments Admin are the two roles that can pay). + if (hasAtLeastOneNonPaymentsAdminRole && isControlPolicy(policy) && canAssignElevatedRoles) { options.push(paymentsAdminOption); } diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index d45aa125b522..49f8d692e036 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -16,7 +16,6 @@ import {useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons'; import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useEnvironment from '@hooks/useEnvironment'; import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -114,7 +113,6 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const illustrations = useThemeIllustrations(); const companyCardFeedIcons = useCompanyCardFeedIcons(); const {accountID: currentUserAccountID, login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); - const {environmentURL} = useEnvironment(); const [cardFeeds] = useCardFeeds(policyID); const [cardList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); @@ -142,14 +140,10 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails); const policyOwnerDisplayName = formatPhoneNumber(temporaryGetDisplayNameOrDefault({passedPersonalDetails: ownerDetails, translate})) ?? policy?.owner ?? ''; const {cardList: assignableCards, ...workspaceCards} = getAllCardsForWorkspace(workspaceAccountID, cardList, cardFeeds, expensifyCardSettings); - const workspaceWorkflowsPageURL = `${environmentURL}/${ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)}`; const isSMSLogin = Str.isSMSLogin(memberLogin); const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - // An Authorized Payer (reimburser) must be either an admin or a payments admin. Lock the Role row only once they are already an Admin — every - // remaining change would be a demotion that breaks reimbursements. A non-admin payer can still be promoted to Admin. - const isReimburserAdmin = isReimburser && member?.role === CONST.POLICY.ROLE.ADMIN; const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); @@ -383,9 +377,9 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM { if ( @@ -400,8 +394,6 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM } Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, accountID)); }} - hintText={isReimburserAdmin ? translate('common.roleCannotBeChanged', workspaceWorkflowsPageURL) : undefined} - shouldRenderHintAsHTML /> {isControlPolicy(policy) && ( <> diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index a754989b6edf..de2f5614d73d 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -40,10 +40,10 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); - // The Authorized Payer (reimburser) must remain an admin, so they may only be promoted to Admin — never demoted to another role. + // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to Admin or Payments Admin — the two roles that can pay. const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - const allowedRoles = isReimburser ? [CONST.POLICY.ROLE.ADMIN] : undefined; + const allowedRoles = isReimburser ? [CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN] : undefined; useRedirectSubmitWorkspaceFeatureUpgrade({ policy, backTo: ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID), @@ -57,8 +57,8 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp if (!canMemberAssignRole(policy, currentUserLogin, value)) { return; } - // Guard the direct-navigation path: a reimburser can only be promoted to Admin, so reject any demotion. - if (isReimburser && value !== CONST.POLICY.ROLE.ADMIN) { + // Guard the direct-navigation path: a reimburser must stay a valid payer, so reject any role other than Admin or Payments Admin. + if (isReimburser && value !== CONST.POLICY.ROLE.ADMIN && value !== CONST.POLICY.ROLE.PAYMENTS_ADMIN) { return; } if (value !== CONST.POLICY.ROLE.ADMIN && isRuleBotEnforcingRules(accountID, policy)) { diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index ffedaa0050b7..9a4bc5594b77 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -250,8 +250,8 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); - it('should lock the Role field for an Authorized Payer who is already an Admin', async () => { - // The admin member is the workspace Authorized Payer — every remaining role change would be a demotion. + it('should not lock the Role field for an Authorized Payer who is already an Admin so they can be changed to Payments Admin', async () => { + // The admin member is the workspace Authorized Payer — Payments Admin is also a valid payer, so a lateral change is allowed. await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, @@ -266,10 +266,8 @@ describe('WorkspaceMemberDetailsPage', () => { expect(screen.getByTestId('WorkspaceMemberDetailsPage')).toBeOnTheScreen(); }); - // The locked hint IS shown — an admin payer cannot be demoted while they remain the payer. - await waitFor(() => { - expect(screen.getByText(/Role can/)).toBeOnTheScreen(); - }); + // The locked hint must NOT be shown — an admin payer can still be changed to Payments Admin, another valid payer role. + expect(screen.queryByText(/Role can/)).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index 2876f57b72bc..0e5d5ea4d547 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -426,11 +426,10 @@ describe('WorkspaceMembers', () => { await waitForBatchedUpdatesWithAct(); }); - it('should hide role-change options when the selected member is the Authorized Payer resolved via policy.reimburser', async () => { + it('should hide demotions but offer Make payments admin when the selected member is the Authorized Payer resolved via policy.reimburser', async () => { // Given a workspace whose Authorized Payer is an admin configured through policy.reimburser - // (the canonical resolution) rather than achAccount.reimburser. On the buggy code the guard - // only read achAccount.reimburser, so it failed to recognize this payer and wrongly offered - // the role-change options. + // (the canonical resolution) rather than achAccount.reimburser. Demotions to roles that cannot + // pay must stay hidden, but changing to Payments Admin (the other valid payer role) must be offered. await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, @@ -456,7 +455,7 @@ describe('WorkspaceMembers', () => { expect(screen.getByTestId(`PopoverMenuItem-${removeText}`)).toBeOnTheScreen(); }); - // ...and none of the role-change options are offered for the payer + // ...the demotions that would strip the payer of pay capability are hidden const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); expect(screen.queryByTestId(`PopoverMenuItem-${makeMemberText}`)).not.toBeOnTheScreen(); @@ -466,16 +465,18 @@ describe('WorkspaceMembers', () => { const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1}); expect(screen.queryByTestId(`PopoverMenuItem-${makeCardAdminText}`)).not.toBeOnTheScreen(); + // ...but Make payments admin IS offered — Payments Admin is a valid payer role + const makePaymentsAdminText = TestHelper.translateLocal('workspace.people.makePaymentsAdmin', {count: 1}); + expect(screen.getByTestId(`PopoverMenuItem-${makePaymentsAdminText}`)).toBeOnTheScreen(); + unmount(); await waitForBatchedUpdatesWithAct(); }); - it('should hide the Make workspace admin option when the selected member is a Payments Admin who is the Authorized Payer', async () => { - // Given a Payments Admin who is also the Authorized Payer. PAYMENTS_ADMIN is the only non-admin - // role with write access to WORKFLOWS_PAYMENTS, so it is the sole role that can hold the payer - // role without already being an admin — which makes it the only path that can reach the - // "Make workspace admin" option. Every other role-change option is already gated on the payer, - // but adminOption was not, so it was wrongly offered for this payer. + it('should offer Make workspace admin but hide demotions when the selected member is a Payments Admin who is the Authorized Payer', async () => { + // Given a Payments Admin who is also the Authorized Payer. Admin and Payments Admin are both valid + // payer roles, so promoting this payer to Admin keeps them a valid payer and must be offered. + // Every demotion to a role that cannot pay (Member, Auditor, Card Admin) stays gated on the payer. await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, @@ -504,9 +505,16 @@ describe('WorkspaceMembers', () => { expect(screen.getByTestId(`PopoverMenuItem-${removeText}`)).toBeOnTheScreen(); }); - // ...but "Make workspace admin" is hidden for the payer, even though their role is not admin + // ...and "Make workspace admin" IS offered — Admin is a valid payer role const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makeAdminText}`)).not.toBeOnTheScreen(); + expect(screen.getByTestId(`PopoverMenuItem-${makeAdminText}`)).toBeOnTheScreen(); + + // ...but the demotions that would strip the payer of pay capability stay hidden + const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); + expect(screen.queryByTestId(`PopoverMenuItem-${makeMemberText}`)).not.toBeOnTheScreen(); + + const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1}); + expect(screen.queryByTestId(`PopoverMenuItem-${makeAuditorText}`)).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); From 6a6bcafd8cf816c4971e3e72aec88225a028a4b6 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Sat, 22 Aug 2026 08:35:16 +0000 Subject: [PATCH 4/9] Lock the payer Role row when no other valid payer role is assignable Co-authored-by: Shridhar Goel --- .../members/WorkspaceMemberDetailsPage.tsx | 12 ++++++-- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 9089d12e7e0b..dbdc00b6e245 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -144,9 +144,15 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - // The Authorized Payer (reimburser) is intentionally NOT locked out here: they can still be promoted/changed to another - // valid payer role (Admin or Payments Admin). WorkspaceMemberDetailsRolePage restricts the offered roles accordingly. - const canEditSelectedMemberRole = !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canManageSelectedMemberRole; + // The Authorized Payer (reimburser) may only hold a role that can pay (Admin or Payments Admin), so they can only be + // changed to one of those roles. Keep the Role row interactive for them only when there is another payer role they can + // actually move to, for example promoting a non-admin payer to Admin, or switching between Admin and Payments Admin on a + // Control workspace. Otherwise, such as an Admin payer on a non-Control workspace where Payments Admin is unavailable, + // there is no valid change to make, so the row stays read-only. + const assignablePayerRoles = [CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN].filter((payerRole) => canMemberAssignRole(policy, currentUserLogin, payerRole)); + const canReimburserChangeRole = assignablePayerRoles.some((payerRole) => payerRole !== member?.role); + const canEditSelectedMemberRole = + !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canManageSelectedMemberRole && (!isReimburser || canReimburserChangeRole); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index 7308b2c489a6..e9b2445ce71b 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -273,6 +273,34 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); + it('should keep the Role field read-only for an admin Authorized Payer on a non-Control workspace where no other payer role is available', async () => { + // On a Team (non-Control) workspace, Payments Admin is not an assignable role, so an admin payer has no other valid + // payer role to switch to. The Role row must stay read-only instead of opening a page with no actionable option. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + type: CONST.POLICY.TYPE.TEAM, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: adminPayerEmail, + }); + }); + + const {unmount} = renderPage({policyID: policy.id, accountID: String(adminPayerAccountID)}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('WorkspaceMemberDetailsPage')).toBeOnTheScreen(); + }); + + const roleItem = await screen.findByTestId('member-role-menu-item'); + + // There is no valid role change to make, so the row is disabled and shows no caret. + expect(roleItem).toBeDisabled(); + expect(within(roleItem).queryByTestId('ArrowRight Icon')).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should show the not found page when the accountID matches no workspace member', async () => { const {unmount} = renderPage({policyID: policy.id, accountID: '999999'}); await waitForBatchedUpdatesWithAct(); From 43f0f1d4feebdd27dd19a0a7f161fd82954c0143 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Sat, 22 Aug 2026 08:45:05 +0000 Subject: [PATCH 5/9] Extract shared PAYER_ROLES/canRolePay helper and drop em dash comment Co-authored-by: Shridhar Goel --- src/libs/PolicyUtils.ts | 15 +++++++++++++++ .../members/WorkspaceMemberDetailsPage.tsx | 3 ++- .../members/WorkspaceMemberDetailsRolePage.tsx | 10 +++++----- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 5b72267efafe..d72582f5fc45 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -697,6 +697,19 @@ function getReimburserEmail(policy: OnyxEntry): string | undefined { return policy.reimburser ?? policy.achAccount?.reimburser ?? (isManualReimbursement ? policy.owner : undefined); } +/** + * The roles that are allowed to pay (reimburse) on a workspace. The Authorized Payer (reimburser) must always hold one of + * these, so any role change for a payer is restricted to this set. + */ +const PAYER_ROLES = [CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN] as const; + +/** + * Whether the given role is allowed to pay (reimburse) on a workspace. + */ +function canRolePay(role: string | undefined): boolean { + return PAYER_ROLES.some((payerRole) => payerRole === role); +} + function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | undefined): boolean { if (!policy) { return false; @@ -3174,6 +3187,8 @@ export { isPolicyMember, isPolicyPayer, getReimburserEmail, + PAYER_ROLES, + canRolePay, arePaymentsEnabled, isSubmitterAndApprover, isSubmitAndClose, diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index dbdc00b6e245..fb44ed5e79fa 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -40,6 +40,7 @@ import { getReimburserEmail, isControlPolicy, isPolicyApprover, + PAYER_ROLES, tryNavigateToSubmitWorkspaceUpgrade, } from '@libs/PolicyUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; @@ -149,7 +150,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM // actually move to, for example promoting a non-admin payer to Admin, or switching between Admin and Payments Admin on a // Control workspace. Otherwise, such as an Admin payer on a non-Control workspace where Payments Admin is unavailable, // there is no valid change to make, so the row stays read-only. - const assignablePayerRoles = [CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN].filter((payerRole) => canMemberAssignRole(policy, currentUserLogin, payerRole)); + const assignablePayerRoles = PAYER_ROLES.filter((payerRole) => canMemberAssignRole(policy, currentUserLogin, payerRole)); const canReimburserChangeRole = assignablePayerRoles.some((payerRole) => payerRole !== member?.role); const canEditSelectedMemberRole = !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canManageSelectedMemberRole && (!isReimburser || canReimburserChangeRole); diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index de2f5614d73d..ae14e1f644e1 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -11,7 +11,7 @@ import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {canMemberAssignRole, getReimburserEmail} from '@libs/PolicyUtils'; +import {canMemberAssignRole, canRolePay, getReimburserEmail, PAYER_ROLES} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -40,10 +40,10 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); - // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to Admin or Payments Admin — the two roles that can pay. + // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to the roles that can pay (Admin or Payments Admin). const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - const allowedRoles = isReimburser ? [CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN] : undefined; + const allowedRoles = isReimburser ? [...PAYER_ROLES] : undefined; useRedirectSubmitWorkspaceFeatureUpgrade({ policy, backTo: ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID), @@ -57,8 +57,8 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp if (!canMemberAssignRole(policy, currentUserLogin, value)) { return; } - // Guard the direct-navigation path: a reimburser must stay a valid payer, so reject any role other than Admin or Payments Admin. - if (isReimburser && value !== CONST.POLICY.ROLE.ADMIN && value !== CONST.POLICY.ROLE.PAYMENTS_ADMIN) { + // Guard the direct-navigation path: a reimburser must stay a valid payer, so reject any role that cannot pay. + if (isReimburser && !canRolePay(value)) { return; } if (value !== CONST.POLICY.ROLE.ADMIN && isRuleBotEnforcingRules(accountID, policy)) { From 8d738292d51b847a716b64130538fa9e022a647f Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Sat, 22 Aug 2026 09:58:02 +0000 Subject: [PATCH 6/9] Run npm run fmt Co-authored-by: Shridhar Goel --- src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index fb44ed5e79fa..64fa9d29db49 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -152,8 +152,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM // there is no valid change to make, so the row stays read-only. const assignablePayerRoles = PAYER_ROLES.filter((payerRole) => canMemberAssignRole(policy, currentUserLogin, payerRole)); const canReimburserChangeRole = assignablePayerRoles.some((payerRole) => payerRole !== member?.role); - const canEditSelectedMemberRole = - !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canManageSelectedMemberRole && (!isReimburser || canReimburserChangeRole); + const canEditSelectedMemberRole = !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canManageSelectedMemberRole && (!isReimburser || canReimburserChangeRole); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); From 59b132344e308dbc2576b0f8e3f487f097e2bafb Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Tue, 25 Aug 2026 13:39:16 +0000 Subject: [PATCH 7/9] Derive payer roles from the WORKFLOWS_PAYMENTS permission instead of a hardcoded role list Co-authored-by: Shridhar Goel --- src/libs/PolicyUtils.ts | 16 +++++++++------- .../members/WorkspaceMemberDetailsPage.tsx | 2 +- .../members/WorkspaceMemberDetailsRolePage.tsx | 2 +- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 12 ++++++------ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index d72582f5fc45..60b00c4871d9 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -698,17 +698,19 @@ function getReimburserEmail(policy: OnyxEntry): string | undefined { } /** - * The roles that are allowed to pay (reimburse) on a workspace. The Authorized Payer (reimburser) must always hold one of - * these, so any role change for a payer is restricted to this set. + * Whether the given role is allowed to pay (reimburse) on a workspace. This is derived from the WORKFLOWS_PAYMENTS feature + * permission rather than a hardcoded role list, so any future role that is granted payment permission (for example upcoming + * customer roles) is automatically treated as a valid payer, matching how the rest of the app determines who can pay. */ -const PAYER_ROLES = [CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN] as const; +function canRolePay(role: string | undefined): boolean { + return !!role && ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS] === CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE; +} /** - * Whether the given role is allowed to pay (reimburse) on a workspace. + * The roles that are allowed to pay (reimburse) on a workspace, derived from the WORKFLOWS_PAYMENTS permission. The + * Authorized Payer (reimburser) must always hold one of these, so any role change for a payer is restricted to this set. */ -function canRolePay(role: string | undefined): boolean { - return PAYER_ROLES.some((payerRole) => payerRole === role); -} +const PAYER_ROLES = Object.values(CONST.POLICY.ROLE).filter(canRolePay); function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | undefined): boolean { if (!policy) { diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 64fa9d29db49..caef14be0cae 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -145,7 +145,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - // The Authorized Payer (reimburser) may only hold a role that can pay (Admin or Payments Admin), so they can only be + // The Authorized Payer (reimburser) may only hold a role that can pay (for example Admin or Payments Admin), so they can only be // changed to one of those roles. Keep the Role row interactive for them only when there is another payer role they can // actually move to, for example promoting a non-admin payer to Admin, or switching between Admin and Payments Admin on a // Control workspace. Otherwise, such as an Admin payer on a non-Control workspace where Payments Admin is unavailable, diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index ae14e1f644e1..48c6ebb92d5a 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -40,7 +40,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); - // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to the roles that can pay (Admin or Payments Admin). + // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to the roles that can pay (for example Admin or Payments Admin). const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; const allowedRoles = isReimburser ? [...PAYER_ROLES] : undefined; diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index e9b2445ce71b..c68a129e0f09 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -273,9 +273,9 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); - it('should keep the Role field read-only for an admin Authorized Payer on a non-Control workspace where no other payer role is available', async () => { - // On a Team (non-Control) workspace, Payments Admin is not an assignable role, so an admin payer has no other valid - // payer role to switch to. The Role row must stay read-only instead of opening a page with no actionable option. + it('should keep the Role field interactive for an admin Authorized Payer on a non-Control workspace because Editor also holds the payments permission', async () => { + // On a Team (non-Control) workspace, Payments Admin is not assignable, but Editor also holds the WORKFLOWS_PAYMENTS + // permission, so an admin payer still has another valid payer role to switch to. The Role row must stay interactive. await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { type: CONST.POLICY.TYPE.TEAM, @@ -293,9 +293,9 @@ describe('WorkspaceMemberDetailsPage', () => { const roleItem = await screen.findByTestId('member-role-menu-item'); - // There is no valid role change to make, so the row is disabled and shows no caret. - expect(roleItem).toBeDisabled(); - expect(within(roleItem).queryByTestId('ArrowRight Icon')).not.toBeOnTheScreen(); + // Editor is another payer role the admin payer can switch to, so the row is interactive with no lock hint. + expect(roleItem).not.toBeDisabled(); + expect(screen.queryByText(/Role can/)).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); From d55b2d2d34f57dd2d8865a0604fd0dec907eb8f0 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Tue, 25 Aug 2026 13:45:58 +0000 Subject: [PATCH 8/9] Simplify canRolePay doc comment Co-authored-by: Shridhar Goel --- src/libs/PolicyUtils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 60b00c4871d9..6e8c77156a19 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -698,9 +698,7 @@ function getReimburserEmail(policy: OnyxEntry): string | undefined { } /** - * Whether the given role is allowed to pay (reimburse) on a workspace. This is derived from the WORKFLOWS_PAYMENTS feature - * permission rather than a hardcoded role list, so any future role that is granted payment permission (for example upcoming - * customer roles) is automatically treated as a valid payer, matching how the rest of the app determines who can pay. + * Whether the given role is allowed to pay (reimburse) on a workspace. */ function canRolePay(role: string | undefined): boolean { return !!role && ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS] === CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE; From 6f4598c6d84690b01fb5e82e98a32450651fc032 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Thu, 27 Aug 2026 11:45:52 +0000 Subject: [PATCH 9/9] Simplify Authorized Payer role comment to a one-liner Co-authored-by: Shridhar Goel --- src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index caef14be0cae..9ba89e150ece 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -145,11 +145,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - // The Authorized Payer (reimburser) may only hold a role that can pay (for example Admin or Payments Admin), so they can only be - // changed to one of those roles. Keep the Role row interactive for them only when there is another payer role they can - // actually move to, for example promoting a non-admin payer to Admin, or switching between Admin and Payments Admin on a - // Control workspace. Otherwise, such as an Admin payer on a non-Control workspace where Payments Admin is unavailable, - // there is no valid change to make, so the row stays read-only. + // Only let the Authorized Payer change roles when there is another payer role they can actually move to. const assignablePayerRoles = PAYER_ROLES.filter((payerRole) => canMemberAssignRole(policy, currentUserLogin, payerRole)); const canReimburserChangeRole = assignablePayerRoles.some((payerRole) => payerRole !== member?.role); const canEditSelectedMemberRole = !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canManageSelectedMemberRole && (!isReimburser || canReimburserChangeRole);