diff --git a/assets/images/user-eye.svg b/assets/images/user-eye.svg new file mode 100644 index 000000000000..2265b4892ded --- /dev/null +++ b/assets/images/user-eye.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/src/CONST.ts b/src/CONST.ts index cd4d2b24e97a..fbdfbf40ad19 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2208,6 +2208,7 @@ const CONST = { REMOVE: 'remove', MAKE_MEMBER: 'makeMember', MAKE_ADMIN: 'makeAdmin', + MAKE_AUDITOR: 'makeAuditor', }, BULK_ACTION_TYPES: { DELETE: 'delete', diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 698dc33b4a03..0ef99e98da32 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -186,6 +186,7 @@ import Unlock from '@assets/images/unlock.svg'; import UploadAlt from '@assets/images/upload-alt.svg'; import Upload from '@assets/images/upload.svg'; import UserCheck from '@assets/images/user-check.svg'; +import UserEye from '@assets/images/user-eye.svg'; import UserPlus from '@assets/images/user-plus.svg'; import User from '@assets/images/user.svg'; import Users from '@assets/images/users.svg'; @@ -392,6 +393,7 @@ export { Filters, CalendarSolid, Filter, + UserEye, CaretUpDown, UserPlus, Feed, diff --git a/src/languages/en.ts b/src/languages/en.ts index 5a1e242519c2..47f70ddeb5e4 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -352,6 +352,7 @@ export default { default: 'Default', update: 'Update', member: 'Member', + auditor: 'Auditor', role: 'Role', currency: 'Currency', rate: 'Rate', @@ -2225,6 +2226,21 @@ export default { reuseExistingConnection: 'Reuse existing connection', existingConnections: 'Existing connections', lastSyncDate: (connectionName: string, formattedDate: string) => `${connectionName} - Last synced ${formattedDate}`, + memberAlternateText: 'Members can submit and approve reports.', + adminAlternateText: 'Admins have full edit access to all reports and workspace settings.', + auditorAlternateText: 'Auditors can view and comment on reports.', + roleName: (role?: string): string => { + switch (role) { + case CONST.POLICY.ROLE.ADMIN: + return 'Admin'; + case CONST.POLICY.ROLE.AUDITOR: + return 'Auditor'; + case CONST.POLICY.ROLE.USER: + return 'Member'; + default: + return 'Member'; + } + }, }, qbo: { importDescription: 'Choose which coding configurations to import from QuickBooks Online to Expensify.', @@ -3214,6 +3230,7 @@ export default { transferOwner: 'Transfer owner', makeMember: 'Make member', makeAdmin: 'Make admin', + makeAuditor: 'Make auditor', selectAll: 'Select all', error: { genericAdd: 'There was a problem adding this workspace member.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 0ce9c70a9b56..985f66a37a68 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -342,6 +342,7 @@ export default { default: 'Predeterminado', update: 'Actualizar', member: 'Miembro', + auditor: 'Auditor', role: 'Role', currency: 'Divisa', rate: 'Tarifa', @@ -2256,6 +2257,21 @@ export default { existingConnections: 'Conexiones existentes', lastSyncDate: (connectionName: string, formattedDate: string) => `${connectionName} - Última sincronización ${formattedDate}`, topLevel: 'Nivel superior', + memberAlternateText: 'Los miembros pueden presentar y aprobar informes.', + adminAlternateText: 'Los administradores tienen acceso total para editar todos los informes y la configuración del área de trabajo.', + auditorAlternateText: 'Los auditores pueden ver y comentar los informes.', + roleName: (role?: string): string => { + switch (role) { + case CONST.POLICY.ROLE.ADMIN: + return 'Administrador'; + case CONST.POLICY.ROLE.AUDITOR: + return 'Auditor'; + case CONST.POLICY.ROLE.USER: + return 'Miembro'; + default: + return 'Miembro'; + } + }, }, qbo: { importDescription: 'Elige que configuraciónes de codificación son importadas desde QuickBooks Online a Expensify.', @@ -3264,6 +3280,7 @@ export default { transferOwner: 'Transferir la propiedad', makeMember: 'Hacer miembro', makeAdmin: 'Hacer administrador', + makeAuditor: 'Hacer auditor', selectAll: 'Seleccionar todo', error: { genericAdd: 'Ha ocurrido un problema al añadir el miembro al espacio de trabajo.', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index f62935fc6721..b98188567178 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -201,6 +201,12 @@ const isPolicyAdmin = (policy: OnyxInputOrEntry, currentUserLogin?: stri */ const isPolicyUser = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean => getPolicyRole(policy, currentUserLogin) === CONST.POLICY.ROLE.USER; +/** + * Checks if the current user is an auditor of the policy + */ +const isPolicyAuditor = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean => + (policy?.role ?? (currentUserLogin && policy?.employeeList?.[currentUserLogin]?.role)) === CONST.POLICY.ROLE.AUDITOR; + /** * Checks if the policy is a free group policy. */ @@ -1035,6 +1041,7 @@ export { isPendingDeletePolicy, isPolicyAdmin, isPolicyUser, + isPolicyAuditor, isPolicyEmployee, isPolicyFeatureEnabled, isPolicyOwner, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7001dedea2f1..1cc1878c0c37 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1415,12 +1415,28 @@ function isJoinRequestInAdminRoom(report: OnyxEntry): boolean { return ReportActionsUtils.isActionableJoinRequestPending(report.reportID); } +/** + * Checks if the user has auditor permission in the provided report + */ +function isAuditor(report: OnyxEntry): boolean { + if (report?.policyID) { + const policy = getPolicy(report.policyID); + return PolicyUtils.isPolicyAuditor(policy); + } + + if (Array.isArray(report?.permissions) && report?.permissions.length > 0) { + return report?.permissions?.includes(CONST.REPORT.PERMISSIONS.AUDITOR); + } + + return false; +} + /** * Checks if the user can write in the provided report */ function canWriteInReport(report: OnyxEntry): boolean { - if (Array.isArray(report?.permissions) && report?.permissions.length > 0) { - return report?.permissions?.includes(CONST.REPORT.PERMISSIONS.WRITE) || (report?.permissions?.includes(CONST.REPORT.PERMISSIONS.AUDITOR) && isExpenseReport(report)); + if (Array.isArray(report?.permissions) && report?.permissions.length > 0 && !report?.permissions?.includes(CONST.REPORT.PERMISSIONS.AUDITOR)) { + return report?.permissions?.includes(CONST.REPORT.PERMISSIONS.WRITE); } return true; @@ -1430,6 +1446,10 @@ function canWriteInReport(report: OnyxEntry): boolean { * Checks if the current user is allowed to comment on the given report. */ function isAllowedToComment(report: OnyxEntry): boolean { + if (isAuditor(report)) { + return true; + } + if (!canWriteInReport(report)) { return false; } @@ -7075,7 +7095,8 @@ function canEditReportDescription(report: OnyxEntry, policy: OnyxEntry

; }; const allPolicies: OnyxCollection = {}; @@ -407,7 +408,7 @@ function removeMembers(accountIDs: number[], policyID: string) { API.write(WRITE_COMMANDS.DELETE_MEMBERS_FROM_WORKSPACE, params, {optimisticData, successData, failureData}); } -function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newRole: typeof CONST.POLICY.ROLE.ADMIN | typeof CONST.POLICY.ROLE.USER) { +function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newRole: ValueOf) { const previousEmployeeList = {...allPolicies?.[policyID]?.employeeList}; const memberRoles: WorkspaceMembersRoleData[] = accountIDs.reduce((result: WorkspaceMembersRoleData[], accountID: number) => { if (!allPersonalDetails?.[accountID]?.login) { diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index bcfdaf696946..723bdbe1b137 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -1126,7 +1126,7 @@ function getTaskOwnerAccountID(taskReport: OnyxEntry): number } /** - * Check if you're allowed to modify the task - anyone that has write access to the report can modify the task + * Check if you're allowed to modify the task - anyone that has write access to the report can modify the task, except auditor */ function canModifyTask(taskReport: OnyxEntry, sessionAccountID: number): boolean { if (ReportUtils.isCanceledTaskReport(taskReport)) { @@ -1143,7 +1143,7 @@ function canModifyTask(taskReport: OnyxEntry, sessionAccountID return true; } - if (!ReportUtils.canWriteInReport(taskReport)) { + if (!ReportUtils.canWriteInReport(taskReport) || ReportUtils.isAuditor(taskReport)) { return false; } diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index e686ec0bf513..9de547509762 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -6,6 +6,7 @@ import type {TextInput} from 'react-native'; import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Badge from '@components/Badge'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; @@ -336,9 +337,12 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson const isOwner = policy?.owner === details.login; const isAdmin = policyEmployee.role === CONST.POLICY.ROLE.ADMIN; + const isAuditor = policyEmployee.role === CONST.POLICY.ROLE.AUDITOR; let roleBadge = null; if (isOwner || isAdmin) { roleBadge = ; + } else if (isAuditor) { + roleBadge = ; } result.push({ @@ -450,7 +454,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson return {header}; }; - const changeUserRole = (role: typeof CONST.POLICY.ROLE.ADMIN | typeof CONST.POLICY.ROLE.USER) => { + const changeUserRole = (role: ValueOf) => { if (!isEmptyObject(errors)) { return; } @@ -482,23 +486,43 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson const email = personalDetails?.[accountID]?.login ?? ''; return policy?.employeeList?.[email]?.role; }); - if (selectedEmployeesRoles.find((role) => role === CONST.POLICY.ROLE.ADMIN)) { - options.push({ - text: translate('workspace.people.makeMember'), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_MEMBER, - icon: Expensicons.User, - onSelected: () => changeUserRole(CONST.POLICY.ROLE.USER), - }); + + const memberOption = { + text: translate('workspace.people.makeMember'), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_MEMBER, + icon: Expensicons.User, + onSelected: () => changeUserRole(CONST.POLICY.ROLE.USER), + }; + const adminOption = { + text: translate('workspace.people.makeAdmin'), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_ADMIN, + icon: Expensicons.MakeAdmin, + onSelected: () => changeUserRole(CONST.POLICY.ROLE.ADMIN), + }; + + const auditorOption = { + text: translate('workspace.people.makeAuditor'), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_AUDITOR, + icon: Expensicons.UserEye, + onSelected: () => changeUserRole(CONST.POLICY.ROLE.AUDITOR), + }; + + const hasAtLeastOneNonAuditorRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.AUDITOR); + const hasAtLeastOneNonMemberRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.USER); + const hasAtLeastOneNonAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.ADMIN); + + if (hasAtLeastOneNonMemberRole) { + options.push(memberOption); } - if (selectedEmployeesRoles.find((role) => role === CONST.POLICY.ROLE.USER)) { - options.push({ - text: translate('workspace.people.makeAdmin'), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_ADMIN, - icon: Expensicons.MakeAdmin, - onSelected: () => changeUserRole(CONST.POLICY.ROLE.ADMIN), - }); + if (hasAtLeastOneNonAdminRole) { + options.push(adminOption); } + + if (hasAtLeastOneNonAuditorRole) { + options.push(auditorOption); + } + return options; }; diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 892d38aac48d..a262b747b2bf 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -101,15 +101,24 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM { value: CONST.POLICY.ROLE.ADMIN, text: translate('common.admin'), + alternateText: translate('workspace.common.adminAlternateText'), isSelected: member?.role === CONST.POLICY.ROLE.ADMIN, keyForList: CONST.POLICY.ROLE.ADMIN, }, { value: CONST.POLICY.ROLE.USER, text: translate('common.member'), - isSelected: member?.role !== CONST.POLICY.ROLE.ADMIN, + alternateText: translate('workspace.common.memberAlternateText'), + isSelected: member?.role === CONST.POLICY.ROLE.USER, keyForList: CONST.POLICY.ROLE.USER, }, + { + value: CONST.POLICY.ROLE.AUDITOR, + text: translate('common.auditor'), + alternateText: translate('workspace.common.auditorAlternateText'), + isSelected: member?.role === CONST.POLICY.ROLE.AUDITOR, + keyForList: CONST.POLICY.ROLE.AUDITOR, + }, ], [member?.role, translate], ); @@ -247,7 +256,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM ; text: string; + alternateText: string; isSelected: boolean; - keyForList: typeof CONST.POLICY.ROLE.ADMIN | typeof CONST.POLICY.ROLE.USER; + keyForList: ValueOf; }; type WorkspaceMemberDetailsPageProps = { @@ -56,6 +58,7 @@ function WorkspaceMemberDetailsRoleSelectionModal({isVisible, items, onRoleChang sections={[{data: items}]} ListItem={RadioListItem} onSelectRow={onRoleChange} + isAlternateTextMultilineSupported shouldSingleExecuteRowSelect initiallyFocusedOptionKey={items.find((item) => item.isSelected)?.keyForList} />