diff --git a/src/languages/de.ts b/src/languages/de.ts index 1e5a4131ca3a..899fb5ef09ab 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7800,6 +7800,17 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc editRuleTitle: 'Regel bearbeiten', deleteRule: 'Regel löschen', deleteRuleConfirmation: 'Sind Sie sicher, dass Sie diese Regel löschen möchten?', + unableToRemoveTitle: 'Entfernen nicht möglich', + unableToRemovePrompt: (rulesRoute: string) => + `Die von RuleBot durchgesetzten Agentenregeln müssen zuerst aus Ihrem Workspace entfernt werden, bevor Sie diese Agentin/diesen Agenten entfernen können.`, + unableToCloseAccountTitle: 'Konto kann nicht geschlossen werden', + unableToCloseAccountPrompt: 'Die von RuleBot durchgesetzten Agentenregeln müssen zuerst aus Ihrem Workspace entfernt werden, bevor Sie dieses Konto schließen können.', + unableToDeleteAgentTitle: 'Agent kann nicht gelöscht werden', + unableToDeleteAgentPrompt: + 'Die von RuleBot durchgesetzten Agentenregeln müssen zuerst aus Ihrem Workspace entfernt werden, bevor Sie diese Agentin/diesen Agenten löschen können.', + unableToChangeRoleTitle: 'Rolle kann nicht geändert werden', + unableToChangeRolePrompt: (rulesRoute: string) => + `Die von RuleBot durchgesetzten Agentenregeln müssen zuerst aus Ihrem Workspace entfernt werden, bevor Sie die Rolle dieser Agentin/dieses Agenten ändern können.`, describeRuleTitle: 'Beschreiben Sie die Regel, der Ihre KI-Agentin/Ihr KI-Agent folgen soll', describeRuleHeadline: 'Beschreibe deine Regel', disclaimer: 'KI-Agenten können Fehler machen.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 75bfadee2ca7..f7137c74c18d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8120,6 +8120,16 @@ const translations = { editRuleTitle: 'Edit rule', deleteRule: 'Delete rule', deleteRuleConfirmation: 'Are you sure you want to delete this rule?', + unableToRemoveTitle: 'Unable to remove', + unableToRemovePrompt: (rulesRoute: string) => + `The agent rules being enforced by RuleBot need to be removed from your workspace first before you can remove this agent.`, + unableToCloseAccountTitle: 'Unable to close account', + unableToCloseAccountPrompt: 'The agent rules being enforced by RuleBot need to be removed from your workspace first before you can close this account.', + unableToDeleteAgentTitle: 'Unable to delete agent', + unableToDeleteAgentPrompt: 'The agent rules being enforced by RuleBot need to be removed from your workspace first before you can delete this agent.', + unableToChangeRoleTitle: 'Unable to change role', + unableToChangeRolePrompt: (rulesRoute: string) => + `The agent rules being enforced by RuleBot need to be removed from your workspace first before you can change this agent's role.`, describeRuleTitle: 'Describe the rule for your AI agent to follow', describeRuleHeadline: 'Describe your rule', describeRuleForConcierge: 'Describe your rule and Concierge will build it', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5e173ef30b78..5927128dfc5a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7714,6 +7714,16 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, editRuleTitle: 'Editar regla', deleteRule: 'Eliminar regla', deleteRuleConfirmation: '¿Seguro que quieres eliminar esta regla?', + unableToRemoveTitle: 'No se puede eliminar', + unableToRemovePrompt: (rulesRoute: string) => + `Las reglas de agente que RuleBot está aplicando deben eliminarse primero de tu espacio de trabajo antes de que puedas eliminar este agente.`, + unableToCloseAccountTitle: 'No se puede cerrar la cuenta', + unableToCloseAccountPrompt: 'Las reglas de agente que RuleBot está aplicando deben eliminarse primero de tu espacio de trabajo antes de que puedas cerrar esta cuenta.', + unableToDeleteAgentTitle: 'No se puede eliminar el agente', + unableToDeleteAgentPrompt: 'Las reglas de agente que RuleBot está aplicando deben eliminarse primero de tu espacio de trabajo antes de que puedas eliminar este agente.', + unableToChangeRoleTitle: 'No se puede cambiar el rol', + unableToChangeRolePrompt: (rulesRoute: string) => + `Las reglas de agente que RuleBot está aplicando deben eliminarse primero de tu espacio de trabajo antes de que puedas cambiar el rol de este agente.`, disclaimer: 'Los agentes de IA pueden cometer errores.', agentCreatedTitle: '¡RuleBot se ha añadido a tu espacio de trabajo!', agentCreatedDescription: (agentsRoute: string) => diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 664cee6663fb..ca203bcd4d6e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7828,6 +7828,17 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e editRuleTitle: 'Modifier la règle', deleteRule: 'Supprimer la règle', deleteRuleConfirmation: 'Voulez-vous vraiment supprimer cette règle ?', + unableToRemoveTitle: 'Suppression impossible', + unableToRemovePrompt: (rulesRoute: string) => + `Les règles d’agent appliquées par RuleBot doivent d’abord être supprimées de votre espace de travail avant que vous puissiez retirer cet agent.`, + unableToCloseAccountTitle: 'Impossible de fermer le compte', + unableToCloseAccountPrompt: 'Les règles d’agent appliquées par RuleBot doivent d’abord être supprimées de votre espace de travail avant que vous puissiez fermer ce compte.', + unableToDeleteAgentTitle: 'Impossible de supprimer l’agent', + unableToDeleteAgentPrompt: + 'Les règles d’agent appliquées par RuleBot doivent d’abord être supprimées de votre espace de travail avant que vous puissiez supprimer cet agent.', + unableToChangeRoleTitle: 'Impossible de changer le rôle', + unableToChangeRolePrompt: (rulesRoute: string) => + `Les règles d’agent appliquées par RuleBot doivent d’abord être supprimées de votre espace de travail avant que vous puissiez changer le rôle de cet agent.`, describeRuleTitle: 'Décrivez la règle que votre agent IA doit suivre', describeRuleHeadline: 'Décrivez votre règle', disclaimer: 'Les agents IA peuvent faire des erreurs.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6dc456a27c0c..50e5bac265eb 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7778,6 +7778,16 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, editRuleTitle: 'Modifica regola', deleteRule: 'Elimina regola', deleteRuleConfirmation: 'Sei sicuro di voler eliminare questa regola?', + unableToRemoveTitle: 'Impossibile rimuovere', + unableToRemovePrompt: (rulesRoute: string) => + `Le regole agente applicate da RuleBot devono essere rimosse dal tuo spazio di lavoro prima di poter rimuovere questo agente.`, + unableToCloseAccountTitle: 'Impossibile chiudere l’account', + unableToCloseAccountPrompt: 'Le regole agente applicate da RuleBot devono essere rimosse dal tuo spazio di lavoro prima di poter chiudere questo account.', + unableToDeleteAgentTitle: 'Impossibile eliminare l’agente', + unableToDeleteAgentPrompt: 'Le regole agente applicate da RuleBot devono essere rimosse dal tuo spazio di lavoro prima di poter eliminare questo agente.', + unableToChangeRoleTitle: 'Impossibile modificare il ruolo', + unableToChangeRolePrompt: (rulesRoute: string) => + `Le regole agente applicate da RuleBot devono essere rimosse dal tuo spazio di lavoro prima di poter modificare il ruolo di questo agente.`, describeRuleTitle: 'Descrivi la regola che il tuo agente AI deve seguire', describeRuleHeadline: 'Descrivi la tua regola', disclaimer: 'Gli agenti IA possono commettere errori.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 11db3c3def10..84ac4cf02462 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7685,6 +7685,16 @@ ${reportName}`, editRuleTitle: 'ルールを編集', deleteRule: 'ルールを削除', deleteRuleConfirmation: 'このルールを削除してもよろしいですか?', + unableToRemoveTitle: '削除できません', + unableToRemovePrompt: (rulesRoute: string) => + `このエージェントを削除する前に、RuleBot が適用しているエージェントルールをワークスペースから削除する必要があります。`, + unableToCloseAccountTitle: 'アカウントを閉鎖できません', + unableToCloseAccountPrompt: 'このアカウントを閉鎖する前に、RuleBot が適用しているエージェントルールをワークスペースから削除する必要があります。', + unableToDeleteAgentTitle: 'エージェントを削除できません', + unableToDeleteAgentPrompt: 'このエージェントを削除する前に、RuleBot が適用しているエージェントルールをワークスペースから削除する必要があります。', + unableToChangeRoleTitle: 'ロールを変更できません', + unableToChangeRolePrompt: (rulesRoute: string) => + `このエージェントのロールを変更する前に、RuleBot が適用しているエージェントルールをワークスペースから削除する必要があります。`, describeRuleTitle: 'AI エージェントに従わせるルールを記述してください', describeRuleHeadline: 'ルールを説明する', disclaimer: 'AI エージェントは間違える場合があります。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c774982e5ffe..ed5698163bc9 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7759,6 +7759,16 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, editRuleTitle: 'Regel bewerken', deleteRule: 'Regel verwijderen', deleteRuleConfirmation: 'Weet je zeker dat je deze regel wilt verwijderen?', + unableToRemoveTitle: 'Verwijderen niet mogelijk', + unableToRemovePrompt: (rulesRoute: string) => + `De agentregels die door RuleBot worden afgedwongen, moeten eerst uit je werkruimte worden verwijderd voordat je deze agent kunt verwijderen.`, + unableToCloseAccountTitle: 'Account kan niet worden gesloten', + unableToCloseAccountPrompt: 'De agentregels die door RuleBot worden afgedwongen, moeten eerst uit je werkruimte worden verwijderd voordat je dit account kunt sluiten.', + unableToDeleteAgentTitle: 'Agent kan niet worden verwijderd', + unableToDeleteAgentPrompt: 'De agentregels die door RuleBot worden afgedwongen, moeten eerst uit je werkruimte worden verwijderd voordat je deze agent kunt verwijderen.', + unableToChangeRoleTitle: 'Rol kan niet worden gewijzigd', + unableToChangeRolePrompt: (rulesRoute: string) => + `De agentregels die door RuleBot worden afgedwongen, moeten eerst uit je werkruimte worden verwijderd voordat je de rol van deze agent kunt wijzigen.`, describeRuleTitle: 'Beschrijf de regel die je AI-agent moet volgen', describeRuleHeadline: 'Beschrijf je regel', disclaimer: 'AI-agents kunnen fouten maken.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ac30cc2c6efc..1cb126b62dfe 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7739,6 +7739,16 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, editRuleTitle: 'Edytuj regułę', deleteRule: 'Usuń regułę', deleteRuleConfirmation: 'Na pewno chcesz usunąć tę regułę?', + unableToRemoveTitle: 'Nie można usunąć', + unableToRemovePrompt: (rulesRoute: string) => + `Zasady agenta egzekwowane przez RuleBot muszą najpierw zostać usunięte z przestrzeni roboczej, zanim będzie można usunąć tego agenta.`, + unableToCloseAccountTitle: 'Nie można zamknąć konta', + unableToCloseAccountPrompt: 'Zasady agenta egzekwowane przez RuleBot muszą najpierw zostać usunięte z przestrzeni roboczej, zanim będzie można zamknąć to konto.', + unableToDeleteAgentTitle: 'Nie można usunąć agenta', + unableToDeleteAgentPrompt: 'Zasady agenta egzekwowane przez RuleBot muszą najpierw zostać usunięte z przestrzeni roboczej, zanim będzie można usunąć tego agenta.', + unableToChangeRoleTitle: 'Nie można zmienić roli', + unableToChangeRolePrompt: (rulesRoute: string) => + `Zasady agenta egzekwowane przez RuleBot muszą najpierw zostać usunięte z przestrzeni roboczej, zanim będzie można zmienić rolę tego agenta.`, describeRuleTitle: 'Opisz zasadę, której ma przestrzegać twój agent AI', describeRuleHeadline: 'Opisz swoją regułę', disclaimer: 'Agenci AI mogą popełniać błędy.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index fe6a996bfccf..2adcc47c8e33 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7751,6 +7751,16 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, editRuleTitle: 'Editar regra', deleteRule: 'Excluir regra', deleteRuleConfirmation: 'Tem certeza de que quer excluir esta regra?', + unableToRemoveTitle: 'Não é possível remover', + unableToRemovePrompt: (rulesRoute: string) => + `As regras de agente aplicadas pelo RuleBot precisam ser removidas do seu espaço de trabalho antes que você possa remover este agente.`, + unableToCloseAccountTitle: 'Não é possível encerrar a conta', + unableToCloseAccountPrompt: 'As regras de agente aplicadas pelo RuleBot precisam ser removidas do seu espaço de trabalho antes que você possa encerrar esta conta.', + unableToDeleteAgentTitle: 'Não é possível excluir o agente', + unableToDeleteAgentPrompt: 'As regras de agente aplicadas pelo RuleBot precisam ser removidas do seu espaço de trabalho antes que você possa excluir este agente.', + unableToChangeRoleTitle: 'Não é possível alterar a função', + unableToChangeRolePrompt: (rulesRoute: string) => + `As regras de agente aplicadas pelo RuleBot precisam ser removidas do seu espaço de trabalho antes que você possa alterar a função deste agente.`, describeRuleTitle: 'Descreva a regra que seu agente de IA deve seguir', describeRuleHeadline: 'Descreva sua regra', disclaimer: 'Os agentes de IA podem cometer erros.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 487d47fe7611..db70220118f8 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7513,6 +7513,14 @@ ${reportName}`, editRuleTitle: '编辑规则', deleteRule: '删除规则', deleteRuleConfirmation: '确定要删除此规则吗?', + unableToRemoveTitle: '无法移除', + unableToRemovePrompt: (rulesRoute: string) => `在移除此代理之前,需要先从工作区中移除由 RuleBot 执行的代理规则。`, + unableToCloseAccountTitle: '无法关闭账户', + unableToCloseAccountPrompt: '在关闭此账户之前,需要先从工作区中移除由 RuleBot 执行的代理规则。', + unableToDeleteAgentTitle: '无法删除代理', + unableToDeleteAgentPrompt: '在删除此代理之前,需要先从工作区中移除由 RuleBot 执行的代理规则。', + unableToChangeRoleTitle: '无法更改角色', + unableToChangeRolePrompt: (rulesRoute: string) => `在更改此代理的角色之前,需要先从工作区中移除由 RuleBot 执行的代理规则。`, describeRuleTitle: '描述你的 AI 代理需要遵循的规则', describeRuleHeadline: '描述您的规则', disclaimer: 'AI 智能体可能会犯错。', diff --git a/src/libs/AgentRulesUtils.ts b/src/libs/AgentRulesUtils.ts index a73dfad28911..93ab1191eb33 100644 --- a/src/libs/AgentRulesUtils.ts +++ b/src/libs/AgentRulesUtils.ts @@ -1,6 +1,9 @@ import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; import type {AgentRule} from '@src/types/onyx/Policy'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + type AgentRulesCollection = Record | undefined; type AgentRuleWithID = AgentRule & { @@ -32,5 +35,19 @@ function getVisibleAgentRules(agentRules: AgentRulesCollection, isOffline: boole return getSortedAgentRules(agentRules).filter((rule) => rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); } -export {getAgentRuleDisplayTitle, getVisibleAgentRules}; +function hasAgentRules(policy: OnyxEntry): boolean { + return Object.values(policy?.rules?.agentRules ?? {}).some((rule) => !!rule && rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); +} + +/** Whether the given account is the RuleBot agent enforcing Agent rules on the policy. Such an account can't be removed until its rules are deleted. */ +function isRuleBotEnforcingRules(accountID: number | undefined, policy: OnyxEntry): boolean { + return !!accountID && policy?.ruleBotAccountID === accountID && hasAgentRules(policy); +} + +/** Whether the given account is the RuleBot agent enforcing Agent rules on any of the given policies. Such an account can't be deleted until its rules are removed. */ +function isRuleBotEnforcingRulesOnAnyPolicy(accountID: number | undefined, policies: OnyxCollection): boolean { + return !!accountID && Object.values(policies ?? {}).some((policy) => isRuleBotEnforcingRules(accountID, policy)); +} + +export {getAgentRuleDisplayTitle, getVisibleAgentRules, isRuleBotEnforcingRules, isRuleBotEnforcingRulesOnAnyPolicy}; export type {AgentRuleWithID}; diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx index 1951e24b04bd..cf58e58ad411 100644 --- a/src/pages/settings/Agents/AgentsPage.tsx +++ b/src/pages/settings/Agents/AgentsPage.tsx @@ -26,6 +26,7 @@ import useSwitchToDelegator from '@hooks/useSwitchToDelegator'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {isRuleBotEnforcingRulesOnAnyPolicy} from '@libs/AgentRulesUtils'; import {getLatestError} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -146,6 +147,16 @@ function AgentsPage() { }; const askForConfirmationToDelete = async () => { + const isRuleBotSelected = selectedAgentKeys.some((accountIDString) => isRuleBotEnforcingRulesOnAnyPolicy(Number(accountIDString), allPolicies)); + if (isRuleBotSelected) { + showConfirmModal({ + shouldShowCancelButton: false, + title: translate('workspace.rules.agentRules.unableToDeleteAgentTitle'), + prompt: translate('workspace.rules.agentRules.unableToDeleteAgentPrompt'), + confirmText: translate('common.buttonConfirm'), + }); + return; + } const result = await showConfirmModal({ title: translate('agentsPage.deleteAgentsTitle', {count: selectedAgentKeys.length}), prompt: translate('agentsPage.deleteAgentsMessage', {count: selectedAgentKeys.length}), diff --git a/src/pages/settings/Agents/EditAgentPage.tsx b/src/pages/settings/Agents/EditAgentPage.tsx index c7b197cb82f2..9e239f6e0951 100644 --- a/src/pages/settings/Agents/EditAgentPage.tsx +++ b/src/pages/settings/Agents/EditAgentPage.tsx @@ -16,6 +16,7 @@ import useSwitchToDelegator from '@hooks/useSwitchToDelegator'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearAgentAvatarUpdateError, clearAgentNameUpdateError, clearAgentPromptUpdateError, deleteAgent} from '@libs/actions/Agent'; +import {isRuleBotEnforcingRulesOnAnyPolicy} from '@libs/AgentRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -53,6 +54,15 @@ function EditAgentPage({route}: EditAgentPageProps) { const handleEditNamePress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_NAME.getRoute(accountID)); const handleEditPromptPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.getRoute(accountID)); const handleDeletePress = async () => { + if (isRuleBotEnforcingRulesOnAnyPolicy(accountID, allPolicies)) { + showConfirmModal({ + shouldShowCancelButton: false, + title: translate('workspace.rules.agentRules.unableToDeleteAgentTitle'), + prompt: translate('workspace.rules.agentRules.unableToDeleteAgentPrompt'), + confirmText: translate('common.buttonConfirm'), + }); + return; + } const result = await showConfirmModal({ title: translate('editAgentPage.deleteAgentTitle'), prompt: translate('editAgentPage.deleteAgentMessage'), diff --git a/src/pages/settings/Security/CloseAccountPage.tsx b/src/pages/settings/Security/CloseAccountPage.tsx index 67c087980df4..dbfe51075781 100644 --- a/src/pages/settings/Security/CloseAccountPage.tsx +++ b/src/pages/settings/Security/CloseAccountPage.tsx @@ -12,6 +12,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; import {formatE164PhoneNumber, getPhoneNumberWithoutSpecialChars, sanitizePhoneOrEmail} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; @@ -32,6 +33,10 @@ import {View} from 'react-native'; function CloseAccountPage() { const [session] = useOnyx(ONYXKEYS.SESSION); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + + // The account being closed can be a workspace's RuleBot agent (e.g. when accessed via copilot). Closing it would leave the workspace's Agent rules without an enforcer, so it stays open until those rules are removed. + const isRuleBotWithRules = Object.values(policies ?? {}).some((policy) => isRuleBotEnforcingRules(session?.accountID, policy)); const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); @@ -56,6 +61,15 @@ function CloseAccountPage() { useEffect(() => () => clearError(), []); const onSubmit = (values: FormOnyxValues) => { + if (isRuleBotWithRules) { + showConfirmModal({ + shouldShowCancelButton: false, + title: translate('workspace.rules.agentRules.unableToCloseAccountTitle'), + prompt: translate('workspace.rules.agentRules.unableToCloseAccountPrompt'), + confirmText: translate('common.buttonConfirm'), + }); + return; + } showCloseAccountWarningModal().then((result) => { if (result.action !== ModalActions.CONFIRM) { return; diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index 9f60f9620c4a..23cfd7b232cf 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -23,6 +23,7 @@ import useWaitForNavigation from '@hooks/useWaitForNavigation'; import {deleteAgent} from '@libs/actions/Agent'; import {disconnect, openSecuritySettingsPage} from '@libs/actions/Delegate'; +import {isRuleBotEnforcingRulesOnAnyPolicy} from '@libs/AgentRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; import {useIsAgentAccount} from '@libs/SessionUtils'; import {hasDeviceManagementError} from '@libs/UserUtils'; @@ -200,6 +201,15 @@ function SecuritySettingsPage() { }); return; } + if (isRuleBotEnforcingRulesOnAnyPolicy(session?.accountID, allPolicies)) { + showConfirmModal({ + shouldShowCancelButton: false, + title: translate('workspace.rules.agentRules.unableToDeleteAgentTitle'), + prompt: translate('workspace.rules.agentRules.unableToDeleteAgentPrompt'), + confirmText: translate('common.buttonConfirm'), + }); + return; + } const result = await showConfirmModal({ title: translate('editAgentPage.deleteAgentTitle'), prompt: translate('editAgentPage.deleteAgentMessage'), diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 6ee5d9799be5..4d8cade4753f 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -6,6 +6,7 @@ import DecisionModal from '@components/DecisionModal'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import MessagesRow from '@components/MessagesRow'; import {ModalActions} from '@components/Modal/Global/ModalContext'; +import RenderHTML from '@components/RenderHTML'; import type {TableHandle} from '@components/Table'; import type {WorkspaceMemberRowData, WorkspaceMembersTableColumnKey} from '@components/Tables/WorkspaceMembersTable'; import WorkspaceMembersTable from '@components/Tables/WorkspaceMembersTable'; @@ -42,6 +43,7 @@ import { updateWorkspaceMembersRole, } from '@libs/actions/Policy/Member'; import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow'; +import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; import {getLatestErrorMessageField} from '@libs/ErrorUtils'; import {getConnectedHRProvider, showMergeHRManualSyncLimitModalIfReached} from '@libs/HRUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -266,6 +268,20 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers * Show the modal to confirm removal of the selected members */ const askForConfirmationToRemove = useCallback(() => { + const isRuleBotSelected = selectedEmployees.some((email) => isRuleBotEnforcingRules(policyMemberEmailsToAccountIDs[email], policy)); + if (isRuleBotSelected) { + showConfirmModal({ + shouldShowCancelButton: false, + title: translate('workspace.rules.agentRules.unableToRemoveTitle'), + prompt: ( + + + + ), + confirmText: translate('common.buttonConfirm'), + }); + return; + } showConfirmModal({ danger: true, title: translate('workspace.people.removeMembersTitle', {count: selectedEmployees.length}), @@ -285,7 +301,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers removeUsers(); }); - }, [confirmModalPrompt, removeUsers, selectedEmployees.length, showConfirmModal, translate]); + }, [confirmModalPrompt, removeUsers, selectedEmployees, policyMemberEmailsToAccountIDs, policy, policyID, styles, showConfirmModal, translate]); /** Opens the member details page */ const openMemberDetails = useCallback( @@ -496,6 +512,19 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers }); const changeUserRole = (role: ValueOf) => { + if (role !== CONST.POLICY.ROLE.ADMIN && selectedEmployees.some((email) => isRuleBotEnforcingRules(policyMemberEmailsToAccountIDs[email], policy))) { + showConfirmModal({ + shouldShowCancelButton: false, + title: translate('workspace.rules.agentRules.unableToChangeRoleTitle'), + prompt: ( + + + + ), + confirmText: translate('common.buttonConfirm'), + }); + return; + } const loginsToUpdate = selectedEmployees.filter((login) => { return policy?.employeeList?.[login]?.role !== role; }); diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 80f0f107a7c2..05fd0ee4b270 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -7,6 +7,7 @@ import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -28,6 +29,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setPolicyPreventSelfApproval} from '@libs/actions/Policy/Policy'; import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow'; +import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; import {getAllCardsForWorkspace, getCardFeedIcon, getCardFeedWithDomainID, getPlaidInstitutionIconUrl, lastFourNumbersFromCardName, maskCardNumber} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -255,6 +257,19 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM }; const askForConfirmationToRemove = () => { + if (isRuleBotEnforcingRules(accountID, policy)) { + showConfirmModal({ + shouldShowCancelButton: false, + title: translate('workspace.rules.agentRules.unableToRemoveTitle'), + prompt: ( + + + + ), + confirmText: translate('common.buttonConfirm'), + }); + return; + } if (isReimburser) { showConfirmModal({ shouldShowCancelButton: false, diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 1600ef0f00e1..522d6b19a6df 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -1,11 +1,16 @@ +import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; import type {ListItemType} from '@components/WorkspaceMemberRoleList'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; +import useThemeStyles from '@hooks/useThemeStyles'; import {updateWorkspaceMembersRole} from '@libs/actions/Policy/Member'; +import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -23,6 +28,7 @@ import type {PersonalDetailsList} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; +import {View} from 'react-native'; type WorkspaceMemberDetailsRolePageProps = Omit & PlatformStackScreenProps & { @@ -33,6 +39,9 @@ type WorkspaceMemberDetailsRolePageProps = Omit + + + ), + confirmText: translate('common.buttonConfirm'), + }); + return; + } updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value); Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }; diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index 4982f3b7f83e..10ae88451ad7 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -1,6 +1,7 @@ -import {act, render, screen, waitFor} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import {ModalProvider} from '@components/Modal/Global/ModalContext'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -37,7 +38,7 @@ const Stack = createPlatformStackNavigator(); const renderPage = (initialParams: SettingsNavigatorParamList[typeof SCREENS.WORKSPACE.MEMBER_DETAILS]) => { return render( - + @@ -190,6 +191,38 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); + it('should show the unable-to-remove modal when the member is a RuleBot enforcing agent rules', async () => { + // The invited member acts as the workspace RuleBot with an active agent rule + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + ruleBotAccountID: invitedAccountID, + rules: { + agentRules: { + rule1: {ruleID: 'rule1', prompt: 'Flag all weekend expenses', created: '2025-01-01 00:00:00'}, + }, + }, + }); + }); + + const {unmount} = renderPage({policyID: policy.id, accountID: String(invitedAccountID)}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('WorkspaceMemberDetailsPage')).toBeOnTheScreen(); + }); + + fireEvent.press(screen.getByText(TestHelper.translateLocal('workspace.people.removeWorkspaceMemberButtonTitle'))); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('workspace.rules.agentRules.unableToRemoveTitle'))).toBeOnTheScreen(); + }); + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.removeMemberTitle'))).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should show the not found page when the accountID matches no workspace member', async () => { const {unmount} = renderPage({policyID: policy.id, accountID: '999999'}); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index 0b542bc18c65..8316e9bf3ffb 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -1,6 +1,7 @@ import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import {ModalProvider} from '@components/Modal/Global/ModalContext'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -36,7 +37,7 @@ const Stack = createPlatformStackNavigator(); const renderPage = (initialRouteName: typeof SCREENS.WORKSPACE.MEMBERS, initialParams: WorkspaceSplitNavigatorParamList[typeof SCREENS.WORKSPACE.MEMBERS]) => { return render( - + @@ -467,6 +468,78 @@ describe('WorkspaceMembers', () => { }); }); + describe('RuleBot restrictions', () => { + const makeAdminTheRuleBot = async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + ruleBotAccountID: adminAccountID, + rules: { + agentRules: { + rule1: {ruleID: 'rule1', prompt: 'Flag all weekend expenses', created: '2025-01-01 00:00:00'}, + }, + }, + }); + }); + }; + + const selectAdminAndOpenDropdown = async () => { + await screen.findByText(ADMIN_OPTION); + selectCheckboxByMemberName('Admin'); + fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); + await waitForBatchedUpdatesWithAct(); + }; + + it('should show the unable-to-remove modal when removing a RuleBot enforcing agent rules', async () => { + await makeAdminTheRuleBot(); + + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + await selectAdminAndOpenDropdown(); + + const removeMenuItem = screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1})); + fireEvent.press(removeMenuItem, { + nativeEvent: {}, + type: 'press', + target: removeMenuItem, + currentTarget: removeMenuItem, + }); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('workspace.rules.agentRules.unableToRemoveTitle'))).toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should show the unable-to-change-role modal when demoting a RuleBot enforcing agent rules', async () => { + await makeAdminTheRuleBot(); + + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + await selectAdminAndOpenDropdown(); + + const makeMemberMenuItem = screen.getByText(TestHelper.translateLocal('workspace.people.makeMember', {count: 1})); + fireEvent.press(makeMemberMenuItem, { + nativeEvent: {}, + type: 'press', + target: makeMemberMenuItem, + currentTarget: makeMemberMenuItem, + }); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('workspace.rules.agentRules.unableToChangeRoleTitle'))).toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + }); + describe('Role display on Submit workspaces', () => { it('should show the workspace owner as Editor instead of Owner', async () => { // Given a Submit workspace, where every member (including the owner) uses the flat Editor role diff --git a/tests/unit/AgentRulesUtilsTest.ts b/tests/unit/AgentRulesUtilsTest.ts new file mode 100644 index 000000000000..1121b93a268e --- /dev/null +++ b/tests/unit/AgentRulesUtilsTest.ts @@ -0,0 +1,132 @@ +import {getAgentRuleDisplayTitle, getVisibleAgentRules, isRuleBotEnforcingRules, isRuleBotEnforcingRulesOnAnyPolicy} from '@libs/AgentRulesUtils'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; +import type {AgentRule} from '@src/types/onyx/Policy'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; + +const RULE_BOT_ACCOUNT_ID = 12345; + +function buildAgentRule(ruleID: string, overrides: Partial = {}): AgentRule { + return { + ruleID, + prompt: `Prompt for ${ruleID}`, + created: '2025-01-01 00:00:00', + ...overrides, + }; +} + +function buildPolicyWithAgentRules(agentRules: Record | undefined, ruleBotAccountID: number | undefined = RULE_BOT_ACCOUNT_ID): Policy { + const policy = createRandomPolicy(1); + return { + ...policy, + ruleBotAccountID, + rules: { + ...policy.rules, + agentRules, + }, + }; +} + +describe('AgentRulesUtils', () => { + describe('getAgentRuleDisplayTitle', () => { + it('returns the title when present, collapsing extra whitespace', () => { + const rule = buildAgentRule('rule1', {title: ' Flag meals\nover $50 '}); + expect(getAgentRuleDisplayTitle(rule)).toBe('Flag meals over $50'); + }); + + it('falls back to the prompt when the rule has no title', () => { + const rule = buildAgentRule('rule1', {prompt: 'Flag all weekend expenses'}); + expect(getAgentRuleDisplayTitle(rule)).toBe('Flag all weekend expenses'); + }); + }); + + describe('getVisibleAgentRules', () => { + it('returns rules sorted with the most recently created first', () => { + const agentRules = { + older: buildAgentRule('older', {created: '2025-01-01 00:00:00'}), + newer: buildAgentRule('newer', {created: '2025-06-01 00:00:00'}), + }; + const visibleRules = getVisibleAgentRules(agentRules, false); + expect(visibleRules.map((rule) => rule.ruleID)).toEqual(['newer', 'older']); + }); + + it('hides rules pending deletion when online but keeps them when offline', () => { + const agentRules = { + active: buildAgentRule('active'), + deleted: buildAgentRule('deleted', {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), + }; + expect(getVisibleAgentRules(agentRules, false).map((rule) => rule.ruleID)).toEqual(['active']); + expect(getVisibleAgentRules(agentRules, true)).toHaveLength(2); + }); + + it('returns an empty array when there are no rules', () => { + expect(getVisibleAgentRules(undefined, false)).toEqual([]); + }); + }); + + describe('isRuleBotEnforcingRules', () => { + it('returns true when the account is the policy RuleBot and an active rule exists', () => { + const policy = buildPolicyWithAgentRules({rule1: buildAgentRule('rule1')}); + expect(isRuleBotEnforcingRules(RULE_BOT_ACCOUNT_ID, policy)).toBe(true); + }); + + it('returns false when the account is not the policy RuleBot', () => { + const policy = buildPolicyWithAgentRules({rule1: buildAgentRule('rule1')}); + expect(isRuleBotEnforcingRules(RULE_BOT_ACCOUNT_ID + 1, policy)).toBe(false); + }); + + it('returns false when the accountID is undefined', () => { + const policy = buildPolicyWithAgentRules({rule1: buildAgentRule('rule1')}); + expect(isRuleBotEnforcingRules(undefined, policy)).toBe(false); + }); + + it('returns false when the policy has no agent rules', () => { + const policy = buildPolicyWithAgentRules(undefined); + expect(isRuleBotEnforcingRules(RULE_BOT_ACCOUNT_ID, policy)).toBe(false); + }); + + it('returns false when every agent rule is pending deletion', () => { + const policy = buildPolicyWithAgentRules({ + rule1: buildAgentRule('rule1', {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), + }); + expect(isRuleBotEnforcingRules(RULE_BOT_ACCOUNT_ID, policy)).toBe(false); + }); + + it('returns false when the policy is undefined', () => { + expect(isRuleBotEnforcingRules(RULE_BOT_ACCOUNT_ID, undefined)).toBe(false); + }); + }); + + describe('isRuleBotEnforcingRulesOnAnyPolicy', () => { + it('returns true when the account enforces rules on at least one policy', () => { + const policies: OnyxCollection = { + policy1: buildPolicyWithAgentRules(undefined, undefined), + policy2: buildPolicyWithAgentRules({rule1: buildAgentRule('rule1')}), + }; + expect(isRuleBotEnforcingRulesOnAnyPolicy(RULE_BOT_ACCOUNT_ID, policies)).toBe(true); + }); + + it('returns false when no policy has the account as an enforcing RuleBot', () => { + const policies: OnyxCollection = { + policy1: buildPolicyWithAgentRules({rule1: buildAgentRule('rule1')}, RULE_BOT_ACCOUNT_ID + 1), + policy2: undefined, + }; + expect(isRuleBotEnforcingRulesOnAnyPolicy(RULE_BOT_ACCOUNT_ID, policies)).toBe(false); + }); + + it('returns false when the accountID is undefined', () => { + const policies: OnyxCollection = { + policy1: buildPolicyWithAgentRules({rule1: buildAgentRule('rule1')}), + }; + expect(isRuleBotEnforcingRulesOnAnyPolicy(undefined, policies)).toBe(false); + }); + + it('returns false when the policy collection is undefined', () => { + expect(isRuleBotEnforcingRulesOnAnyPolicy(RULE_BOT_ACCOUNT_ID, undefined)).toBe(false); + }); + }); +});