Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent self approvals #55740

Merged
merged 34 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c9637e2
add utility functions related to self approval
lakchote Jan 24, 2025
220b869
add translation keys for prevent self approval
lakchote Jan 24, 2025
ae7274c
WIP prevent self approval
lakchote Jan 24, 2025
57c1026
working logic
lakchote Jan 27, 2025
e4ac957
remove logic related to default workspace owner
lakchote Jan 27, 2025
545adfb
exclude policy owner from logic
lakchote Jan 27, 2025
6cb1387
fix prettier
lakchote Jan 27, 2025
3dcd36d
fix spanish translation
lakchote Jan 27, 2025
084a2aa
fix lint
lakchote Jan 27, 2025
524efc3
fix lint
lakchote Jan 27, 2025
0a500f4
fix style
lakchote Jan 27, 2025
56f3cbd
Revert "remove logic related to default workspace owner"
lakchote Jan 27, 2025
d429bac
update logic for self approvals check
lakchote Jan 27, 2025
13b7176
update translation for self approvals disabled
lakchote Jan 27, 2025
6d3b528
add logic for self approvals disabled
lakchote Jan 27, 2025
6b6f2e1
remove ternary
lakchote Jan 27, 2025
d1d4b07
fix translation
lakchote Jan 28, 2025
8663b65
Merge branch 'main' into lucien/fix-prevent-self-approvals
lakchote Jan 28, 2025
6d8c2fe
fix style
lakchote Jan 28, 2025
659d332
make comment more meaningful
lakchote Jan 29, 2025
0c2e7e0
disable self approvals automatically if only one user in the workspace
lakchote Jan 29, 2025
29a5acc
fix lint
lakchote Jan 29, 2025
fc00b9e
remove auto prevent self approvals
lakchote Feb 3, 2025
f9a087c
handle prevent self approvals
lakchote Feb 3, 2025
78506de
fix style
lakchote Feb 3, 2025
a1d930d
remove unnecessary `useEffect`
lakchote Feb 3, 2025
632516a
use named imports
lakchote Feb 3, 2025
edc72fb
fix style
lakchote Feb 3, 2025
237bb4d
fix lint
lakchote Feb 3, 2025
87d51ca
fix lint
lakchote Feb 3, 2025
418596d
Merge branch 'main' into lucien/fix-prevent-self-approvals
lakchote Feb 5, 2025
82b13c7
Merge branch 'main' into lucien/fix-prevent-self-approvals
lakchote Feb 6, 2025
813a886
improve comment
lakchote Feb 6, 2025
d318ce3
lint fix
lakchote Feb 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4545,6 +4545,11 @@ const translations = {
unlockFeatureGoToSubtitle: 'Go to',
unlockFeatureEnableWorkflowsSubtitle: ({featureName}: FeatureNameParams) => `and enable workflows, then add ${featureName} to unlock this feature.`,
enableFeatureSubtitle: ({featureName}: FeatureNameParams) => `and enable ${featureName} to unlock this feature.`,
preventSelfApprovalsModalText: ({managerEmail}: {managerEmail: string}) =>
`Any members currently approving their own expenses will be removed and replaced with the default approver for this workspace (${managerEmail}).`,
preventSelfApprovalsConfirmButton: 'Prevent self-approvals',
preventSelfApprovalsModalTitle: 'Prevent self-approvals?',
preventSelfApprovalsDisabledSubtitle: "Self approvals can't be enabled until this workspace has at least two members.",
},
categoryRules: {
title: 'Category rules',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4611,6 +4611,11 @@ const translations = {
unlockFeatureGoToSubtitle: 'Ir a',
unlockFeatureEnableWorkflowsSubtitle: ({featureName}: FeatureNameParams) => `y habilita flujos de trabajo, luego agrega ${featureName} para desbloquear esta función.`,
enableFeatureSubtitle: ({featureName}: FeatureNameParams) => `y habilita ${featureName} para desbloquear esta función.`,
preventSelfApprovalsModalText: ({managerEmail}: {managerEmail: string}) =>
`Todos los miembros que actualmente estén aprobando sus propios gastos serán eliminados y reemplazados con el aprobador predeterminado de este espacio de trabajo (${managerEmail}).`,
preventSelfApprovalsConfirmButton: 'Evitar autoaprobaciones',
preventSelfApprovalsModalTitle: '¿Evitar autoaprobaciones?',
preventSelfApprovalsDisabledSubtitle: 'Las aprobaciones propias no pueden habilitarse hasta que este espacio de trabajo tenga al menos dos miembros.',
},
categoryRules: {
title: 'Reglas de categoría',
Expand Down
31 changes: 31 additions & 0 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1264,11 +1264,42 @@ const getDescriptionForPolicyDomainCard = (domainName: string): string => {
return domainName;
};

/**
* Returns an array of user emails who are currently self-approving:
* i.e. user.submitsTo === their own email.
*/
function getAllSelfApprovers(policy: OnyxEntry<Policy>): string[] {
const defaultApprover = policy?.approver ?? policy?.owner;
if (!policy?.employeeList || !defaultApprover) {
return [];
}
return Object.keys(policy.employeeList).filter((email) => {
const employee = policy?.employeeList?.[email] ?? {};
return employee?.submitsTo === email && employee?.email !== defaultApprover;
});
}

/**
* Checks if the policy has a default approver that is self-approving and the workspace has only one user.
* If so, we cannot enable the "Prevent Self Approvals" feature.
*/
function canEnablePreventSelfApprovals(policy: OnyxEntry<Policy>): boolean {
if (!policy?.employeeList || !policy.approver) {
return false;
}

const employeeEmails = Object.keys(policy.employeeList);

return employeeEmails.length > 1;
}

export {
canEditTaxRate,
canEnablePreventSelfApprovals,
extractPolicyIDFromPath,
escapeTagName,
getActivePolicies,
getAllSelfApprovers,
getAdminEmployees,
getCleanedTagName,
getConnectedIntegration,
Expand Down
213 changes: 159 additions & 54 deletions src/pages/workspace/rules/ExpenseReportRulesSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React from 'react';
import React, {useMemo, useState} from 'react';
import {useOnyx} from 'react-native-onyx';
import ConfirmModal from '@components/ConfirmModal';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import Section from '@components/Section';
Expand All @@ -7,13 +9,24 @@ import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
import useThemeStyles from '@hooks/useThemeStyles';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import {convertToDisplayString} from '@libs/CurrencyUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import {canEnablePreventSelfApprovals, getAllSelfApprovers, getWorkflowApprovalsUnavailable} from '@libs/PolicyUtils';
import {convertPolicyEmployeesToApprovalWorkflows} from '@libs/WorkflowUtils';
import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow';
import * as PolicyActions from '@userActions/Policy/Policy';
import {
enableAutoApprovalOptions,
enablePolicyAutoReimbursementLimit,
enablePolicyDefaultReportTitle,
setPolicyPreventMemberCreatedTitle,
setPolicyPreventSelfApproval,
} from '@userActions/Policy/Policy';
import {updateApprovalWorkflow} from '@userActions/Workflow';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow';
import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow';

type ExpenseReportRulesSectionProps = {
policyID: string;
Expand All @@ -23,12 +36,48 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
const policy = usePolicy(policyID);

const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const customReportNamesUnavailable = !policy?.areReportFieldsEnabled;
// Auto-approvals and self-approvals are unavailable due to the policy workflows settings
const workflowApprovalsUnavailable = PolicyUtils.getWorkflowApprovalsUnavailable(policy);
const workflowApprovalsUnavailable = getWorkflowApprovalsUnavailable(policy);
const autoPayApprovedReportsUnavailable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO;

const [isPreventSelfApprovalsModalVisible, setIsPreventSelfApprovalsModalVisible] = useState(false);
const isPreventSelfApprovalsDisabled = !canEnablePreventSelfApprovals(policy) && !policy?.preventSelfApproval;
const selfApproversEmails = getAllSelfApprovers(policy);

function handleTogglePreventSelfApprovals(isEnabled: boolean) {
if (!isEnabled) {
setPolicyPreventSelfApproval(policyID, false);
return;
}

if (selfApproversEmails.length === 0) {
setPolicyPreventSelfApproval(policyID, true);
} else {
setIsPreventSelfApprovalsModalVisible(true);
}
}

const {currentApprovalWorkflows, defaultWorkflowMembers, usedApproverEmails} = useMemo(() => {
if (!policy || !personalDetails) {
return {};
}

const defaultApprover = policy?.approver ?? policy.owner;
const result = convertPolicyEmployeesToApprovalWorkflows({
employees: policy.employeeList ?? {},
defaultApprover,
personalDetails,
});

return {
defaultWorkflowMembers: result.availableMembers,
usedApproverEmails: result.usedApproverEmails,
currentApprovalWorkflows: result.approvalWorkflows.filter((workflow) => !workflow.isDefault),
};
}, [personalDetails, policy]);

const renderFallbackSubtitle = ({featureName, variant = 'unlock'}: {featureName: string; variant?: 'unlock' | 'enable'}) => {
return (
<Text style={[styles.flexRow, styles.alignItemsCenter, styles.w100, styles.mt2]}>
Expand Down Expand Up @@ -60,7 +109,7 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) {
disabled: customReportNamesUnavailable,
showLockIcon: customReportNamesUnavailable,
pendingAction: policy?.pendingFields?.shouldShowCustomReportTitleOption,
onToggle: (isEnabled: boolean) => PolicyActions.enablePolicyDefaultReportTitle(policyID, isEnabled),
onToggle: (isEnabled: boolean) => enablePolicyDefaultReportTitle(policyID, isEnabled),
subMenuItems: [
<OfflineWithFeedback
pendingAction={!policy?.pendingFields?.shouldShowCustomReportTitleOption && reportTitlePendingFields.defaultValue ? reportTitlePendingFields.defaultValue : null}
Expand All @@ -82,21 +131,27 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) {
wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt6]}
titleStyle={styles.pv2}
isActive={!policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE].deletable}
onToggle={(isEnabled) => PolicyActions.setPolicyPreventMemberCreatedTitle(policyID, isEnabled)}
onToggle={(isEnabled) => setPolicyPreventMemberCreatedTitle(policyID, isEnabled)}
/>,
],
},
{
title: translate('workspace.rules.expenseReportRules.preventSelfApprovalsTitle'),
subtitle: workflowApprovalsUnavailable
? renderFallbackSubtitle({featureName: translate('common.approvals').toLowerCase()})
: translate('workspace.rules.expenseReportRules.preventSelfApprovalsSubtitle'),
subtitle: (() => {
if (workflowApprovalsUnavailable) {
return renderFallbackSubtitle({featureName: translate('common.approvals').toLowerCase()});
}
if (isPreventSelfApprovalsDisabled) {
return translate('workspace.rules.expenseReportRules.preventSelfApprovalsDisabledSubtitle');
}
return translate('workspace.rules.expenseReportRules.preventSelfApprovalsSubtitle');
})(),
switchAccessibilityLabel: translate('workspace.rules.expenseReportRules.preventSelfApprovalsTitle'),
isActive: policy?.preventSelfApproval && !workflowApprovalsUnavailable,
disabled: workflowApprovalsUnavailable,
showLockIcon: workflowApprovalsUnavailable,
disabled: workflowApprovalsUnavailable || isPreventSelfApprovalsDisabled,
showLockIcon: workflowApprovalsUnavailable || isPreventSelfApprovalsDisabled,
pendingAction: policy?.pendingFields?.preventSelfApproval,
onToggle: (isEnabled: boolean) => PolicyActions.setPolicyPreventSelfApproval(policyID, isEnabled),
onToggle: (isEnabled: boolean) => handleTogglePreventSelfApprovals(isEnabled),
},
{
title: translate('workspace.rules.expenseReportRules.autoApproveCompliantReportsTitle'),
Expand All @@ -109,7 +164,7 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) {
showLockIcon: workflowApprovalsUnavailable,
pendingAction: policy?.pendingFields?.shouldShowAutoApprovalOptions,
onToggle: (isEnabled: boolean) => {
PolicyActions.enableAutoApprovalOptions(policyID, isEnabled);
enableAutoApprovalOptions(policyID, isEnabled);
},
subMenuItems: [
<OfflineWithFeedback
Expand All @@ -118,10 +173,7 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) {
>
<MenuItemWithTopDescription
description={translate('workspace.rules.expenseReportRules.autoApproveReportsUnderTitle')}
title={CurrencyUtils.convertToDisplayString(
policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS,
policy?.outputCurrency ?? CONST.CURRENCY.USD,
)}
title={convertToDisplayString(policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS, policy?.outputCurrency ?? CONST.CURRENCY.USD)}
shouldShowRightIcon
style={[styles.sectionMenuItemTopDescription, styles.mt6, styles.mbn3]}
onPress={() => Navigation.navigate(ROUTES.RULES_AUTO_APPROVE_REPORTS_UNDER.getRoute(policyID))}
Expand Down Expand Up @@ -150,7 +202,7 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) {
: translate('workspace.rules.expenseReportRules.autoPayApprovedReportsSubtitle'),
switchAccessibilityLabel: translate('workspace.rules.expenseReportRules.autoPayApprovedReportsTitle'),
onToggle: (isEnabled: boolean) => {
PolicyActions.enablePolicyAutoReimbursementLimit(policyID, isEnabled);
enablePolicyAutoReimbursementLimit(policyID, isEnabled);
},
disabled: autoPayApprovedReportsUnavailable,
showLockIcon: autoPayApprovedReportsUnavailable,
Expand All @@ -167,10 +219,7 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) {
>
<MenuItemWithTopDescription
description={translate('workspace.rules.expenseReportRules.autoPayReportsUnderTitle')}
title={CurrencyUtils.convertToDisplayString(
policy?.autoReimbursement?.limit ?? CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS,
policy?.outputCurrency ?? CONST.CURRENCY.USD,
)}
title={convertToDisplayString(policy?.autoReimbursement?.limit ?? CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS, policy?.outputCurrency ?? CONST.CURRENCY.USD)}
shouldShowRightIcon
style={[styles.sectionMenuItemTopDescription, styles.mt6, styles.mbn3]}
onPress={() => Navigation.navigate(ROUTES.RULES_AUTO_PAY_REPORTS_UNDER.getRoute(policyID))}
Expand All @@ -181,36 +230,92 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) {
];

return (
<Section
isCentralPane
title={translate('workspace.rules.expenseReportRules.title')}
subtitle={translate('workspace.rules.expenseReportRules.subtitle')}
titleStyles={styles.accountSettingsSectionTitle}
subtitleMuted
>
{optionItems.map(({title, subtitle, isActive, subMenuItems, showLockIcon, disabled, onToggle, pendingAction}, index) => {
const showBorderBottom = index !== optionItems.length - 1;

return (
<ToggleSettingOptionRow
key={title}
title={title}
subtitle={subtitle}
switchAccessibilityLabel={title}
wrapperStyle={[styles.pv6, showBorderBottom && styles.borderBottom]}
shouldPlaceSubtitleBelowSwitch
titleStyle={styles.pv2}
subtitleStyle={styles.pt1}
isActive={!!isActive}
showLockIcon={showLockIcon}
disabled={disabled}
subMenuItems={subMenuItems}
onToggle={onToggle}
pendingAction={pendingAction}
/>
);
})}
</Section>
<>
<Section
isCentralPane
title={translate('workspace.rules.expenseReportRules.title')}
subtitle={translate('workspace.rules.expenseReportRules.subtitle')}
titleStyles={styles.accountSettingsSectionTitle}
subtitleMuted
>
{optionItems.map(({title, subtitle, isActive, subMenuItems, showLockIcon, disabled, onToggle, pendingAction}, index) => {
const showBorderBottom = index !== optionItems.length - 1;

return (
<ToggleSettingOptionRow
key={title}
title={title}
subtitle={subtitle}
switchAccessibilityLabel={title}
wrapperStyle={[styles.pv6, showBorderBottom && styles.borderBottom]}
shouldPlaceSubtitleBelowSwitch
titleStyle={styles.pv2}
subtitleStyle={styles.pt1}
isActive={!!isActive}
showLockIcon={showLockIcon}
disabled={disabled}
subMenuItems={subMenuItems}
onToggle={onToggle}
pendingAction={pendingAction}
/>
);
})}
</Section>
<ConfirmModal
isVisible={isPreventSelfApprovalsModalVisible}
title={translate('workspace.rules.expenseReportRules.preventSelfApprovalsTitle')}
prompt={translate('workspace.rules.expenseReportRules.preventSelfApprovalsModalText', {
managerEmail: policy?.approver ?? '',
})}
confirmText={translate('workspace.rules.expenseReportRules.preventSelfApprovalsConfirmButton')}
cancelText={translate('common.cancel')}
onConfirm={() => {
setPolicyPreventSelfApproval(policyID, true);

const defaultApprover = policy?.approver ?? policy?.owner;
if (!defaultApprover) {
setIsPreventSelfApprovalsModalVisible(false);
return;
}

currentApprovalWorkflows?.forEach((workflow: ApprovalWorkflow) => {
const oldApprovers = workflow.approvers ?? [];
const approversToRemove = oldApprovers.filter((approver: Approver) => selfApproversEmails.includes(approver?.email));
const newApprovers = oldApprovers.filter((approver: Approver) => !selfApproversEmails.includes(approver?.email));

if (!newApprovers.some((a) => a.email === defaultApprover)) {
newApprovers.unshift({
email: defaultApprover,
displayName: defaultApprover,
});
}

const oldMembers = workflow.members ?? [];
const newMembers = oldMembers.map((member: Member) => {
const isSelfApprover = selfApproversEmails.includes(member.email);
return isSelfApprover ? {...member, submitsTo: defaultApprover} : member;
});

const newWorkflow = {
...workflow,
approvers: newApprovers,
availableMembers: [...workflow.members, ...defaultWorkflowMembers],
members: newMembers,
usedApproverEmails,
isDefault: workflow.isDefault ?? false,
action: CONST.APPROVAL_WORKFLOW.ACTION.EDIT,
errors: null,
};

const membersToRemove: Member[] = [];

updateApprovalWorkflow(policyID, newWorkflow, membersToRemove, approversToRemove);
});
setIsPreventSelfApprovalsModalVisible(false);
}}
onCancel={() => setIsPreventSelfApprovalsModalVisible(false)}
/>
</>
);
}

Expand Down
Loading