diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7fef732a5435..e47f56f4c0ce 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4949,6 +4949,12 @@ const CONST = { CARD_ADMIN: 'cardAdmin', PEOPLE_ADMIN: 'peopleAdmin', PAYMENTS_ADMIN: 'paymentsAdmin', + APPROVE_ONLY: 'approveOnly', + }, + APPROVE_ONLY_BLOCKED_REASONS: { + HAS_CARD_ON_POLICY: 'hasCardOnPolicy', + IS_RESTRICTED_BY_DOMAIN_GROUP: 'isRestrictedByDomainGroup', + IS_DEFAULT_POLICY: 'isDefaultPolicy', }, WORKSPACE_STATUS: { ACTIVE: 'active', @@ -4981,6 +4987,7 @@ const CONST = { REPORT_FIELDS: 'reportFields', ACCOUNTING: 'accounting', MORE_FEATURES: 'moreFeatures', + CREATE_EXPENSES: 'createExpenses', }, POLICY_FEATURE_ACCESS: { READ: 'read', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 7ef5e2934576..6db10eb90564 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -160,6 +160,9 @@ const ONYXKEYS = { /** Object containing contact method that's going to be added */ PENDING_CONTACT_ACTION: 'pendingContactAction', + /** Reasons the backend refused an approve-only role change, shown to the requesting client in a modal */ + BLOCKED_ROLE_CHANGE: 'blockedRoleChange', + /** Store the information of validateCode */ VALIDATE_ACTION_CODE: 'validateActionCode', @@ -1944,6 +1947,7 @@ type OnyxValuesMapping = { [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED]: boolean; [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: OnyxTypes.SupportalPermissionDenied | null; + [ONYXKEYS.BLOCKED_ROLE_CHANGE]: OnyxTypes.BlockedRoleChange | null; [ONYXKEYS.RESET_REQUIRED]: boolean; [ONYXKEYS.PLAID_CURRENT_EVENT]: string; [ONYXKEYS.NVP_PRIVATE_TAX_EXEMPT]: boolean; diff --git a/src/components/DeferredGlobalModals.tsx b/src/components/DeferredGlobalModals.tsx index 31eb16153bc3..2b9c578183fd 100644 --- a/src/components/DeferredGlobalModals.tsx +++ b/src/components/DeferredGlobalModals.tsx @@ -1,3 +1,4 @@ +import useApproveOnlyRoleBlockedModal from '@hooks/useApproveOnlyRoleBlockedModal'; import useScreenShareRequestPrompt from '@hooks/useScreenShareRequestPrompt'; import React from 'react'; @@ -14,6 +15,7 @@ const LazyTrialPaymentReminderModalManager = React.lazy(() => import('./TrialPay */ function DeferredGlobalModals() { useScreenShareRequestPrompt(); + useApproveOnlyRoleBlockedModal(); return ( <> diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx index bff0bfab5a42..52f3c8c54652 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx @@ -12,7 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearCopyPolicySettings} from '@libs/actions/Policy/CopyPolicySettings'; import {callFunctionIfActionIsAllowed} from '@libs/actions/Session'; import Navigation from '@libs/Navigation/Navigation'; -import {canUnarchivePolicy} from '@libs/PolicyUtils'; +import {canRoleCreateExpenses, canUnarchivePolicy} from '@libs/PolicyUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import UnarchiveWorkspaceFlow from '@pages/workspace/archiveWorkspace/UnarchiveWorkspaceFlow'; @@ -135,7 +135,8 @@ function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, onArchiveWorkspace, } } - if (!isDefault && !item?.isJoinRequestPending && !isRestrictedToPreferredPolicy) { + // Making the workspace the default would break expense creation routing for members without create-expenses permission. + if (!isDefault && !item?.isJoinRequestPending && !isRestrictedToPreferredPolicy && canRoleCreateExpenses(item.role)) { menuItems.push({ icon: icons.Star, text: translate('workspace.common.setAsDefault'), diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index 3aa123b0685a..3da20f0444c6 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -69,6 +69,7 @@ type WorkspaceMembersTableProps = { const WORKSPACE_MEMBER_FILTER_VALUES = { ADMINS: 'admins', + APPROVE_ONLY: 'approveOnly', APPROVERS: 'approvers', AUDITORS: 'auditors', CARD_ADMINS: 'cardAdmins', @@ -266,6 +267,11 @@ export default function WorkspaceMembersTable({ return true; } + const isApproveOnly = item.role === CONST.POLICY.ROLE.APPROVE_ONLY; + if (filterValues.includes(WORKSPACE_MEMBER_FILTER_VALUES.APPROVE_ONLY) && isApproveOnly) { + return true; + } + const isEditor = item.role === CONST.POLICY.ROLE.EDITOR; if (filterValues.includes(WORKSPACE_MEMBER_FILTER_VALUES.EDITORS) && isEditor) { return true; @@ -321,6 +327,11 @@ export default function WorkspaceMembersTable({ label: translate('workspace.people.guests'), value: WORKSPACE_MEMBER_FILTER_VALUES.GUESTS, }); + + filterConfig.role.options.push({ + label: translate('workspace.people.approveOnly'), + value: WORKSPACE_MEMBER_FILTER_VALUES.APPROVE_ONLY, + }); } if (isSubmitPolicy(policy)) { diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index 91d3d8e86772..8fb284fa3d71 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -111,6 +111,13 @@ function getAssignableWorkspaceMemberRoleItems( isSelected: currentRole === CONST.POLICY.ROLE.USER, keyForList: CONST.POLICY.ROLE.USER, }, + { + value: CONST.POLICY.ROLE.APPROVE_ONLY, + text: translate('workspace.common.roleName', CONST.POLICY.ROLE.APPROVE_ONLY), + alternateText: translate('workspace.common.approveOnlyAlternateText'), + isSelected: currentRole === CONST.POLICY.ROLE.APPROVE_ONLY, + keyForList: CONST.POLICY.ROLE.APPROVE_ONLY, + }, ]; const selectableRoles = getSelectableRoles(policy, currentUserLogin, allowedRoles); @@ -148,7 +155,7 @@ function WorkspaceMemberRoleList({ ListItem={SingleSelectListItem} onSelectRow={onSelectRole} confirmButtonOptions={confirmButtonOptions} - shouldSingleExecuteRowSelect + shouldSingleExecuteRowSelect={!confirmButtonOptions?.showButton} initiallyFocusedItemKey={availableRoleItems.find((item) => item.isSelected)?.keyForList} addBottomSafeAreaPadding > diff --git a/src/hooks/useApproveOnlyRoleBlockedModal.ts b/src/hooks/useApproveOnlyRoleBlockedModal.ts new file mode 100644 index 000000000000..37f1e61cb985 --- /dev/null +++ b/src/hooks/useApproveOnlyRoleBlockedModal.ts @@ -0,0 +1,77 @@ +/** + * Subscribes to BLOCKED_ROLE_CHANGE in Onyx and shows the blocked-reasons modal on the global modal stack + * when the backend refuses an approve-only role change. + */ +import {clearBlockedRoleChange, clearUpdateMemberRoleError} from '@userActions/Policy/Member'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type BlockedRoleChange from '@src/types/onyx/BlockedRoleChange'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useEffect, useRef} from 'react'; + +import useConfirmModal from './useConfirmModal'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; + +const APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID = 'approveOnlyRoleBlocked'; + +const REASON_TRANSLATION_KEYS: Record = { + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.HAS_CARD_ON_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.hasCardOnPolicy', + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_RESTRICTED_BY_DOMAIN_GROUP]: 'workspace.people.approveOnlyRoleBlockedReasons.isRestrictedByDomainGroup', + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_DEFAULT_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.isDefaultPolicy', +}; + +function useApproveOnlyRoleBlockedModal() { + const {translate} = useLocalize(); + const {showConfirmModal, closeModalByID} = useConfirmModal(); + const [blockedRoleChange] = useOnyx(ONYXKEYS.BLOCKED_ROLE_CHANGE); + const lastShownRef = useRef>(undefined); + const isModalOpenRef = useRef(false); + + useEffect(() => { + if (!blockedRoleChange) { + if (isModalOpenRef.current) { + isModalOpenRef.current = false; + closeModalByID(APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID); + } + return; + } + + if (lastShownRef.current === blockedRoleChange) { + return; + } + lastShownRef.current = blockedRoleChange; + isModalOpenRef.current = true; + + const {policyID, email, blockedReasons = []} = blockedRoleChange; + + // `OnyxUpdates.apply` runs `request.failureData` on the microtask after `response.onyxData`, which sets a + // generic inline error on the member row. Clear that row error once `failureData` has settled so the modal + // is the only feedback shown for a blocked role change. + if (policyID && email) { + Promise.resolve().then(() => { + clearUpdateMemberRoleError(policyID, email); + }); + } + + const reasonLines = blockedReasons.filter((reason) => reason in REASON_TRANSLATION_KEYS).map((reason) => `• ${translate(REASON_TRANSLATION_KEYS[reason])}`); + const hasReasonLines = reasonLines.length > 0; + + showConfirmModal({ + id: APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID, + title: translate('workspace.people.approveOnlyRoleBlockedTitle'), + prompt: hasReasonLines ? [translate('workspace.people.approveOnlyRoleBlockedDescription'), '', ...reasonLines].join('\n') : translate('common.genericErrorMessage'), + confirmText: translate(hasReasonLines ? 'workspace.people.approveOnlyRoleBlockedConfirm' : 'common.buttonConfirm'), + shouldShowCancelButton: false, + }).then(() => { + isModalOpenRef.current = false; + clearBlockedRoleChange(); + }); + }, [blockedRoleChange, closeModalByID, showConfirmModal, translate]); +} + +export default useApproveOnlyRoleBlockedModal; diff --git a/src/hooks/useDefaultExpensePolicy.tsx b/src/hooks/useDefaultExpensePolicy.tsx index 2cf0ea1d88d5..447ae3cbf315 100644 --- a/src/hooks/useDefaultExpensePolicy.tsx +++ b/src/hooks/useDefaultExpensePolicy.tsx @@ -1,15 +1,19 @@ import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {isGroupPolicy, isPolicyAccessible} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, isGroupPolicy, isPolicyAccessible} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; import usePreferredPolicy from './usePreferredPolicy'; +function isEligibleDefaultExpensePolicy(policy: OnyxEntry, login: string): policy is Policy { + return !!policy && isGroupPolicy(policy) && isPolicyAccessible(policy, login) && canCreateExpensesOnPolicy(policy, login) && !isTeachersUnitePolicyID(policy.id); +} + /** * Selector that finds the single qualifying group policy ID from the collection. * Returns only an ID (stable) — prevents re-renders when unrelated policies change. @@ -21,7 +25,7 @@ function getSingleGroupPolicyID(policies: OnyxCollection, login: string) let singlePolicyID: string | undefined; for (const policy of Object.values(policies)) { - if (!policy || !isGroupPolicy(policy) || !isPolicyAccessible(policy, login) || isTeachersUnitePolicyID(policy.id)) { + if (!isEligibleDefaultExpensePolicy(policy, login)) { continue; } if (!singlePolicyID) { @@ -50,11 +54,11 @@ export default function useDefaultExpensePolicy() { const [singleGroupPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${singleGroupPolicyID}`); // Teachers Unite only supports expenses via split expense , so it can never be the default destination for a new expense. - if (isRestrictedToPreferredPolicy && isGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login) && !isTeachersUnitePolicyID(preferredPolicy?.id)) { + if (isRestrictedToPreferredPolicy && isEligibleDefaultExpensePolicy(preferredPolicy, login)) { return preferredPolicy; } - if (isGroupPolicy(activePolicy) && isPolicyAccessible(activePolicy, login) && !isTeachersUnitePolicyID(activePolicy?.id)) { + if (isEligibleDefaultExpensePolicy(activePolicy, login)) { return activePolicy; } diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index 7cc5f96cd51b..6087db6e014d 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -2,7 +2,7 @@ import {useActivePolicyContext} from '@components/ActivePolicyProvider'; import {useSession} from '@components/OnyxListItemProvider'; import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {canSubmitPerDiemExpenseFromWorkspace, getPolicyRole, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, canSubmitPerDiemExpenseFromWorkspace, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -22,15 +22,10 @@ function checkForUserPendingDelete(login: string, policy: OnyxEntry) { return isPolicyMemberWithoutPendingDelete(login, policy); } -function isPolicyMemberByRole(policy: OnyxEntry, login: string) { - const role = getPolicyRole(policy, login); - return !!role && (Object.values(CONST.POLICY.ROLE) as string[]).includes(role); -} - function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string, isPerDiemRequest?: boolean, isTimeRequest?: boolean) { return ( checkForUserPendingDelete(login, policy) && - isPolicyMemberByRole(policy, login) && + canCreateExpensesOnPolicy(policy, login) && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && // Teachers Unite doesn't support reimbursement, so it can never be a destination for moving/reporting an expense. diff --git a/src/languages/de.ts b/src/languages/de.ts index c5b27180bcea..5e41a19083b2 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5013,6 +5013,8 @@ ${amount} für ${merchant} – ${date}`, return 'Personenverwaltung'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Zahlungsadministrator'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Nur Genehmigung'; case CONST.POLICY.ROLE.USER: return 'Mitglied'; default: @@ -5053,6 +5055,7 @@ ${amount} für ${merchant} – ${date}`, cardAdminAlternateText: 'Arbeitsbereichskarten verwalten.', peopleAdminAlternateText: 'Mitglieder und Genehmigungsabläufe verwalten.', paymentsAdminAlternateText: 'Workflow-Zahlungen verwalten.', + approveOnlyAlternateText: 'Nur Berichte genehmigen.', readOnlyActionTitle: 'Nicht so schnell …', readOnlyActionPrompt: 'Ihre Arbeitsbereichsrolle kann diese Einstellungen anzeigen, aber nicht bearbeiten.', noAccessActionPrompt: 'Ihre Arbeitsbereichsrolle hat keinen Zugriff auf diese Einstellungen. Bitten Sie eine/n Admin, falls Sie Zugriff benötigen.', @@ -7298,6 +7301,15 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, }, people: { genericFailureMessage: 'Beim Entfernen eines Mitglieds aus dem Workspace ist ein Fehler aufgetreten, bitte versuche es erneut', + approveOnlyRoleBlockedTitle: 'Diese Rolle kann nicht geändert werden', + approveOnlyRoleBlockedConfirm: 'Verstanden', + approveOnlyRoleBlockedDescription: 'Mitglieder mit der Rolle „Nur Genehmigung“ können in diesem Arbeitsbereich keine Ausgaben erstellen. Diese müssen zuerst behoben werden:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Ihnen ist in diesem Arbeitsbereich eine Karte zugewiesen. Ein Arbeitsbereichs- oder Kartenadministrator muss sie zuerst entziehen.', + isRestrictedByDomainGroup: + 'Dieser Arbeitsbereich ist der bevorzugte Arbeitsbereich für eine Domänengruppe, in der sie sich befinden. Ein Domänenadministrator muss dies zuerst ändern.', + isDefaultPolicy: 'Dies ist ihr Standard-Arbeitsbereich. Sie müssen einen anderen festlegen, bevor Sie ihre Rolle aktualisieren können.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Möchtest du ${memberName} wirklich entfernen?`, other: 'Sind Sie sicher, dass Sie diese Mitglieder entfernen möchten?', @@ -7349,6 +7361,10 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, cannotRemove: 'Du kannst dich selbst oder den/die Workspace-Inhaber:in nicht entfernen', genericRemove: 'Beim Entfernen dieses Workspace-Mitglieds ist ein Problem aufgetreten', }, + makeApproveOnly: () => ({ + one: 'Auf „Nur Genehmigung“ setzen', + other: 'Als „Nur Genehmigung“ festlegen', + }), addedWithPrimary: 'Einige Mitglieder wurden mit ihren primären Anmeldungen hinzugefügt.', invitedBySecondaryLogin: (secondaryLogin: string) => `Hinzugefügt durch sekundären Login ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Gesamtzahl der Workspace-Mitglieder: ${count}`, @@ -7371,6 +7387,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, admins: 'Workspace-Administratoren', approvers: 'Genehmigende', auditors: 'Prüfer', + approveOnly: 'Nur Genehmigung', guests: 'Gäste', editors: 'Bearbeiter', emptyRoleFilter: {title: 'Keine Mitglieder entsprechen diesem Filter', subtitle: 'Laden Sie ein Mitglied ein oder ändern Sie den Filter oben.'}, @@ -9675,8 +9692,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc other: `hat Sie aus den Genehmigungs-Workflows und Spesen-Chats von ${joinedNames} entfernt. Zuvor eingereichte Berichte bleiben in Ihrem Posteingang zur Genehmigung verfügbar.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `hat Ihre Rolle in ${policyName} von ${oldRole} zu Nutzer geändert. Sie wurden aus allen Einreicher-Spesen-Chats entfernt, außer aus Ihrem eigenen.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `hat Ihre Rolle in ${policyName} von ${oldRole} zu ${newRole} geändert. Sie wurden aus allen Einreicher-Spesen-Chats entfernt, außer aus Ihrem eigenen.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `Standardwährung auf ${newCurrency} aktualisiert (zuvor ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `die automatische Berichtshäufigkeit auf „${newFrequency}“ aktualisiert (zuvor „${oldFrequency}“)`, updateApprovalMode: (newValue: string, oldValue?: string) => `hat den Genehmigungsmodus auf „${newValue}“ aktualisiert (zuvor „${oldValue}“)`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 45893f913c62..6f03807d857f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -5067,6 +5067,7 @@ ${amount} για ${merchant} - ${date}`, cardAdminAlternateText: 'Διαχειριστείτε τις κάρτες χώρου εργασίας.', peopleAdminAlternateText: 'Διαχειριστείτε μέλη και ροές έγκρισης.', paymentsAdminAlternateText: 'Διαχειριστείτε τις πληρωμές ροής εργασιών.', + approveOnlyAlternateText: 'Μόνο έγκριση αναφορών.', reimbursementChoice: { [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES]: 'Άμεσο', [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO]: 'Κανένα', @@ -5090,6 +5091,8 @@ ${amount} για ${merchant} - ${date}`, return 'Διαχείριση χρηστών'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Διαχείριση πληρωμών'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Μόνο έγκριση'; case CONST.POLICY.ROLE.USER: return 'Μέλος'; default: @@ -7421,6 +7424,15 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, people: { genericFailureMessage: 'Παρουσιάστηκε σφάλμα κατά την αφαίρεση μέλους από τον χώρο εργασίας, δοκιμάστε ξανά', + approveOnlyRoleBlockedTitle: 'Δεν είναι δυνατή η αλλαγή αυτού του ρόλου', + approveOnlyRoleBlockedConfirm: 'Κατάλαβα', + approveOnlyRoleBlockedDescription: 'Τα μέλη με μόνο έγκριση δεν μπορούν να δημιουργήσουν έξοδα σε αυτόν τον χώρο εργασίας. Αυτά πρέπει να επιλυθούν πρώτα:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Έχει εκχωρηθεί μια κάρτα σε αυτούς σε αυτόν τον χώρο εργασίας. Ένας διαχειριστής χώρου εργασίας ή καρτών πρέπει πρώτα να την αφαιρέσει.', + isRestrictedByDomainGroup: + 'Αυτός ο χώρος εργασίας είναι ο προτιμώμενος χώρος εργασίας για μια ομάδα τομέα στην οποία ανήκουν. Ένας διαχειριστής τομέα πρέπει πρώτα να τον αλλάξει.', + isDefaultPolicy: 'Αυτός είναι ο προεπιλεγμένος χώρος εργασίας τους. Θα πρέπει να ορίσουν έναν διαφορετικό πριν μπορέσετε να ενημερώσετε τον ρόλο τους.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τον/την ${memberName};`, other: 'Είστε βέβαιοι ότι θέλετε να αφαιρέσετε αυτά τα μέλη;', @@ -7476,6 +7488,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ cannotRemove: 'Δεν μπορείτε να αφαιρέσετε τον εαυτό σας ή τον ιδιοκτήτη του χώρου εργασίας', genericRemove: 'Προέκυψε πρόβλημα κατά την αφαίρεση αυτού του μέλους του χώρου εργασίας', }, + makeApproveOnly: () => ({ + one: 'Ορισμός ως μόνο έγκριση', + other: 'Ορισμός ως μόνο έγκριση', + }), addedWithPrimary: 'Ορισμένα μέλη προστέθηκαν με τους κύριους λογαριασμούς σύνδεσής τους.', invitedBySecondaryLogin: (secondaryLogin: string) => `Προστέθηκε από το δευτερεύον στοιχείο σύνδεσης ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Σύνολο μελών χώρου εργασίας: ${count}`, @@ -7488,6 +7504,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ paymentsAdmins: 'Διαχειριστές πληρωμών', approvers: 'Εγκρίνοντες', auditors: 'Ελεγκτές', + approveOnly: 'Μόνο έγκριση', guests: 'Επισκέπτες', editors: 'Συντάκτες', members: 'Μέλη', @@ -9955,8 +9972,8 @@ ${reportName}`, other: `σας αφαίρεσε από τις ροές έγκρισης και τις συνομιλίες εξόδων του/της ${joinedNames}. Οι αναφορές που υποβλήθηκαν παλαιότερα θα παραμείνουν διαθέσιμες για έγκριση στα εισερχόμενά σας.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `ενημέρωσε τον ρόλο σας στην πολιτική ${policyName} από ${oldRole} σε χρήστη. Έχετε αφαιρεθεί από όλες τις συνομιλίες εξόδων υποβολέων, εκτός από τις δικές σας.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `ενημέρωσε τον ρόλο σας στην πολιτική ${policyName} από ${oldRole} σε ${newRole}. Έχετε αφαιρεθεί από όλες τις συνομιλίες εξόδων υποβολέων, εκτός από τις δικές σας.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `ενημέρωσε το προεπιλεγμένο νόμισμα σε ${newCurrency} (προηγουμένως ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `ενημέρωσε τη συχνότητα αυτόματης αναφοράς σε «${newFrequency}» (προηγουμένως «${oldFrequency}»)`, updateApprovalMode: (newValue, oldValue) => `ενημέρωσε τη λειτουργία έγκρισης σε «${newValue}» (προηγουμένως «${oldValue}»)`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 4969be101380..c1940b9c78a7 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5191,6 +5191,7 @@ const translations = { cardAdminAlternateText: 'Manage workspace cards.', peopleAdminAlternateText: 'Manage members and approval workflows.', paymentsAdminAlternateText: 'Manage workflow payments.', + approveOnlyAlternateText: 'Approve reports only.', reimbursementChoice: { [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES]: 'Direct', [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO]: 'None', @@ -5214,6 +5215,8 @@ const translations = { return 'People admin'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Payments admin'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Approve-only'; case CONST.POLICY.ROLE.USER: return 'Member'; default: @@ -7727,6 +7730,14 @@ const translations = { }, people: { genericFailureMessage: 'An error occurred removing a member from the workspace, please try again', + approveOnlyRoleBlockedTitle: 'Can’t change this role', + approveOnlyRoleBlockedConfirm: 'Got it', + approveOnlyRoleBlockedDescription: 'Approve-only members can’t create expenses on this workspace. These need to be resolved first:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'A card is assigned to them on this workspace. A Workspace Admin or Card Admin needs to unassign it first.', + isRestrictedByDomainGroup: 'This workspace is the Preferred workspace for a domain group they’re in. A Domain Admin needs to change it first.', + isDefaultPolicy: 'This is their default workspace. They’ll need to set a different one before you can update their role.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Are you sure you want to remove ${memberName}?`, other: 'Are you sure you want to remove these members?', @@ -7796,6 +7807,10 @@ const translations = { cannotRemove: "You can't remove yourself or the workspace owner", genericRemove: 'There was a problem removing that workspace member', }, + makeApproveOnly: () => ({ + one: 'Make approve only', + other: 'Make approve only members', + }), addedWithPrimary: 'Some members were added with their primary logins.', invitedBySecondaryLogin: (secondaryLogin: string) => `Added by secondary login ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Total workspace members: ${count}`, @@ -7808,6 +7823,7 @@ const translations = { paymentsAdmins: 'Payments Admins', approvers: 'Approvers', auditors: 'Auditors', + approveOnly: 'Approve-only', guests: 'Guests', editors: 'Editors', members: 'Members', @@ -9975,8 +9991,8 @@ const translations = { other: `removed you from ${joinedNames}'s approval workflows and expense chats. Previously submitted reports will remain available for approval in your Inbox.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `updated your role in ${policyName} from ${oldRole} to user. You have been removed from all submitter expense chats except for you own.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `updated your role in ${policyName} from ${oldRole} to ${newRole}. You have been removed from all submitter expense chats except for you own.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `updated the default currency to ${newCurrency} (previously ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `updated the auto-reporting frequency to "${newFrequency}" (previously "${oldFrequency}")`, updateApprovalMode: (newValue: string, oldValue?: string) => `updated the approval mode to "${newValue}" (previously "${oldValue}")`, @@ -10947,7 +10963,7 @@ const translations = { `the Plaid connection to your business bank account is broken. Please reconnect your bank account ${maskedAccountNumber} so you can continue to use your Expensify Cards.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { const translatedRole = String(translations.workspace.common.roleName(role)).toLowerCase(); - const article = role === CONST.POLICY.ROLE.AUDITOR ? 'an' : 'a'; + const article = role === CONST.POLICY.ROLE.AUDITOR || role === CONST.POLICY.ROLE.APPROVE_ONLY ? 'an' : 'a'; return didJoinPolicy ? `${email} joined via the workspace invite link` : `added ${email} as ${article} ${translatedRole}`; }, updateRole: (email: string, currentRole: string, newRole: string) => `updated the role of ${email} to ${newRole} (previously ${currentRole})`, diff --git a/src/languages/es.ts b/src/languages/es.ts index a9183e8f3bd2..48159343e073 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4933,6 +4933,8 @@ ${amount} para ${merchant} - ${date}`, return 'Administrar personas'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Administrador de pagos'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Solo aprobación'; case CONST.POLICY.ROLE.USER: return 'Miembro'; default: @@ -4974,6 +4976,7 @@ ${amount} para ${merchant} - ${date}`, cardAdminAlternateText: 'Gestiona tarjetas del espacio de trabajo.', peopleAdminAlternateText: 'Gestiona miembros y flujos de aprobación.', paymentsAdminAlternateText: 'Gestiona los pagos del flujo de trabajo.', + approveOnlyAlternateText: 'Solo aprobar informes.', noAccessActionPrompt: 'Tu rol en el espacio de trabajo no tiene acceso a estos ajustes. Pide a un administrador que te lo habilite si lo necesitas.', connections: 'Conexiones', }, @@ -7155,6 +7158,15 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, people: { genericFailureMessage: 'Se ha producido un error al intentar eliminar a un miembro del espacio de trabajo. Por favor, inténtalo más tarde.', + approveOnlyRoleBlockedTitle: 'No se puede cambiar este rol', + approveOnlyRoleBlockedConfirm: 'Entendido', + approveOnlyRoleBlockedDescription: 'Los miembros de solo aprobación no pueden crear gastos en este espacio de trabajo. Primero deben resolverse lo siguiente:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Tienen una tarjeta asignada en este espacio de trabajo. Un administrador del espacio de trabajo o de tarjetas debe desasignarla primero.', + isRestrictedByDomainGroup: + 'Este espacio de trabajo es el espacio de trabajo preferido de un grupo de dominio al que pertenecen. Un administrador de dominio debe cambiarlo primero.', + isDefaultPolicy: 'Este es su espacio de trabajo predeterminado. Deberán establecer uno diferente antes de que puedas actualizar su rol.', + }, removeMembersPrompt: ({memberName}) => ({ one: `¿Estás seguro de que deseas eliminar ${memberName}`, other: '¿Estás seguro de que deseas eliminar a estos miembros?', @@ -7207,6 +7219,10 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, cannotRemove: 'No puedes eliminarte ni a ti mismo ni al dueño del espacio de trabajo', genericRemove: 'Ha ocurrido un problema al eliminar al miembro del espacio de trabajo', }, + makeApproveOnly: () => ({ + one: 'Convertir en solo aprobación', + other: 'Convertir en miembros de solo aprobación', + }), addedWithPrimary: 'Se agregaron algunos miembros con sus nombres de usuario principales.', invitedBySecondaryLogin: (secondaryLogin) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`, workspaceMembersCount: (count) => `Total de miembros del espacio de trabajo: ${count}`, @@ -7214,6 +7230,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, admins: 'Administradores del espacio de trabajo', approvers: 'Aprobadores', auditors: 'Auditores', + approveOnly: 'Solo aprobación', guests: 'Invitados', editors: 'Editores', emptyRoleFilter: { @@ -9586,7 +9603,7 @@ ${reportName}`, other: `te eliminó de los flujos de trabajo de aprobaciones y de los chats de gastos de ${joinedNames}. Los informes enviados anteriormente seguirán estando disponibles para su aprobación en tu bandeja de entrada.`, }; }, - demotedFromWorkspace: (policyName, oldRole) => `cambió tu rol en ${policyName} de ${oldRole} a miembro. Te eliminamos de todos los chats de gastos, excepto el suyo.`, + demotedFromWorkspace: (policyName, oldRole, newRole) => `cambió tu rol en ${policyName} de ${oldRole} a ${newRole}. Te eliminamos de todos los chats de gastos, excepto el suyo.`, updatedWorkspaceCurrencyAction: (oldCurrency, newCurrency) => `actualizó la moneda predeterminada a ${newCurrency} (previamente ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency, newFrequency) => `actualizó la frecuencia de generación automática de informes a "${newFrequency}" (previamente "${oldFrequency}")`, updateApprovalMode: (newValue, oldValue) => `actualizó el modo de aprobación a "${newValue}" (previamente "${oldValue}")`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b71f4b1501ea..449cc83f54e1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5019,6 +5019,8 @@ ${amount} pour ${merchant} - ${date}`, return 'Administrateur des personnes'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Administrateur des paiements'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Approbation uniquement'; case CONST.POLICY.ROLE.USER: return 'Membre'; default: @@ -5059,6 +5061,7 @@ ${amount} pour ${merchant} - ${date}`, cardAdminAlternateText: 'Gérer les cartes de l’espace de travail.', peopleAdminAlternateText: 'Gérez les membres et les workflows d’approbation.', paymentsAdminAlternateText: 'Gérer les paiements de workflow.', + approveOnlyAlternateText: 'Approuver uniquement les rapports.', readOnlyActionTitle: 'Pas si vite...', readOnlyActionPrompt: 'Votre rôle dans cet espace de travail peut afficher ces paramètres, mais ne peut pas les modifier.', noAccessActionPrompt: 'Votre rôle dans l’espace de travail n’a pas accès à ces paramètres. Demandez à un administrateur si vous en avez besoin.', @@ -7317,6 +7320,15 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, }, people: { genericFailureMessage: "Une erreur s'est produite lors de la suppression d'un membre de l'espace de travail, veuillez réessayer.", + approveOnlyRoleBlockedTitle: 'Impossible de modifier ce rôle', + approveOnlyRoleBlockedConfirm: 'Compris', + approveOnlyRoleBlockedDescription: 'Les membres en approbation uniquement ne peuvent pas créer de dépenses sur cet espace de travail. Ces points doivent d’abord être résolus :', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Une carte leur est attribuée sur cet espace de travail. Un administrateur de l’espace de travail ou des cartes doit d’abord la désattribuer.', + isRestrictedByDomainGroup: + 'Cet espace de travail est l’espace de travail préféré d’un groupe de domaine dont ils font partie. Un administrateur de domaine doit d’abord le modifier.', + isDefaultPolicy: 'C’est leur espace de travail par défaut. Ils devront en définir un autre avant que vous puissiez mettre à jour leur rôle.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Voulez-vous vraiment supprimer ${memberName} ?`, other: 'Voulez-vous vraiment supprimer ces membres ?', @@ -7368,6 +7380,10 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, cannotRemove: 'Vous ne pouvez pas vous supprimer vous-même ni supprimer le responsable de l’espace de travail', genericRemove: 'Un problème est survenu lors de la suppression de ce membre de l’espace de travail', }, + makeApproveOnly: () => ({ + one: 'Définir en approbation uniquement', + other: 'Définir en approbation uniquement', + }), addedWithPrimary: 'Certains membres ont été ajoutés avec leurs identifiants principaux.', invitedBySecondaryLogin: (secondaryLogin: string) => `Ajouté par la connexion secondaire ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Nombre total de membres de l'espace de travail : ${count}`, @@ -7390,6 +7406,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, admins: 'Administrateurs de l’espace de travail', approvers: 'Approbateurs', auditors: 'Auditeurs', + approveOnly: 'Approbation uniquement', guests: 'Invités', editors: 'Éditeurs', emptyRoleFilter: {title: 'Aucun membre ne correspond à ce filtre', subtitle: 'Invitez un membre ou modifiez le filtre ci-dessus.'}, @@ -9697,8 +9714,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e other: `vous a retiré des workflows d’approbation et des discussions de dépenses de ${joinedNames}. Les notes de frais déjà soumises resteront disponibles pour approbation dans votre boîte de réception.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `a mis à jour votre rôle dans ${policyName}, passant de ${oldRole} à utilisateur. Vous avez été retiré de toutes les discussions de dépenses des déclarants, sauf de la vôtre.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `a mis à jour votre rôle dans ${policyName}, passant de ${oldRole} à ${newRole}. Vous avez été retiré de toutes les discussions de dépenses des déclarants, sauf de la vôtre.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `a mis à jour la devise par défaut en ${newCurrency} (auparavant ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `a mis à jour la fréquence de création automatique de notes de frais sur « ${newFrequency} » (auparavant « ${oldFrequency} »)`, diff --git a/src/languages/it.ts b/src/languages/it.ts index a5de51647749..29b425f998e8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4981,6 +4981,8 @@ ${amount} per ${merchant} - ${date}`, return 'Gestione persone'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Amministratore pagamenti'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Solo approvazione'; case CONST.POLICY.ROLE.USER: return 'Membro'; default: @@ -5021,6 +5023,7 @@ ${amount} per ${merchant} - ${date}`, cardAdminAlternateText: 'Gestisci le carte dello spazio di lavoro.', peopleAdminAlternateText: 'Gestisci i membri e i flussi di approvazione.', paymentsAdminAlternateText: 'Gestisci i pagamenti del flusso di lavoro.', + approveOnlyAlternateText: 'Approva solo i report.', readOnlyActionTitle: 'Non così in fretta...', readOnlyActionPrompt: 'Il tuo ruolo nello spazio di lavoro può visualizzare queste impostazioni, ma non può modificarle.', noAccessActionPrompt: 'Il tuo ruolo nello spazio di lavoro non ha accesso a queste impostazioni. Chiedi a un amministratore se ne hai bisogno.', @@ -7258,6 +7261,15 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, }, people: { genericFailureMessage: 'Si è verificato un errore durante la rimozione di un membro dallo spazio di lavoro, riprova per favore', + approveOnlyRoleBlockedTitle: 'Impossibile modificare questo ruolo', + approveOnlyRoleBlockedConfirm: 'Capito', + approveOnlyRoleBlockedDescription: 'I membri con solo approvazione non possono creare spese in questo spazio di lavoro. Questi problemi devono essere risolti prima:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Hanno una carta assegnata in questo spazio di lavoro. Un amministratore dello spazio di lavoro o delle carte deve prima rimuoverla.', + isRestrictedByDomainGroup: + 'Questo spazio di lavoro è lo spazio di lavoro preferito per un gruppo di dominio di cui fanno parte. Un amministratore di dominio deve prima modificarlo.', + isDefaultPolicy: 'Questo è il loro spazio di lavoro predefinito. Dovranno impostarne uno diverso prima che tu possa aggiornare il loro ruolo.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Sei sicuro di voler rimuovere ${memberName}?`, other: 'Sei sicuro di voler rimuovere questi membri?', @@ -7309,6 +7321,10 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, cannotRemove: 'Non puoi rimuovere te stessə o il proprietario dello spazio di lavoro', genericRemove: 'Si è verificato un problema durante la rimozione di quel membro dello spazio di lavoro', }, + makeApproveOnly: () => ({ + one: 'Imposta come solo approvazione', + other: 'Imposta come solo approvazione', + }), addedWithPrimary: 'Alcuni membri sono stati aggiunti con i loro accessi principali.', invitedBySecondaryLogin: (secondaryLogin: string) => `Aggiunto dall’accesso secondario ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Totale membri dello spazio di lavoro: ${count}`, @@ -7331,6 +7347,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, admins: 'Amministratori dello spazio di lavoro', approvers: 'Approvatori', auditors: 'Revisori', + approveOnly: 'Solo approvazione', guests: 'Ospiti', editors: 'Editori', emptyRoleFilter: {title: 'Nessun membro corrisponde a questo filtro', subtitle: 'Invita un membro o modifica il filtro qui sopra.'}, @@ -9619,8 +9636,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, other: `ti ha rimosso dai flussi di approvazione e dalle chat spese di ${joinedNames}. Le note spese già inviate resteranno disponibili per l'approvazione nella tua Posta in arrivo.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `ha aggiornato il tuo ruolo in ${policyName} da ${oldRole} a utente. Sei stato rimosso da tutte le chat delle note spese dei richiedenti, tranne che dalla tua.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `ha aggiornato il tuo ruolo in ${policyName} da ${oldRole} a ${newRole}. Sei stato rimosso da tutte le chat delle note spese dei richiedenti, tranne che dalla tua.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `ha aggiornato la valuta predefinita in ${newCurrency} (precedentemente ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `ha aggiornato la frequenza di creazione automatica dei report a "${newFrequency}" (in precedenza "${oldFrequency}")`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 675616d4fbdf..f6a5da4fc10c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4946,6 +4946,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' return 'メンバー管理'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return '支払い管理者'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return '承認のみ'; case CONST.POLICY.ROLE.USER: return 'メンバー'; default: @@ -4986,6 +4988,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' cardAdminAlternateText: 'ワークスペースカードを管理します。', peopleAdminAlternateText: 'メンバーと承認ワークフローを管理します。', paymentsAdminAlternateText: 'ワークフローの支払いを管理します。', + approveOnlyAlternateText: 'レポートの承認のみ。', readOnlyActionTitle: 'ちょっと待ってください…', readOnlyActionPrompt: 'このワークスペースでのあなたのロールは、これらの設定を表示できますが、編集することはできません。', noAccessActionPrompt: 'このワークスペースでのあなたのロールでは、これらの設定にアクセスできません。必要な場合は管理者に依頼してください。', @@ -7164,6 +7167,14 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から }, people: { genericFailureMessage: 'ワークスペースからメンバーを削除する際にエラーが発生しました。もう一度お試しください', + approveOnlyRoleBlockedTitle: 'この役割は変更できません', + approveOnlyRoleBlockedConfirm: '了解', + approveOnlyRoleBlockedDescription: '承認のみのメンバーはこのワークスペースで経費を作成できません。先に以下を解決する必要があります:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'このワークスペースでカードが割り当てられています。ワークスペース管理者またはカード管理者が先に割り当てを解除する必要があります。', + isRestrictedByDomainGroup: 'このワークスペースは、彼らが所属するドメイングループの優先ワークスペースです。ドメイン管理者が先に変更する必要があります。', + isDefaultPolicy: 'これは彼らのデフォルトのワークスペースです。役割を更新する前に、別のワークスペースを設定する必要があります。', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `${memberName} さんを削除してもよろしいですか?`, other: 'これらのメンバーを本当に削除しますか?', @@ -7215,6 +7226,10 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から cannotRemove: '自分自身またはワークスペースのオーナーを削除することはできません', genericRemove: 'そのワークスペースメンバーを削除する際に問題が発生しました', }, + makeApproveOnly: () => ({ + one: '承認のみに設定', + other: '承認のみに設定', + }), addedWithPrimary: '一部のメンバーは、プライマリーログインで追加されました。', invitedBySecondaryLogin: (secondaryLogin: string) => `セカンダリログイン${secondaryLogin}によって追加されました。`, workspaceMembersCount: (count: number) => `ワークスペースメンバー合計:${count}`, @@ -7237,6 +7252,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から admins: 'ワークスペース管理者', approvers: '承認者', auditors: '監査担当者', + approveOnly: '承認のみ', guests: 'ゲスト', editors: '編集者', emptyRoleFilter: {title: 'このフィルターに一致するメンバーはいません', subtitle: 'メンバーを招待するか、上のフィルターを変更してください。'}, @@ -9484,8 +9500,8 @@ ${reportName}`, other: `${joinedNames} の承認ワークフローと経費チャットからあなたを削除しました。これまでに提出されたレポートは、引き続き受信トレイで承認可能です。`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `${policyName} でのあなたのロールを ${oldRole} からユーザーに更新しました。あなた自身のものを除き、すべての申請者経費チャットから削除されました。`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `${policyName} でのあなたのロールを ${oldRole} から${newRole}に更新しました。あなた自身のものを除き、すべての申請者経費チャットから削除されました。`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `デフォルト通貨を${newCurrency}(以前は${oldCurrency})に更新しました`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `自動レポート頻度を「${newFrequency}」(以前は「${oldFrequency}」)に更新しました`, updateApprovalMode: (newValue: string, oldValue?: string) => `承認モードを「${newValue}」(以前は「${oldValue}」)に更新しました`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 47c159219060..ea7c19472ea2 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4983,6 +4983,8 @@ ${amount} voor ${merchant} - ${date}`, return 'Beheer personen'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Beheerder betalingen'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Alleen goedkeuren'; case CONST.POLICY.ROLE.USER: return 'Lid'; default: @@ -5023,6 +5025,7 @@ ${amount} voor ${merchant} - ${date}`, cardAdminAlternateText: 'Werkruimtekaarten beheren.', peopleAdminAlternateText: 'Beheer leden en goedkeuringsworkflows.', paymentsAdminAlternateText: 'Workflowsbetalingen beheren.', + approveOnlyAlternateText: 'Alleen rapporten goedkeuren.', readOnlyActionTitle: 'Niet zo snel...', readOnlyActionPrompt: 'Je rol in de workspace kan deze instellingen bekijken, maar niet bewerken.', noAccessActionPrompt: 'Je rol in deze workspace heeft geen toegang tot deze instellingen. Vraag een admin als je die nodig hebt.', @@ -7242,6 +7245,14 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, }, people: { genericFailureMessage: 'Er is een fout opgetreden bij het verwijderen van een lid uit de werkruimte, probeer het opnieuw.', + approveOnlyRoleBlockedTitle: 'Kan deze rol niet wijzigen', + approveOnlyRoleBlockedConfirm: 'Begrepen', + approveOnlyRoleBlockedDescription: 'Leden met alleen goedkeuren kunnen geen uitgaven aanmaken in deze werkruimte. Deze moeten eerst worden opgelost:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Er is een kaart aan hen toegewezen in deze werkruimte. Een werkruimte- of kaartbeheerder moet deze eerst ontkoppelen.', + isRestrictedByDomainGroup: 'Deze werkruimte is de voorkeurswerkruimte voor een domeingroep waarvan ze deel uitmaken. Een domeinbeheerder moet dit eerst wijzigen.', + isDefaultPolicy: 'Dit is hun standaardwerkruimte. Ze moeten een andere instellen voordat je hun rol kunt bijwerken.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Weet je zeker dat je ${memberName} wilt verwijderen?`, other: 'Weet je zeker dat je deze leden wilt verwijderen?', @@ -7293,6 +7304,10 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, cannotRemove: 'Je kunt jezelf of de eigenaar van de werkruimte niet verwijderen', genericRemove: 'Er is een probleem opgetreden bij het verwijderen van dat werkpleklid', }, + makeApproveOnly: () => ({ + one: 'Alleen goedkeuren maken', + other: 'Alleen goedkeuren maken', + }), addedWithPrimary: 'Sommige leden zijn toegevoegd met hun primaire login.', invitedBySecondaryLogin: (secondaryLogin: string) => `Toegevoegd door secundair login ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Totaal aantal werkruimteleden: ${count}`, @@ -7315,6 +7330,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, admins: 'Workspace-beheerders', approvers: 'Fiatteurs', auditors: 'Accountants', + approveOnly: 'Alleen goedkeuren', guests: 'Gasten', editors: 'Bewerkers', emptyRoleFilter: {title: 'Geen leden komen overeen met dit filter', subtitle: 'Nodig een lid uit of wijzig het filter hierboven.'}, @@ -9598,8 +9614,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, other: `heeft je verwijderd uit de goedkeuringsworkflows en onkostenchats van ${joinedNames}. Eerder ingediende rapporten blijven beschikbaar voor goedkeuring in je inbox.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `heeft je rol in ${policyName} bijgewerkt van ${oldRole} naar gebruiker. Je bent verwijderd uit alle inzendingsdeclaratiechats, behalve uit je eigen.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `heeft je rol in ${policyName} bijgewerkt van ${oldRole} naar ${newRole}. Je bent verwijderd uit alle inzendingsdeclaratiechats, behalve uit je eigen.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `heeft de standaardvaluta bijgewerkt naar ${newCurrency} (voorheen ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `heeft de frequentie van automatisch rapporteren gewijzigd naar ‘${newFrequency}’ (voorheen ‘${oldFrequency}’)`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a2728eb2528b..b7515c0c58c5 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5016,6 +5016,8 @@ ${amount} dla ${merchant} - ${date}`, return 'Administrator osób'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Administrator płatności'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Tylko zatwierdzanie'; case CONST.POLICY.ROLE.USER: return 'Członek'; default: @@ -5056,6 +5058,7 @@ ${amount} dla ${merchant} - ${date}`, cardAdminAlternateText: 'Zarządzaj kartami przestrzeni roboczej.', peopleAdminAlternateText: 'Zarządzaj członkami i procesami akceptacji.', paymentsAdminAlternateText: 'Zarządzaj płatnościami w przepływie pracy.', + approveOnlyAlternateText: 'Tylko zatwierdzanie raportów.', readOnlyActionTitle: 'Nie tak szybko…', readOnlyActionPrompt: 'Twoja rola w przestrzeni roboczej może wyświetlać te ustawienia, ale nie może ich edytować.', noAccessActionPrompt: 'Twoja rola w tym zespole nie ma dostępu do tych ustawień. Jeśli go potrzebujesz, poproś administratora.', @@ -7270,6 +7273,14 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, }, people: { genericFailureMessage: 'Wystąpił błąd podczas usuwania członka z przestrzeni roboczej, spróbuj ponownie', + approveOnlyRoleBlockedTitle: 'Nie można zmienić tej roli', + approveOnlyRoleBlockedConfirm: 'Rozumiem', + approveOnlyRoleBlockedDescription: 'Członkowie z rolą tylko zatwierdzania nie mogą tworzyć wydatków w tym obszarze roboczym. Należy najpierw rozwiązać następujące kwestie:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Mają przypisaną kartę w tym obszarze roboczym. Administrator obszaru roboczego lub kart musi ją najpierw usunąć.', + isRestrictedByDomainGroup: 'Ten obszar roboczy jest preferowanym obszarem roboczym dla grupy domen, do której należą. Administrator domeny musi to najpierw zmienić.', + isDefaultPolicy: 'To jest ich domyślny obszar roboczy. Muszą ustawić inny, zanim będzie można zaktualizować ich rolę.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Czy na pewno chcesz usunąć ${memberName}?`, other: 'Czy na pewno chcesz usunąć tych członków?', @@ -7321,6 +7332,10 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, cannotRemove: 'Nie możesz usunąć siebie ani właściciela przestrzeni roboczej', genericRemove: 'Wystąpił problem z usunięciem tego członka przestrzeni roboczej', }, + makeApproveOnly: () => ({ + one: 'Ustaw jako tylko zatwierdzanie', + other: 'Ustaw jako tylko zatwierdzanie', + }), addedWithPrimary: 'Niektóre osoby zostały dodane za pomocą swoich głównych loginów.', invitedBySecondaryLogin: (secondaryLogin: string) => `Dodane przez dodatkowy login ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Łączna liczba członków przestrzeni roboczej: ${count}`, @@ -7343,6 +7358,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, admins: 'Administratorzy przestrzeni roboczej', approvers: 'Osoby zatwierdzające', auditors: 'Audytorzy', + approveOnly: 'Tylko zatwierdzanie', guests: 'Goście', editors: 'Edytorzy', emptyRoleFilter: {title: 'Żadni członkowie nie pasują do tego filtra', subtitle: 'Zaproś członka lub zmień filtr powyżej.'}, @@ -9617,8 +9633,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, other: `usunął(-ę) Cię z obiegów akceptacji i czatów kosztowych użytkownika ${joinedNames}. Wcześniej przesłane raporty pozostaną dostępne do zatwierdzenia w Twojej skrzynce odbiorczej.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `zaktualizowano Twoją rolę w ${policyName} z ${oldRole} na użytkownika. Zostałeś usunięty ze wszystkich czatów wydatków osób rozliczających się, z wyjątkiem własnego czatu.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `zaktualizowano Twoją rolę w ${policyName} z ${oldRole} na ${newRole}. Zostałeś usunięty ze wszystkich czatów wydatków osób rozliczających się, z wyjątkiem własnego czatu.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `zaktualizowano domyślną walutę na ${newCurrency} (wcześniej ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `zaktualizowano częstotliwość automatycznego raportowania na „${newFrequency}” (poprzednio „${oldFrequency}”)`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4aa38a9e051d..e1651be6caa1 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4974,6 +4974,8 @@ ${amount} para ${merchant} - ${date}`, return 'Admin de pessoas'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Admin de pagamentos'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Somente aprovação'; case CONST.POLICY.ROLE.USER: return 'Membro'; default: @@ -5014,6 +5016,7 @@ ${amount} para ${merchant} - ${date}`, cardAdminAlternateText: 'Gerenciar cartões do workspace.', peopleAdminAlternateText: 'Gerencie membros e fluxos de aprovação.', paymentsAdminAlternateText: 'Gerencie pagamentos de fluxo de trabalho.', + approveOnlyAlternateText: 'Apenas aprovar relatórios.', readOnlyActionTitle: 'Calma aí...', readOnlyActionPrompt: 'Sua função no workspace pode ver essas configurações, mas não pode editá-las.', noAccessActionPrompt: 'Sua função no espaço de trabalho não tem acesso a essas configurações. Peça a um administrador se você precisar disso.', @@ -7241,6 +7244,15 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, }, people: { genericFailureMessage: 'Ocorreu um erro ao remover um membro do workspace, tente novamente', + approveOnlyRoleBlockedTitle: 'Não é possível alterar esta função', + approveOnlyRoleBlockedConfirm: 'Entendi', + approveOnlyRoleBlockedDescription: 'Membros somente de aprovação não podem criar despesas neste espaço de trabalho. Estes itens precisam ser resolvidos primeiro:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Um cartão está atribuído a eles neste espaço de trabalho. Um administrador do espaço de trabalho ou de cartões precisa desatribuí-lo primeiro.', + isRestrictedByDomainGroup: + 'Este espaço de trabalho é o espaço de trabalho preferido de um grupo de domínio do qual eles fazem parte. Um administrador de domínio precisa alterá-lo primeiro.', + isDefaultPolicy: 'Este é o espaço de trabalho padrão deles. Eles precisarão definir um diferente antes que você possa atualizar a função deles.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Tem certeza de que deseja remover ${memberName}?`, other: 'Tem certeza de que deseja remover estes membros?', @@ -7292,6 +7304,10 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, cannotRemove: 'Você não pode remover a si mesmo nem o proprietário do workspace', genericRemove: 'Houve um problema ao remover esse membro do workspace', }, + makeApproveOnly: () => ({ + one: 'Tornar somente de aprovação', + other: 'Tornar membros somente de aprovação', + }), addedWithPrimary: 'Alguns membros foram adicionados com seus logins principais.', invitedBySecondaryLogin: (secondaryLogin: string) => `Adicionado pelo login secundário ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Total de membros no workspace: ${count}`, @@ -7314,6 +7330,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, admins: 'Admins do workspace', approvers: 'Aprovadores', auditors: 'Auditores', + approveOnly: 'Somente de aprovação', guests: 'Convidados', editors: 'Editores', emptyRoleFilter: {title: 'Nenhum membro corresponde a este filtro', subtitle: 'Convide um membro ou altere o filtro acima.'}, @@ -9593,8 +9610,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, other: `removeu você dos fluxos de aprovação e chats de despesas de ${joinedNames}. Relatórios enviados anteriormente continuarão disponíveis para aprovação na sua Caixa de entrada.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `atualizou sua função em ${policyName} de ${oldRole} para usuário. Você foi removido de todos os chats de despesas de solicitantes, exceto do seu próprio.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `atualizou sua função em ${policyName} de ${oldRole} para ${newRole}. Você foi removido de todos os chats de despesas de solicitantes, exceto do seu próprio.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `atualizou a moeda padrão para ${newCurrency} (anteriormente ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `atualizou a frequência de preenchimento automático para "${newFrequency}" (antes "${oldFrequency}")`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 80bfcf78395d..f1961b8f9768 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4828,6 +4828,8 @@ ${amount},商户:${merchant} - 日期:${date}`, return '人员管理'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return '付款管理员'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return '仅审批'; case CONST.POLICY.ROLE.USER: return '成员'; default: @@ -4867,6 +4869,7 @@ ${amount},商户:${merchant} - 日期:${date}`, cardAdminAlternateText: '管理工作区卡片。', peopleAdminAlternateText: '管理成员和审批流程。', paymentsAdminAlternateText: '管理工作流付款。', + approveOnlyAlternateText: '仅审批报告。', readOnlyActionTitle: '别急……', readOnlyActionPrompt: '你的工作区角色可以查看这些设置,但不能编辑。', noAccessActionPrompt: '您的工作区角色无权访问这些设置。如需访问,请联系管理员。', @@ -6988,6 +6991,14 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM }, people: { genericFailureMessage: '从工作区移除成员时出错,请重试', + approveOnlyRoleBlockedTitle: '无法更改此角色', + approveOnlyRoleBlockedConfirm: '知道了', + approveOnlyRoleBlockedDescription: '仅审批成员无法在此工作区创建费用。需要先解决以下问题:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: '他们在此工作区中分配了一张卡。工作区管理员或卡管理员需要先取消分配。', + isRestrictedByDomainGroup: '此工作区是他们所在域组的首选工作区。域管理员需要先更改它。', + isDefaultPolicy: '这是他们的默认工作区。他们需要先设置一个不同的工作区,然后您才能更新他们的角色。', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `确定要移除 ${memberName} 吗?`, other: '确定要移除这些成员吗?', @@ -7039,6 +7050,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM cannotRemove: '你无法将自己或工作区所有者移除', genericRemove: '移除该工作区成员时出现问题', }, + makeApproveOnly: () => ({ + one: '设为仅审批', + other: '设为仅审批', + }), addedWithPrimary: '某些成员已通过其主登录名添加。', invitedBySecondaryLogin: (secondaryLogin: string) => `由次要登录账号 ${secondaryLogin} 添加。`, workspaceMembersCount: (count: number) => `工作区成员总数:${count}`, @@ -7057,6 +7072,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM admins: '工作区管理员', approvers: '审批人', auditors: '审计员', + approveOnly: '仅审批', guests: '访客', editors: '编辑者', emptyRoleFilter: {title: '没有成员符合此筛选条件', subtitle: '邀请成员或更改上方的筛选条件。'}, @@ -9240,7 +9256,8 @@ ${reportName}`, other: `已将你从${joinedNames}的审批工作流和费用聊天中移除。你之前提交的报告仍可在收件箱中供你审批。`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => `已将你在 ${policyName} 中的角色从 ${oldRole} 更新为用户。你已从所有报销人费用聊天中移除,但你自己的除外。`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `已将你在 ${policyName} 中的角色从 ${oldRole} 更新为${newRole}。你已从所有报销人费用聊天中移除,但你自己的除外。`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `已将默认货币更新为 ${newCurrency}(之前为 ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `已将自动报表频率更新为“${newFrequency}”(此前为“${oldFrequency}”)`, updateApprovalMode: (newValue: string, oldValue?: string) => `将审批模式更新为“${newValue}”(之前为“${oldValue}”)`, diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 9a9bb75c1ab5..dadd10517e8b 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -411,6 +411,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.APPROVAL_WORKFLOW, ONYXKEYS.ASSIGN_CARD, ONYXKEYS.BILLING_RECEIPT_DETAILS, + ONYXKEYS.BLOCKED_ROLE_CHANGE, ONYXKEYS.COLLECTION.AGENT, ONYXKEYS.COLLECTION.ATTACHMENT, ONYXKEYS.COLLECTION.BANK_ACCOUNT_SHARE_DETAILS, diff --git a/src/libs/Fullstory/types.ts b/src/libs/Fullstory/types.ts index d8f1b7c4620c..a65a9645f7b3 100644 --- a/src/libs/Fullstory/types.ts +++ b/src/libs/Fullstory/types.ts @@ -1,8 +1,10 @@ +import type {POLICY_ROLE_RANKING} from '@libs/PolicyUtils/permissions'; + import type CONST from '@src/CONST'; import type {OnyxInputOrEntry, Report, Session, UserMetadata} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; type FSClass = ValueOf; @@ -15,7 +17,8 @@ type FullstoryUserVars = { user_status?: 'new' | 'returning'; has_completed_onboarding?: boolean; onb_step?: 'registration' | 'accounting' | 'completed'; - user_role?: 'admin' | 'paymentsAdmin' | 'cardAdmin' | 'peopleAdmin' | 'auditor' | 'editor' | 'member' | 'guest'; + // Every ranked policy role, with USER renamed to FullStory's 'member'. + user_role?: Exclude, typeof CONST.POLICY.ROLE.USER> | 'member'; workspace_state?: 'has_workspaces' | 'no_workspaces'; workspace_count?: number; workspace_member_count?: number; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 6d93a59df7e6..b3de14c8417d 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -22,6 +22,7 @@ import type {OptionData as PersonalDetailOptionData} from '@libs/PersonalDetailO import {getLoginByAccountID, getPersonalDetailForAccountID, getPersonalDetailsForAccountIDs, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import { + canCreateExpensesOnPolicy, canSendInvoiceFromWorkspace, canSubmitPerDiemExpenseFromWorkspace, getCountOfEnabledTagsOfList, @@ -1938,6 +1939,7 @@ function isValidReport( isDefaultRoomsBetaEnabled = false, includeMultipleParticipantReports = false, includeOwnedWorkspaceChats = false, + excludeWorkspacesWithoutCreateExpensePermission = false, includeThreads = false, includeTasks = false, includeMoneyRequests = false, @@ -2002,6 +2004,10 @@ function isValidReport( return false; } + if (isPolicyExpenseChat && excludeWorkspacesWithoutCreateExpensePermission && !canCreateExpensesOnPolicy(policy)) { + return false; + } + if (isPolicyExpenseChat && isRestrictedToPreferredPolicy && option.policyID !== preferredPolicyID) { return false; } diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index 022248c7f394..ad09277a1994 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -217,6 +217,7 @@ type GetValidReportsConfig = { forcePolicyNamePreview?: boolean; includeSelfDM?: boolean; includeOwnedWorkspaceChats?: boolean; + excludeWorkspacesWithoutCreateExpensePermission?: boolean; includeThreads?: boolean; includeTasks?: boolean; includeMoneyRequests?: boolean; @@ -250,6 +251,7 @@ type IsValidReportsConfig = Pick< | 'isDefaultRoomsBetaEnabled' | 'includeMultipleParticipantReports' | 'includeOwnedWorkspaceChats' + | 'excludeWorkspacesWithoutCreateExpensePermission' | 'includeThreads' | 'includeTasks' | 'includeMoneyRequests' diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 4a559c966495..5b89f3a04516 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -90,18 +90,26 @@ const EDITOR_POLICY_FEATURES = buildFeatureAccessMap(CONST.POLICY.POLICY_FEATURE const ROLE_PERMISSION_BUNDLES: Record>> = { [CONST.POLICY.ROLE.ADMIN]: WRITE_ALL_POLICY_FEATURES, [CONST.POLICY.ROLE.EDITOR]: EDITOR_POLICY_FEATURES, + // Auditors get read-only access to workspace settings, write access to chat rooms, and can create expenses. [CONST.POLICY.ROLE.AUDITOR]: { ...READ_ALL_POLICY_FEATURES, [CONST.POLICY.POLICY_FEATURE.ROOMS]: CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE, + [CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES]: CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE, }, [CONST.POLICY.ROLE.USER]: { [CONST.POLICY.POLICY_FEATURE.OVERVIEW]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, [CONST.POLICY.POLICY_FEATURE.MEMBERS]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, [CONST.POLICY.POLICY_FEATURE.AGENTS]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, [CONST.POLICY.POLICY_FEATURE.ROOMS]: CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE, + [CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES]: CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE, + }, + [CONST.POLICY.ROLE.APPROVE_ONLY]: { + [CONST.POLICY.POLICY_FEATURE.OVERVIEW]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, + [CONST.POLICY.POLICY_FEATURE.MEMBERS]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, }, [CONST.POLICY.ROLE.GUEST]: { [CONST.POLICY.POLICY_FEATURE.OVERVIEW]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, + [CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES]: CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE, }, [CONST.POLICY.ROLE.CARD_ADMIN]: { [CONST.POLICY.POLICY_FEATURE.OVERVIEW]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, @@ -110,6 +118,7 @@ const ROLE_PERMISSION_BUNDLES: Record controlPolicyOnlyRole === role); @@ -196,10 +214,11 @@ function canMemberAssignRole(policy: OnyxInputOrEntry, login: string, ro return true; } - // Reaching here: USER always, plus GUEST/AUDITOR only on corporate policies (control-only roles are - // already filtered out on non-corporate policies above). Assigning USER/GUEST/AUDITOR needs the - // MEMBERS permission, and only on corporate policies. - const isNonElevatedRole = role === CONST.POLICY.ROLE.USER || role === CONST.POLICY.ROLE.GUEST || role === CONST.POLICY.ROLE.AUDITOR; + // Reaching here: USER always, plus GUEST/AUDITOR/APPROVE_ONLY only on corporate policies (control-only roles are + // already filtered out on non-corporate policies above). Assigning these needs the + // MEMBERS permission, and only on corporate policies. The following roles stay within a People Admin's + // authority because they are less permissive than the "People Admin" role. + const isNonElevatedRole = role === CONST.POLICY.ROLE.USER || role === CONST.POLICY.ROLE.GUEST || role === CONST.POLICY.ROLE.AUDITOR || role === CONST.POLICY.ROLE.APPROVE_ONLY; return isCorporatePolicy && canMemberWrite(policy, login, CONST.POLICY.POLICY_FEATURE.MEMBERS) && isNonElevatedRole; } @@ -215,6 +234,7 @@ const SELECTABLE_ROLES = [ CONST.POLICY.ROLE.PEOPLE_ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN, CONST.POLICY.ROLE.USER, + CONST.POLICY.ROLE.APPROVE_ONLY, ] as const; /** @@ -267,6 +287,7 @@ const POLICY_ROLE_RANKING = [ CONST.POLICY.ROLE.AUDITOR, CONST.POLICY.ROLE.EDITOR, CONST.POLICY.ROLE.USER, + CONST.POLICY.ROLE.APPROVE_ONLY, CONST.POLICY.ROLE.GUEST, ] as const; @@ -297,6 +318,14 @@ function canRolePay(role: string | undefined): boolean { */ const PAYER_ROLES = Object.values(CONST.POLICY.ROLE).filter(canRolePay); +function canRoleCreateExpenses(role: string | undefined): boolean { + return !!role && ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES] === CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE; +} + +function canCreateExpensesOnPolicy(policy: OnyxInputOrEntry, login?: string): boolean { + return canRoleCreateExpenses(getPolicyRole(policy, login, false) ?? policy?.role); +} + /** Check if the passed employee is an approver in the policy's employeeList */ function isPolicyApprover(policy: OnyxInputOrEntry, employeeLogin: string) { if (policy?.approver === employeeLogin) { @@ -368,6 +397,7 @@ function canEditWorkspaceSettings(policy: OnyxInputOrEntry, login?: stri export { isPolicyAdmin, isPolicyOwner, + POLICY_ROLE_RANKING, isRoomMemberProtectedByPolicyRole, isControlPolicyOnlyRole, canMemberRead, @@ -380,6 +410,8 @@ export { getPolicyRole, getHighestPolicyRole, canRolePay, + canRoleCreateExpenses, + canCreateExpensesOnPolicy, PAYER_ROLES, isPolicyApprover, getPolicyApproverLogins, diff --git a/src/libs/PolicyUtils/policy.ts b/src/libs/PolicyUtils/policy.ts index 23faa9cc4acb..7c5970c4bc05 100644 --- a/src/libs/PolicyUtils/policy.ts +++ b/src/libs/PolicyUtils/policy.ts @@ -21,7 +21,7 @@ import type {ValueOf} from 'type-fest'; import {hasAccountingConnections} from './connections'; import {getPerDiemCustomUnit, isPerDiemEligiblePolicy} from './customUnits'; import {isTimeTrackingEnabled} from './features'; -import {canEditWorkspaceSettings, canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyOwner, isPolicyUser} from './permissions'; +import {canCreateExpensesOnPolicy, canEditWorkspaceSettings, canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyOwner, isPolicyUser} from './permissions'; import {isArchivedPolicy, isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitPolicy} from './policyType'; type WorkspaceDetails = { @@ -281,11 +281,13 @@ function hasAnyPaidPolicy(policies: OnyxCollection | null) { return getGroupPaidPolicies(policies).length > 0; } +/** /** * Whether the user can create a report on this workspace: a paid (Team/Corporate) or Submit workspace the user * has a role on. Submit workspaces are free but still support report creation, so they belong here even though * they're excluded from `getGroupPaidPolicies`. A policy stub (e.g. the `{id, name}` the backend sends for a - * preferred workspace the user is not a member of) has no type or role and is rejected. + * preferred workspace the user is not a member of) has no type or role and is rejected. Workspaces whose role for the user + * cannot create expenses (e.g. approve-only) are excluded. */ function canCreateReportOnPolicy(policy: OnyxEntry, currentUserLogin?: string): policy is Policy { return ( @@ -293,6 +295,7 @@ function canCreateReportOnPolicy(policy: OnyxEntry, currentUserLogin?: s !policy.isJoinRequestPending && (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && shouldShowPolicy(policy, false, currentUserLogin) && + canCreateExpensesOnPolicy(policy, currentUserLogin) && !isTeachersUnitePolicyID(policy.id) ); } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index e47d9ce19b28..32064512b8be 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4603,7 +4603,8 @@ function getDemotedFromWorkspaceMessage(translate: LocalizedTranslate, reportAct const originalMessage = getOriginalMessage(reportAction); const policyName = originalMessage?.policyName ?? translate('workspace.common.workspace'); const oldRole = translate('workspace.common.roleName', originalMessage?.oldRole).toLowerCase(); - return translate('workspaceActions.demotedFromWorkspace', policyName, oldRole); + const newRole = translate('workspace.common.roleName', originalMessage?.newRole).toLowerCase(); + return translate('workspaceActions.demotedFromWorkspace', policyName, oldRole, newRole); } function getUpdatedAuditRateMessage(translate: LocalizedTranslate, reportAction: OnyxEntry) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 5b80f79153cd..069dfd8c66a9 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -143,6 +143,7 @@ import {getPolicyReimburserAccountID} from './PolicyPaymentUtils'; import { arePaymentsEnabled, canMemberWrite as canMemberWritePolicyUtils, + canCreateExpensesOnPolicy, canSendInvoiceFromWorkspace, getActivePolicies, getConnectedIntegration, @@ -3179,6 +3180,10 @@ function canAddTransaction(moneyRequestReport: OnyxEntry, rules: OnyxCol // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 const policy = getPolicy(moneyRequestReport?.policyID); + if (policy && !canCreateExpensesOnPolicy(policy)) { + return false; + } + if (isExpenseReport(moneyRequestReport) && (!isCurrentUserSubmitter(moneyRequestReport) || !isGroupPolicyPolicyUtils(policy))) { return false; } @@ -11581,6 +11586,11 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o return false; } + // Members without create-expenses permission get no create options in the workspace chats and reports. + if (!!policy?.id && report?.policyID === policy.id && !canCreateExpensesOnPolicy(policy)) { + return false; + } + const isOwnPolicyExpenseChat = report?.isOwnPolicyExpenseChat ?? false; // In case there are no other participants than the current user and it's not user's own policy expense chat, they can't submit expenses from such report. @@ -14312,6 +14322,10 @@ function isWorkspaceEligibleForReportChange(submitterEmail: string | undefined, if (report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED && !isPolicyAdminPolicyUtils(newPolicy)) { return false; } + // Moving a report creates expenses there, so the submitter must hold create-expenses permission. + if (!canCreateExpensesOnPolicy(newPolicy, submitterEmail)) { + return false; + } return !!newPolicy.role && !isPendingDeletePolicy(newPolicy); } diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index a54d0e330422..384ee06abfc8 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -22,7 +22,7 @@ import Parser from '@libs/Parser'; import {buildPersonalDetailsUpdate} from '@libs/PersonalDetailsUtils'; import type {PersonalDetailsOnyxUpdate} from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; -import {getDefaultApprover, getOwnerChangePayerSuccessData, isControlPolicy, isPolicyAdmin, isSubmitPolicy} from '@libs/PolicyUtils'; +import {canRoleCreateExpenses, getDefaultApprover, getOwnerChangePayerSuccessData, isControlPolicy, isPolicyAdmin, isSubmitPolicy} from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -723,8 +723,7 @@ function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMemberEma if (!policy?.id) { return; } - // Submit workspaces lock everyone to the Editor role — role changes are not allowed. - // This guard prevents any future code path from bypassing the UI-level blocks. + // Submit workspaces lock everyone to the Editor role, so role changes are not allowed. if (isSubmitPolicy(policy)) { return; } @@ -875,15 +874,17 @@ function buildAddMembersToWorkspaceOnyxData( doesPersonalDetailExistByAccountID[accountID] = !newPersonalDetailAccountIDs.has(accountID); } - // create onyx data for policy expense chats for each new member - const membersChats = createPolicyExpenseChats({ - policyID, - invitedEmailsToAccountIDs, - currentUser, - reportActionsList, - notificationPreference: policyExpenseChatNotificationPreference, - doesPersonalDetailExistByAccountID, - }); + // Roles that cannot create expenses don't get a workspace chat, so don't build an optimistic one. + const membersChats = canRoleCreateExpenses(effectiveRole) + ? createPolicyExpenseChats({ + policyID, + invitedEmailsToAccountIDs, + currentUser, + reportActionsList, + notificationPreference: policyExpenseChatNotificationPreference, + doesPersonalDetailExistByAccountID, + }) + : {onyxSuccessData: [], onyxOptimisticData: [], onyxFailureData: [], reportCreationData: {}}; const optimisticMembersState: OnyxCollectionInputValue = {}; const successMembersState: OnyxCollectionInputValue = {}; @@ -1285,6 +1286,10 @@ function clearUpdateMemberRoleError(policyID: string, login: string) { }); } +function clearBlockedRoleChange() { + Onyx.set(ONYXKEYS.BLOCKED_ROLE_CHANGE, null); +} + function openWorkspaceMembersPage(policyID: string, clientMemberEmails: string[]) { if (!policyID || !clientMemberEmails) { Log.warn('openWorkspaceMembersPage invalid params', {policyID, clientMemberEmails}); @@ -1587,6 +1592,7 @@ export { clearDeleteMemberError, clearAddMemberError, clearUpdateMemberRoleError, + clearBlockedRoleChange, openWorkspaceMembersPage, setWorkspaceInviteMembersDraft, inviteMemberToWorkspace, diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index 865d9abcffa8..c159ceb640e9 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -18,7 +18,7 @@ import useWorkspaceList from '@hooks/useWorkspaceList'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {isGroupPolicy} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, isGroupPolicy} from '@libs/PolicyUtils'; import type {MoneyRequestNavigatorParamList} from '@navigation/types'; @@ -78,7 +78,7 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { shouldSortSelectedToTop: false, searchTerm: debouncedSearchTerm, localeCompare, - additionalFilter: (newPolicy) => isGroupPolicy(newPolicy), + additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && canCreateExpensesOnPolicy(newPolicy), }); const confirmButtonOptions = { diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 2aabbeadfcfa..5237380336fb 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -28,7 +28,7 @@ import getButtonState from '@libs/getButtonState'; import getIconForAction from '@libs/getIconForAction'; import Navigation from '@libs/Navigation/Navigation'; import {getDistanceExpenseTypeForPolicy} from '@libs/PolicyDistanceRatesUtils'; -import {isGroupPolicyByType} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, isGroupPolicyByType} from '@libs/PolicyUtils'; import { canCreateTaskInReport, getPayeeName, @@ -336,21 +336,18 @@ function AttachmentPickerWithMenuItems({ rules, ]); - const createReportOption: PopoverMenuItem[] = useMemo(() => { - if (!isPolicyExpenseChat(report) || !isGroupPolicyByType(policy?.type) || !isReportOwner(report)) { - return []; - } - - return [ - { - icon: icons.Document, - text: translate('report.newReport.createReport'), - shouldCallAfterModalHide: shouldUseNarrowLayout, - sentryLabel: CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_MENU_CREATE_REPORT, - onSelected: () => selectOption(() => handleCreateReport(), true), - }, - ]; - }, [icons.Document, handleCreateReport, policy?.type, report, selectOption, shouldUseNarrowLayout, translate]); + const createReportOption: PopoverMenuItem[] = + !isPolicyExpenseChat(report) || !isGroupPolicyByType(policy?.type) || !isReportOwner(report) || !canCreateExpensesOnPolicy(policy) + ? [] + : [ + { + icon: icons.Document, + text: translate('report.newReport.createReport'), + shouldCallAfterModalHide: shouldUseNarrowLayout, + sentryLabel: CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_MENU_CREATE_REPORT, + onSelected: () => selectOption(() => handleCreateReport(), true), + }, + ]; /** * Determines if we can show the task option diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index ae2d54fe1464..ceda4009a1ca 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -199,14 +199,14 @@ function ParticipantSearchResults({ const isGroupPolicy = isGroupPolicyUtil(policy); const activeAdminWorkspaces = getActiveAdminWorkspaces(allPolicies, currentUserLogin); - - const includeOwnedWorkspaceChats = - !shouldExcludeWorkspaces && (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK); + const canUseWorkspaceChats = iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK; + const includeOwnedWorkspaceChats = !shouldExcludeWorkspaces && canUseWorkspaceChats; const getValidOptionsConfig = { selectedOptions: participants as Participant[], excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeOwnedWorkspaceChats, + excludeWorkspacesWithoutCreateExpensePermission: canUseWorkspaceChats, excludeNonAdminWorkspaces: action === CONST.IOU.ACTION.SHARE, includeP2P: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest && !isTransactionFromCreditCardImport && !shouldExcludeP2P, includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index 863467854081..2960879731db 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -20,7 +20,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportTransactions from '@hooks/useReportTransactions'; import Navigation from '@libs/Navigation/Navigation'; -import {canSubmitPerDiemExpenseFromWorkspace, isPerDiemEnabled, isPolicyAdmin, isTimeTrackingEnabled} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, canSubmitPerDiemExpenseFromWorkspace, isPerDiemEnabled, isPolicyAdmin, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import {canAddTransaction, getIconsForExpenseReport, isIOUReport, isOpenReport, isReportOwner, isSelfDM, sortOutstandingReportsBySelected} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {isPerDiemRequest as isPerDiemRequestUtil} from '@libs/TransactionUtils'; @@ -165,6 +165,8 @@ function IOURequestEditReportCommon({ return []; } + const reportOwnerLogin = personalDetails?.[resolvedReportOwnerAccountID]?.login; + return outstandingReports .sort((report1, report2) => sortOutstandingReportsBySelected(report1, report2, selectedReportID, localeCompare)) .filter((report) => !debouncedSearchValue || report?.reportName?.toLowerCase().includes(debouncedSearchValue.toLowerCase())) @@ -172,6 +174,10 @@ function IOURequestEditReportCommon({ .filter((report) => { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + if (!canCreateExpensesOnPolicy(policy, reportOwnerLogin)) { + return false; + } + if (isTimeRequest && !isTimeTrackingEnabled(policy)) { return false; } @@ -210,6 +216,7 @@ function IOURequestEditReportCommon({ localeCompare, allPolicies, currentUserPersonalDetails.accountID, + resolvedReportOwnerAccountID, hasMultipleSubmitters, isPerDiemRequest, isTimeRequest, diff --git a/src/pages/workspace/MemberRightIcon.tsx b/src/pages/workspace/MemberRightIcon.tsx index 126fc7f6498d..b3ca5f2e3183 100644 --- a/src/pages/workspace/MemberRightIcon.tsx +++ b/src/pages/workspace/MemberRightIcon.tsx @@ -31,7 +31,8 @@ export default function MemberRightIcon({role, owner, login, badgeStyles}: Membe role === CONST.POLICY.ROLE.GUEST || role === CONST.POLICY.ROLE.CARD_ADMIN || role === CONST.POLICY.ROLE.PEOPLE_ADMIN || - role === CONST.POLICY.ROLE.PAYMENTS_ADMIN + role === CONST.POLICY.ROLE.PAYMENTS_ADMIN || + role === CONST.POLICY.ROLE.APPROVE_ONLY ) { badgeText = translate('workspace.common.roleName', role); } else if (role === CONST.POLICY.ROLE.EDITOR) { diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index f53b3e4233c5..6fa484c6577a 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -25,7 +25,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; -import {canMemberWrite, filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; +import {canMemberWrite, canRoleCreateExpenses, filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -218,7 +218,7 @@ function AssigneeStep({route}: AssigneeStepProps) { const membersDetails: AssigneeListItem[] = []; if (policy?.employeeList) { for (const [email, policyEmployee] of Object.entries(policy.employeeList ?? {})) { - if (isDeletedPolicyEmployee(policyEmployee, isOffline)) { + if (isDeletedPolicyEmployee(policyEmployee, isOffline) || (policyEmployee.role && !canRoleCreateExpenses(policyEmployee.role))) { continue; } diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index 9d40d915fd4c..ea290cdd9838 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -21,7 +21,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; -import {canMemberWrite, filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; +import {canMemberWrite, canRoleCreateExpenses, filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -148,7 +148,7 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr const membersDetails: AssigneeListItem[] = []; if (policy?.employeeList) { for (const [email, policyEmployee] of Object.entries(policy.employeeList ?? {})) { - if (isDeletedPolicyEmployee(policyEmployee, isOffline)) { + if (isDeletedPolicyEmployee(policyEmployee, isOffline) || (policyEmployee.role && !canRoleCreateExpenses(policyEmployee.role))) { continue; } diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 6cf74e079b4c..9c9ae213425a 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -3,8 +3,10 @@ import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; import type {ListItemType} from '@components/WorkspaceMemberRoleList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; +import useSingleExecution from '@hooks/useSingleExecution'; import {updateWorkspaceMembersRole} from '@libs/actions/Policy/Member'; import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; @@ -24,8 +26,9 @@ import type SCREENS from '@src/SCREENS'; import type {PersonalDetailsList} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; -import React from 'react'; +import React, {useState} from 'react'; type WorkspaceMemberDetailsRolePageProps = Omit & PlatformStackScreenProps & { @@ -36,9 +39,12 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const accountID = Number(route.params.accountID); const policyID = route.params.policyID; const showRuleBotGuardModal = useRuleBotGuardModal(); + const {singleExecution} = useSingleExecution(); + const {translate} = useLocalize(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; + const [selectedRole, setSelectedRole] = useState>(); 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 (for example Admin or Payments Admin). const isReimburser = isPolicyReimburser(policy, memberLogin); @@ -49,22 +55,22 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp upgradeFeatureAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, }); - const changeRole = ({value}: ListItemType) => { - if (value === member?.role) { + const saveRole = () => { + if (!selectedRole || selectedRole === member?.role) { return; } - if (!canMemberAssignRole(policy, currentUserLogin, value)) { + if (!canMemberAssignRole(policy, currentUserLogin, selectedRole)) { return; } // Guard the direct-navigation path: a reimburser must stay a valid payer, so reject any role that cannot pay. - if (isReimburser && !canRolePay(value)) { + if (isReimburser && !canRolePay(selectedRole)) { return; } - if (value !== CONST.POLICY.ROLE.ADMIN && isRuleBotEnforcingRules(accountID, policy)) { + if (selectedRole !== CONST.POLICY.ROLE.ADMIN && isRuleBotEnforcingRules(accountID, policy)) { showRuleBotGuardModal('changeRole', policyID); return; } - updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value); + updateWorkspaceMembersRole(policy, [memberLogin], [accountID], selectedRole); Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }; @@ -80,11 +86,17 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp enableEdgeToEdgeBottomSafeAreaPadding > setSelectedRole(item.value)} allowedRoles={allowedRoles} navigateBackTo={ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)} + confirmButtonOptions={{ + showButton: true, + text: translate('common.save'), + onConfirm: singleExecution(saveRole), + isDisabled: !selectedRole || selectedRole === member?.role, + }} /> diff --git a/src/setup/index.ts b/src/setup/index.ts index 0a2b6a0b1c3d..ac09b4fc84c5 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -66,6 +66,7 @@ export default function () { [ONYXKEYS.RAM_ONLY_IS_PRODUCT_MARKETING_WINDOW_COVERED]: false, // Ensure the Supportal permission modal doesn't persist across reloads [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null, + [ONYXKEYS.BLOCKED_ROLE_CHANGE]: null, [ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false, // Without a default this server-owned NVP has no row until it arrives, and its loading status holds the Search router behind a skeleton [ONYXKEYS.RECENT_SEARCHES]: {}, diff --git a/src/types/onyx/BlockedRoleChange.ts b/src/types/onyx/BlockedRoleChange.ts new file mode 100644 index 000000000000..3d51177699c0 --- /dev/null +++ b/src/types/onyx/BlockedRoleChange.ts @@ -0,0 +1,13 @@ +/** Model of a refused approve-only role change, delivered to the requesting client to show in a modal */ +type BlockedRoleChange = { + /** Policy the refused role change targeted */ + policyID: string; + + /** Member whose role change was refused */ + email: string; + + /** Reason keys from the API explaining why the role was refused */ + blockedReasons: string[]; +}; + +export default BlockedRoleChange; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 5eafc50f3336..431a847c8db1 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1452,6 +1452,9 @@ type OriginalMessageDemotedFromWorkspace = { /** The old role of the employee that is being demoted */ oldRole: string; + /** The new role of the employee that is being demoted. Missing on actions created before the role was included */ + newRole?: string; + /** The accountID of the member who was demoted from workspace */ whisperedTo: number[]; }; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 003be9900ede..600311107085 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -24,6 +24,7 @@ import type BillingGraceEndPeriod from './BillingGraceEndPeriod'; import type BillingReceiptDetails from './BillingReceiptDetails'; import type BillingStatus from './BillingStatus'; import type BlockedFromConcierge from './BlockedFromConcierge'; +import type BlockedRoleChange from './BlockedRoleChange'; import type BulkAction from './BulkAction'; import type CancellationDetails from './CancellationDetails'; import type Card from './Card'; @@ -281,6 +282,7 @@ export type { BetaConfiguration, BetaOverrides, BlockedFromConcierge, + BlockedRoleChange, Card, CardList, CardOnWaitlist, diff --git a/tests/actions/IOU/GetMoneyRequestInformationTest.ts b/tests/actions/IOU/GetMoneyRequestInformationTest.ts index 5b6dcf8fe5c0..b9ea2f99f926 100644 --- a/tests/actions/IOU/GetMoneyRequestInformationTest.ts +++ b/tests/actions/IOU/GetMoneyRequestInformationTest.ts @@ -177,7 +177,12 @@ describe('getMoneyRequestInformation', () => { const moneyRequestReportID = 'iou-report-lookup-1'; const differentPolicyID = 'policy-different'; await Onyx.set(ONYXKEYS.SESSION, {accountID: PAYEE_ACCOUNT_ID, email: 'payee@example.com'}); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${differentPolicyID}`, {id: differentPolicyID, type: CONST.POLICY.TYPE.CORPORATE, name: 'Test Policy'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${differentPolicyID}`, { + id: differentPolicyID, + type: CONST.POLICY.TYPE.CORPORATE, + name: 'Test Policy', + role: CONST.POLICY.ROLE.USER, + }); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`, { reportID: moneyRequestReportID, policyID: differentPolicyID, diff --git a/tests/ui/AssignCardAssigneeStepTest.tsx b/tests/ui/AssignCardAssigneeStepTest.tsx index cf22eec82e40..3ed5d3a05a3d 100644 --- a/tests/ui/AssignCardAssigneeStepTest.tsx +++ b/tests/ui/AssignCardAssigneeStepTest.tsx @@ -100,6 +100,7 @@ jest.mock('@libs/PersonalDetailsUtils', () => ({ })); jest.mock('@libs/PolicyUtils', () => ({ canMemberWrite: jest.fn(() => false), + canRoleCreateExpenses: jest.fn((role?: string) => role !== 'approveOnly'), filterGuideAndAccountManager: jest.fn((items: unknown[]) => items), getGuideAndAccountManagerInfo: jest.fn(() => ({assignedGuideEmail: undefined, accountManagerLogin: undefined, exclusions: {}})), getIneligibleInvitees: jest.fn(() => []), diff --git a/tests/ui/IssueNewCardAssigneeStepTest.tsx b/tests/ui/IssueNewCardAssigneeStepTest.tsx index 46826b25160a..ac61c68a1b53 100644 --- a/tests/ui/IssueNewCardAssigneeStepTest.tsx +++ b/tests/ui/IssueNewCardAssigneeStepTest.tsx @@ -94,6 +94,7 @@ jest.mock('@hooks/usePersonalDetailSearchSelector', () => jest.mock('@libs/PolicyUtils', () => ({ canMemberWrite: jest.fn(() => true), + canRoleCreateExpenses: jest.fn((role?: string) => role !== 'approveOnly'), filterGuideAndAccountManager: (list: unknown) => list, getGuideAndAccountManagerInfo: jest.fn(() => ({assignedGuideEmail: undefined, accountManagerLogin: undefined, exclusions: {}})), getIneligibleInvitees: jest.fn(() => []), diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 6c2d99adba7e..cf0d2923c3e8 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -1397,6 +1397,99 @@ describe('OptionsListUtils', () => { expect(results.recentReports).toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})])); }); + describe('excludeWorkspacesWithoutCreateExpensePermission', () => { + const approveOnlyPolicyID = 'APPROVE_ONLY_POLICY_1'; + const approveOnlyChatReportID = '901'; + + const buildPolicyWithRole = (role: Policy['role']): Policy => ({ + id: approveOnlyPolicyID, + name: 'Approve Only Workspace', + role, + type: CONST.POLICY.TYPE.CORPORATE, + owner: 'reedrichards@expensify.com', + outputCurrency: 'USD', + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + employeeList: { + [CURRENT_USER_EMAIL]: { + email: CURRENT_USER_EMAIL, + role, + }, + }, + }); + + const workspaceChatReports: OnyxCollection = { + [approveOnlyChatReportID]: { + reportID: approveOnlyChatReportID, + reportName: 'Approve Only Workspace', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + policyID: approveOnlyPolicyID, + policyName: 'Approve Only Workspace', + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + lastVisibleActionCreated: '2022-11-22 03:26:02.001', + participants: { + [CURRENT_USER_ACCOUNT_ID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, + }, + }, + }; + + // Mirrors the config ParticipantSearchResults passes for create/submit flows + const getOptionsForPolicy = (policy: Policy) => { + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}${approveOnlyPolicyID}`]: policy, + }; + const optionList = createFilteredOptionList( + PERSONAL_DETAILS, + workspaceChatReports, + createMockReportAttributesDerived(workspaceChatReports, PERSONAL_DETAILS, CURRENT_USER_ACCOUNT_ID), + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + policies, + {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true, formatPhoneNumber}, + undefined, + ); + + return getValidOptions( + optionList, + policies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + { + getReportByID: getReportByIDFromOnyx, + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + includeP2P: true, + includeOwnedWorkspaceChats: true, + excludeWorkspacesWithoutCreateExpensePermission: true, + shouldSeparateWorkspaceChat: true, + action: CONST.IOU.ACTION.CREATE, + personalDetails: PERSONAL_DETAILS, + }, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); + }; + + it('should exclude the workspace chat when the member role is approveOnly', () => { + const {options: results} = getOptionsForPolicy(buildPolicyWithRole(CONST.POLICY.ROLE.APPROVE_ONLY)); + + expect(results.workspaceChats).toEqual([]); + expect(results.recentReports.find((report) => report.reportID === approveOnlyChatReportID)).toBeUndefined(); + }); + + it('should include the workspace chat when the member role can create expenses', () => { + const {options: results} = getOptionsForPolicy(buildPolicyWithRole(CONST.POLICY.ROLE.USER)); + + expect(results.workspaceChats).toEqual(expect.arrayContaining([expect.objectContaining({reportID: approveOnlyChatReportID})])); + }); + }); + it.each([ { description: 'Vietnamese diacritics', diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 3838dd690f1e..5d2e17f38660 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -17,6 +17,8 @@ import { canMemberManageMemberWithRole, canMemberRead, canMemberWrite, + canCreateExpensesOnPolicy, + canRoleCreateExpenses, canSendInvoice, canSendInvoiceFromWorkspace, canShowReimbursableToggleForPolicy, @@ -1472,21 +1474,21 @@ describe('PolicyUtils', () => { expect(hasReadOnlyAllReportsAccess(policy, memberLogin)).toBe(false); }); - it('allows auditors to read every policy feature but write only rooms', () => { + it('allows auditors to read every policy feature but write only rooms and expense creation', () => { const policy = buildPolicy(CONST.POLICY.ROLE.AUDITOR); for (const feature of Object.values(CONST.POLICY.POLICY_FEATURE)) { expect(canMemberRead(policy, memberLogin, feature)).toBe(true); - expect(canMemberWrite(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.ROOMS); + expect(canMemberWrite(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.ROOMS || feature === CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES); } }); - it('allows guests to read only the workspace overview', () => { + it('allows guests to read only the workspace overview but create expenses', () => { const policy = buildPolicy(CONST.POLICY.ROLE.GUEST); for (const feature of Object.values(CONST.POLICY.POLICY_FEATURE)) { - expect(canMemberRead(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.OVERVIEW); - expect(canMemberWrite(policy, memberLogin, feature)).toBe(false); + expect(canMemberRead(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.OVERVIEW || feature === CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES); + expect(canMemberWrite(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES); } }); @@ -1509,6 +1511,53 @@ describe('PolicyUtils', () => { expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.PAYMENTS_ADMIN)).toBe(false); }); + it('allows People Admins to assign the approve-only role', () => { + const policy = buildPolicy(CONST.POLICY.ROLE.PEOPLE_ADMIN); + + expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.APPROVE_ONLY)).toBe(true); + }); + + it('gives approve-only members read-only access like members', () => { + const policy = buildPolicy(CONST.POLICY.ROLE.APPROVE_ONLY); + + expect(canMemberRead(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.OVERVIEW)).toBe(true); + expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.OVERVIEW)).toBe(false); + }); + + it('allows creating expenses only for roles with create-expenses write access', () => { + expect(canRoleCreateExpenses(CONST.POLICY.ROLE.USER)).toBe(true); + expect(canRoleCreateExpenses(CONST.POLICY.ROLE.ADMIN)).toBe(true); + expect(canRoleCreateExpenses(CONST.POLICY.ROLE.AUDITOR)).toBe(true); + expect(canRoleCreateExpenses(CONST.POLICY.ROLE.APPROVE_ONLY)).toBe(false); + }); + + it('blocks expense creation when the policy role cannot be resolved', () => { + expect(canCreateExpensesOnPolicy(createMock({role: undefined}))).toBe(false); + expect(canCreateExpensesOnPolicy(undefined)).toBe(false); + }); + + it('resolves the member role from employeeList when the policy role is missing', () => { + const legacyPolicy = createMock({ + employeeList: { + [memberLogin]: {role: CONST.POLICY.ROLE.USER}, + }, + }); + + expect(canCreateExpensesOnPolicy(legacyPolicy)).toBe(false); + expect(canCreateExpensesOnPolicy(legacyPolicy, memberLogin)).toBe(true); + }); + + it('resolves the target member role from employeeList even when policy.role belongs to another user', () => { + const policyWithDifferentViewerRole = createMock({ + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [memberLogin]: {role: CONST.POLICY.ROLE.APPROVE_ONLY}, + }, + }); + + expect(canCreateExpensesOnPolicy(policyWithDifferentViewerRole, memberLogin)).toBe(false); + }); + it('allows Guest assignment only on Control workspaces', () => { const controlPolicy = buildPolicy(CONST.POLICY.ROLE.ADMIN); const collectPolicy = {...controlPolicy, type: CONST.POLICY.TYPE.TEAM}; @@ -1927,6 +1976,22 @@ describe('PolicyUtils', () => { }); }); + describe('getGroupPoliciesWhereReportCanBeCreated', () => { + it('excludes workspaces where the current user is approve-only', () => { + const approveOnlyPolicy = createMock({...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), role: CONST.POLICY.ROLE.APPROVE_ONLY, pendingAction: null}); + const memberPolicy = createMock({...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), name: 'member', role: CONST.POLICY.ROLE.USER, pendingAction: null}); + const policies = createCollection( + (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, + (index) => (index === 0 ? approveOnlyPolicy : memberPolicy), + 2, + ); + + const result = getGroupPoliciesWhereReportCanBeCreated(policies, employeeEmail); + expect(result).toHaveLength(1); + expect(result.at(0)?.id).toBe(memberPolicy.id); + }); + }); + describe('customUnits', () => { const perDiemCustomUnit = { name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, @@ -3269,6 +3334,24 @@ describe('PolicyUtils', () => { const result = isWorkspaceEligibleForReportChange(currentUserLogin, newPolicy); expect(result).toBe(false); }); + + it('returns false if the submitter is approve-only on the new policy', async () => { + const currentUserLogin = employeeEmail; + + const newPolicy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + role: CONST.POLICY.ROLE.APPROVE_ONLY, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + employeeList: { + [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.APPROVE_ONLY}, + }, + pendingAction: null, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${newPolicy.id}`, newPolicy); + + const result = isWorkspaceEligibleForReportChange(currentUserLogin, newPolicy); + expect(result).toBe(false); + }); }); describe('tag', () => { diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index b0cb3e759876..e0887184f11b 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -851,6 +851,7 @@ describe('getSecondaryAction', () => { enabled: true, }, type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.USER, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); const TRANSACTION_ID = 'TRANSACTION_ID'; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index e7d0bcc2f51f..9e015a831925 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -16663,6 +16663,24 @@ describe('ReportUtils', () => { // Then account 3 (passed explicitly) cannot request money on it expect(canRequestMoney(iouReport, undefined, [1, 2], undefined, 3)).toBe(false); }); + + it('should return false for a workspace chat when the current user is approve-only on that workspace', () => { + const policyID = 'approveOnlyPolicyID'; + const report: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID, + isOwnPolicyExpenseChat: true, + }; + const approveOnlyPolicy = createMock({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + id: policyID, + role: CONST.POLICY.ROLE.APPROVE_ONLY, + }); + + expect(canRequestMoney(report, approveOnlyPolicy, [1], undefined)).toBe(false); + }); }); // Note: shouldShowFlagComment() calls isArchivedNonExpenseReport() which has it's own unit tests, so whether diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index 67867e3a9dc3..66202a38ca7e 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -25,7 +25,9 @@ export default function createRandomPolicy(index: number, type?: ValueOf role !== CONST.POLICY.ROLE.APPROVE_ONLY && role !== CONST.POLICY.ROLE.OWNER)), owner: randEmail(), ownerAccountID: index, avatarURL: randAvatar(),