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);
+ });
+ });
+});