diff --git a/src/CONST.ts b/src/CONST.ts index 143034afb2e9..cf550923d637 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3897,6 +3897,7 @@ const CONST = { SUBMITTER: 'submitter', ALL: 'all', }, + DELEGATE_ROLE_HELPDOT_ARTICLE_LINK: 'https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/', STRIPE_GBP_AUTH_STATUSES: { SUCCEEDED: 'succeeded', CARD_AUTHENTICATION_REQUIRED: 'authentication_required', diff --git a/src/components/DelegateNoAccessModal.tsx b/src/components/DelegateNoAccessModal.tsx new file mode 100644 index 000000000000..8b708459c122 --- /dev/null +++ b/src/components/DelegateNoAccessModal.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import useLocalize from '@hooks/useLocalize'; +import CONST from '@src/CONST'; +import ConfirmModal from './ConfirmModal'; +import Text from './Text'; +import TextLink from './TextLink'; + +type DelegateNoAccessModalProps = { + isNoDelegateAccessMenuVisible: boolean; + onClose: () => void; + delegatorEmail: string; +}; + +export default function DelegateNoAccessModal({isNoDelegateAccessMenuVisible = false, onClose, delegatorEmail = ''}: DelegateNoAccessModalProps) { + const {translate} = useLocalize(); + const noDelegateAccessPromptStart = translate('delegate.notAllowedMessageStart', {accountOwnerEmail: delegatorEmail}); + const noDelegateAccessHyperLinked = translate('delegate.notAllowedMessageHyperLinked'); + const noDelegateAccessPromptEnd = translate('delegate.notAllowedMessageEnd'); + + const delegateNoAccessPrompt = ( + + {noDelegateAccessPromptStart} + {noDelegateAccessHyperLinked} + {noDelegateAccessPromptEnd} + + ); + + return ( + + ); +} diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 321482f7177c..3cfa862faf14 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -17,6 +17,7 @@ import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; import * as TransactionActions from '@userActions/Transaction'; import CONST from '@src/CONST'; +import useDelegateUserDetails from '@src/hooks/useDelegateUserDetails'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; @@ -26,6 +27,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import Button from './Button'; import ConfirmModal from './ConfirmModal'; +import DelegateNoAccessModal from './DelegateNoAccessModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -142,6 +144,8 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const isAnyTransactionOnHold = ReportUtils.hasHeldExpenses(moneyRequestReport.reportID); const displayedAmount = isAnyTransactionOnHold && canAllowSettlement ? nonHeldAmount : formattedAmount; const isMoreContentShown = shouldShowNextStep || shouldShowStatusBar || (shouldShowAnyButton && shouldUseNarrowLayout); + const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); + const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const confirmPayment = (type?: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type || !chatReport) { @@ -149,7 +153,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea } setPaymentType(type); setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); - if (isAnyTransactionOnHold) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isAnyTransactionOnHold) { setIsHoldMenuVisible(true); } else if (ReportUtils.isInvoiceReport(moneyRequestReport)) { IOU.payInvoice(type, chatReport, moneyRequestReport, payAsBusiness); @@ -160,7 +166,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const confirmApproval = () => { setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); - if (isAnyTransactionOnHold) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isAnyTransactionOnHold) { setIsHoldMenuVisible(true); } else { IOU.approveMoneyRequest(moneyRequestReport, true); @@ -402,6 +410,12 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea transactionCount={transactionIDs.length} /> )} + setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + /> + PromotedAction> & { message: (params: {reportID?: string; accountID?: number; login?: string}) => PromotedAction; } & { - hold: (params: {isTextHold: boolean; reportAction: ReportAction | undefined; reportID?: string}) => PromotedAction; + hold: (params: { + isTextHold: boolean; + reportAction: ReportAction | undefined; + reportID?: string; + isDelegateAccessRestricted: boolean; + setIsNoDelegateAccessMenuVisible: (isVisible: boolean) => void; + }) => PromotedAction; }; const PromotedActions = { @@ -70,11 +76,16 @@ const PromotedActions = { } }, }), - hold: ({isTextHold, reportAction, reportID}) => ({ + hold: ({isTextHold, reportAction, reportID, isDelegateAccessRestricted, setIsNoDelegateAccessMenuVisible}) => ({ key: CONST.PROMOTED_ACTIONS.HOLD, icon: Expensicons.Stopwatch, text: Localize.translateLocal(`iou.${isTextHold ? 'hold' : 'unhold'}`), onSelected: () => { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); // Show the menu + return; + } + if (!isTextHold) { Navigation.goBack(); } diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index ad29a8d84141..9bb6709aa21c 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -5,6 +5,7 @@ import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useOnyx, withOnyx} from 'react-native-onyx'; import Button from '@components/Button'; +import DelegateNoAccessModal from '@components/DelegateNoAccessModal'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -14,6 +15,7 @@ import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import SettlementButton from '@components/SettlementButton'; import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; +import useDelegateUserDetails from '@hooks/useDelegateUserDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; @@ -191,13 +193,18 @@ function ReportPreview({ [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); + const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); + const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); + const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type) { return; } setPaymentType(type); setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); - if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else if (chatReport && iouReport) { if (ReportUtils.isInvoiceReport(iouReport)) { @@ -210,7 +217,9 @@ function ReportPreview({ const confirmApproval = () => { setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); - if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else { IOU.approveMoneyRequest(iouReport, true); @@ -516,6 +525,12 @@ function ReportPreview({ + setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + /> + {isHoldMenuVisible && iouReport && requestType !== undefined && ( `You don't have permission to take this action for ${accountOwnerEmail} as a`, + notAllowedMessageHyperLinked: ' limited access', + notAllowedMessageEnd: ' copilot', }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index 24b44c72c483..53c13e94d3d0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3,6 +3,7 @@ import CONST from '@src/CONST'; import type {DelegateRole} from '@src/types/onyx/Account'; import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy'; import type { + AccountOwnerParams, AddressLineParams, AdminCanceledRequestParams, AlreadySignedInParams, @@ -5089,5 +5090,9 @@ export default { } }, genericError: '¡Ups! Ha ocurrido un error. Por favor, inténtalo de nuevo.', + notAllowed: 'No tan rápido...', + notAllowedMessageStart: ({accountOwnerEmail}: AccountOwnerParams) => `No tienes permiso para realizar esta acción para ${accountOwnerEmail}`, + notAllowedMessageHyperLinked: ' copiloto con acceso', + notAllowedMessageEnd: ' limitado', }, } satisfies EnglishTranslation; diff --git a/src/languages/types.ts b/src/languages/types.ts index fb396a3f64ea..d061a7c24ea2 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -316,6 +316,8 @@ type ChangeTypeParams = {oldType: string; newType: string}; type DelegateSubmitParams = {delegateUser: string; originalManager: string}; +type AccountOwnerParams = {accountOwnerEmail: string}; + type ExportedToIntegrationParams = {label: string; markedManually?: boolean; inProgress?: boolean; lastModified?: string}; type IntegrationsMessageParams = { @@ -475,6 +477,7 @@ export type { ChangeTypeParams, ExportedToIntegrationParams, DelegateSubmitParams, + AccountOwnerParams, IntegrationsMessageParams, MarkedReimbursedParams, MarkReimbursedFromIntegrationParams, diff --git a/src/libs/AccountUtils.ts b/src/libs/AccountUtils.ts index b926e20ca59c..b5b58bfbf70c 100644 --- a/src/libs/AccountUtils.ts +++ b/src/libs/AccountUtils.ts @@ -8,4 +8,11 @@ const isValidateCodeFormSubmitting = (account: OnyxEntry) => /** Whether the accound ID is an odd number, useful for A/B testing. */ const isAccountIDOddNumber = (accountID: number) => accountID % 2 === 1; -export default {isValidateCodeFormSubmitting, isAccountIDOddNumber}; +function isDelegateOnlySubmitter(account: OnyxEntry): boolean { + const delegateEmail = account?.delegatedAccess?.delegate; + const delegateRole = account?.delegatedAccess?.delegates?.find((delegate) => delegate.email === delegateEmail)?.role; + + return delegateRole === CONST.DELEGATE_ROLE.SUBMITTER; +} + +export default {isValidateCodeFormSubmitting, isAccountIDOddNumber, isDelegateOnlySubmitter}; diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index de93ed7a3ced..aa419faedc29 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -7,6 +7,7 @@ import {useOnyx, withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import DelegateNoAccessModal from '@components/DelegateNoAccessModal'; import DisplayNames from '@components/DisplayNames'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -22,6 +23,7 @@ import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useDelegateUserDetails from '@hooks/useDelegateUserDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; @@ -234,15 +236,19 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD const [moneyRequestReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${moneyRequestReport?.reportID}`); const isMoneyRequestExported = ReportUtils.isExported(moneyRequestReportActions); + const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); + const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const unapproveExpenseReportOrShowModal = useCallback(() => { - if (isMoneyRequestExported) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isMoneyRequestExported) { setIsUnapproveModalVisible(true); return; } Navigation.dismissModal(); IOU.unapproveExpenseReport(moneyRequestReport); - }, [isMoneyRequestExported, moneyRequestReport]); + }, [isMoneyRequestExported, moneyRequestReport, isDelegateAccessRestricted]); const shouldShowLeaveButton = ReportUtils.canLeaveChat(report, policy); @@ -552,6 +558,8 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD isTextHold: canHoldUnholdReportAction.canHoldRequest, reportAction: moneyRequestAction, reportID: transactionThreadReportID ? report.reportID : moneyRequestAction?.childReportID ?? '-1', + isDelegateAccessRestricted, + setIsNoDelegateAccessMenuVisible, }), ); } @@ -563,7 +571,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD result.push(PromotedActions.share(report)); return result; - }, [report, moneyRequestAction, canJoin, isExpenseReport, shouldShowHoldAction, canHoldUnholdReportAction.canHoldRequest, transactionThreadReportID]); + }, [report, moneyRequestAction, canJoin, isExpenseReport, shouldShowHoldAction, canHoldUnholdReportAction.canHoldRequest, transactionThreadReportID, isDelegateAccessRestricted]); const nameSectionExpenseIOU = ( @@ -813,6 +821,11 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD danger shouldEnableNewFocusManagement /> + setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + />