From 3203246bdb5facf46c5f6a3b5dc7c20ea58bf655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonet=20=28via=20MelvinBot=29?= Date: Fri, 24 Jul 2026 15:31:30 +0000 Subject: [PATCH 1/7] Flag Agent rules when no RuleBot is enforcing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a workspace has Agent rules but no live RuleBot assigned (none assigned, the agent was deleted, or it was removed from the workspace member list), the rules are silently not enforced. Surface this in the Agent rules section header with a red warning so admins know. Adds a hasRules input to useAgentRulesSectionHeader and renders the warning when rules exist but ruleBotAccountID is missing or the assigned bot is no longer an active policy member. Co-authored-by: Nicolás Bonet --- src/components/AgentRules/useAgentRulesSectionHeader.tsx | 9 ++++++++- src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/pages/workspace/rules/AgentRulesSection.tsx | 1 + src/pages/workspace/rules/tabs/RulesAgentsTab.tsx | 1 + 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/AgentRules/useAgentRulesSectionHeader.tsx b/src/components/AgentRules/useAgentRulesSectionHeader.tsx index 41a068754e7c..88bf73fcb8c4 100644 --- a/src/components/AgentRules/useAgentRulesSectionHeader.tsx +++ b/src/components/AgentRules/useAgentRulesSectionHeader.tsx @@ -17,9 +17,12 @@ type UseAgentRulesSectionHeaderProps = { policyID: string; subtitle: string; isBadgeCondensed?: boolean; + + /** Whether the workspace has any Agent rules. Used to flag when rules exist but no RuleBot is enforcing them. */ + hasRules?: boolean; }; -function useAgentRulesSectionHeader({policyID, subtitle, isBadgeCondensed = false}: UseAgentRulesSectionHeaderProps) { +function useAgentRulesSectionHeader({policyID, subtitle, isBadgeCondensed = false, hasRules = false}: UseAgentRulesSectionHeaderProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); @@ -33,6 +36,9 @@ function useAgentRulesSectionHeader({policyID, subtitle, isBadgeCondensed = fals // ruleBotAccountID stays set on the policy after RuleBot is removed from the workspace, so also require it to still be an active member before showing the "enforced by" line. const isRuleBotActiveMember = isPolicyMemberWithoutPendingDelete(ruleBot?.login, policy); + // Rules exist but there is no live RuleBot enforcing them: either none is assigned, or the assigned one was deleted / removed from the workspace. Flag this so admins know the rules are not being enforced. + const isRuleBotMissing = hasRules && (!ruleBotAccountID || !isRuleBotActiveMember); + const renderTitle = () => ( {translate('workspace.rules.agentRules.title')} @@ -59,6 +65,7 @@ function useAgentRulesSectionHeader({policyID, subtitle, isBadgeCondensed = fals /> )} + {isRuleBotMissing && {translate('workspace.rules.agentRules.notEnforced')}} ); diff --git a/src/languages/en.ts b/src/languages/en.ts index 75bfadee2ca7..13845e341d93 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8112,6 +8112,7 @@ const translations = { subtitle: 'Set rules for how AI agents handle expenses on this workspace.', revampSubtitle: 'Describe flexible rules that run when you need.', enforcedBy: 'Agent rules are enforced by', + notEnforced: 'These rules are not being enforced because no agent is assigned. Add a rule to assign one.', ruleBotName: 'RuleBot', addRule: 'Add agent rule', findRule: 'Find agent rule', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5e173ef30b78..2698323805f3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7704,6 +7704,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, title: 'Reglas del agente', subtitle: 'Configura reglas para cómo los agentes de IA gestionan los gastos en este espacio de trabajo.', enforcedBy: 'Las reglas del agente se aplican mediante', + notEnforced: 'Estas reglas no se están aplicando porque no hay ningún agente asignado. Añade una regla para asignar uno.', ruleBotName: 'RuleBot', addRule: 'Añadir regla de agente', findRule: 'Encontrar regla de agente', diff --git a/src/pages/workspace/rules/AgentRulesSection.tsx b/src/pages/workspace/rules/AgentRulesSection.tsx index 0779fb9f2bdc..e25f8b9923a4 100644 --- a/src/pages/workspace/rules/AgentRulesSection.tsx +++ b/src/pages/workspace/rules/AgentRulesSection.tsx @@ -36,6 +36,7 @@ function AgentRulesSection({policyID, canWriteRules, showReadOnlyModal}: AgentRu policyID, subtitle: translate('workspace.rules.agentRules.subtitle'), isBadgeCondensed: true, + hasRules, }); return ( diff --git a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx index 63b00ced4f96..494aa8fa6cc9 100644 --- a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx @@ -43,6 +43,7 @@ function RulesAgentsTab({policyID, canWriteRules, showReadOnlyModal}: RulesAgent const {renderTitle, renderSubtitle} = useAgentRulesSectionHeader({ policyID, subtitle: translate('workspace.rules.agentRules.revampSubtitle'), + hasRules, }); const handleAddAgentRule = () => { From 848869965278e9b5688be8e81ca577f973973be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonet=20=28via=20MelvinBot=29?= Date: Fri, 24 Jul 2026 15:40:23 +0000 Subject: [PATCH 2/7] Add notEnforced translation to remaining locales to fix typecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nicolás Bonet --- src/languages/de.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + 8 files changed, 8 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 1e5a4131ca3a..060e260d05f1 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7792,6 +7792,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc title: 'Agentenregeln', subtitle: 'Legen Sie Regeln fest, wie KI-Agenten mit Ausgaben in diesem Workspace umgehen.', enforcedBy: 'Agentregeln werden erzwungen durch', + notEnforced: 'Diese Regeln werden nicht durchgesetzt, da kein Agent zugewiesen ist. Füge eine Regel hinzu, um einen zuzuweisen.', ruleBotName: 'RuleBot', addRule: 'Agentenregel hinzufügen', findRule: 'Agentenregel finden', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 664cee6663fb..db763a420ac3 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7820,6 +7820,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e title: 'Règles d’agent', subtitle: 'Définissez des règles pour déterminer comment les agents IA gèrent les dépenses dans cet espace de travail.', enforcedBy: 'Les règles des agents sont appliquées par', + notEnforced: 'Ces règles ne sont pas appliquées car aucun agent n’est assigné. Ajoutez une règle pour en assigner un.', ruleBotName: 'RuleBot', addRule: 'Ajouter une règle d’agent', findRule: 'Rechercher une règle d’agent', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6dc456a27c0c..ec9ab9e22e45 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7770,6 +7770,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, title: 'Regole agente', subtitle: 'Imposta le regole su come gli agenti IA gestiscono le spese in questo spazio di lavoro.', enforcedBy: 'Le regole degli agenti sono applicate da', + notEnforced: 'Queste regole non vengono applicate perché non è assegnato alcun agente. Aggiungi una regola per assegnarne uno.', ruleBotName: 'RuleBot', addRule: 'Aggiungi regola agente', findRule: 'Trova regola agente', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 11db3c3def10..c0618578512e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7677,6 +7677,7 @@ ${reportName}`, title: 'エージェントルール', subtitle: 'このワークスペースで AI エージェントが経費を処理する方法のルールを設定します。', enforcedBy: 'エージェントルールは次によって適用されます', + notEnforced: 'エージェントが割り当てられていないため、これらのルールは適用されていません。ルールを追加してエージェントを割り当ててください。', ruleBotName: 'RuleBot', addRule: 'エージェントルールを追加', findRule: 'エージェントルールを検索', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c774982e5ffe..d3e61a3b4b8a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7751,6 +7751,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, title: 'Agentregels', subtitle: 'Stel regels in voor hoe AI-agenten met uitgaven omgaan in deze werkruimte.', enforcedBy: 'Agentregels worden afgedwongen door', + notEnforced: 'Deze regels worden niet afgedwongen omdat er geen agent is toegewezen. Voeg een regel toe om er een toe te wijzen.', ruleBotName: 'RuleBot', addRule: 'Agentregel toevoegen', findRule: 'Agentregel zoeken', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ac30cc2c6efc..d56271a6687d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7731,6 +7731,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, title: 'Zasady agenta', subtitle: 'Ustaw zasady dotyczące tego, jak agenci AI obsługują wydatki w tym obszarze roboczym.', enforcedBy: 'Zasady agenta są egzekwowane przez', + notEnforced: 'Te zasady nie są egzekwowane, ponieważ nie przypisano żadnego agenta. Dodaj zasadę, aby przypisać agenta.', ruleBotName: 'RuleBot', addRule: 'Dodaj regułę agenta', findRule: 'Znajdź regułę agenta', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index fe6a996bfccf..c4f339dd7ff5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7743,6 +7743,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, title: 'Regras do agente', subtitle: 'Defina regras para como os agentes de IA lidam com despesas neste workspace.', enforcedBy: 'As regras do agente são aplicadas por', + notEnforced: 'Essas regras não estão sendo aplicadas porque nenhum agente foi atribuído. Adicione uma regra para atribuir um.', ruleBotName: 'RuleBot', addRule: 'Adicionar regra de agente', findRule: 'Encontrar regra de agente', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 487d47fe7611..9b2af40f7404 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7505,6 +7505,7 @@ ${reportName}`, title: '代理规则', subtitle: '为此工作区设置 AI 代理处理报销的规则。', enforcedBy: '代理规则强制执行者:', + notEnforced: '这些规则未被强制执行,因为未分配代理。添加规则以分配一个代理。', ruleBotName: 'RuleBot', addRule: '添加代理规则', findRule: '查找代理规则', From d3a6e47c1cfa4e07933fc24af21f89287c1da482 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Fri, 24 Jul 2026 12:06:58 -0500 Subject: [PATCH 3/7] Hide not-enforced warning while rule creation is pending The server assigns a RuleBot when it processes a rule creation, so the optimistic add briefly shows the warning until the response arrives. Only count server-confirmed rules when deciding enforcement is missing. Co-Authored-By: Claude Fable 5 --- src/components/AgentRules/useAgentRulesSectionHeader.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/AgentRules/useAgentRulesSectionHeader.tsx b/src/components/AgentRules/useAgentRulesSectionHeader.tsx index 88bf73fcb8c4..79d93f69ea97 100644 --- a/src/components/AgentRules/useAgentRulesSectionHeader.tsx +++ b/src/components/AgentRules/useAgentRulesSectionHeader.tsx @@ -10,6 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isPolicyMemberWithoutPendingDelete} from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; + import React from 'react'; import {View} from 'react-native'; @@ -36,8 +38,11 @@ function useAgentRulesSectionHeader({policyID, subtitle, isBadgeCondensed = fals // ruleBotAccountID stays set on the policy after RuleBot is removed from the workspace, so also require it to still be an active member before showing the "enforced by" line. const isRuleBotActiveMember = isPolicyMemberWithoutPendingDelete(ruleBot?.login, policy); + // The server assigns a RuleBot when it processes a rule creation, so a rule that is still optimistically pending its add can't have one yet. Only rules confirmed by the server count when deciding whether enforcement is missing. + const hasSyncedRule = Object.values(policy?.rules?.agentRules ?? {}).some((rule) => rule?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + // Rules exist but there is no live RuleBot enforcing them: either none is assigned, or the assigned one was deleted / removed from the workspace. Flag this so admins know the rules are not being enforced. - const isRuleBotMissing = hasRules && (!ruleBotAccountID || !isRuleBotActiveMember); + const isRuleBotMissing = hasRules && hasSyncedRule && (!ruleBotAccountID || !isRuleBotActiveMember); const renderTitle = () => ( From 89bdcec1fcd95b25a50dd35e609f1154564ff52c Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Fri, 24 Jul 2026 13:08:28 -0500 Subject: [PATCH 4/7] Block removing RuleBot and closing its account while agent rules exist Replaces the not-enforced warning approach: instead of flagging rules without an enforcer after the fact, prevent RuleBot from being removed (member details page and bulk members removal) and its account from being closed while agent rules still exist on the workspace. Co-Authored-By: Claude Fable 5 --- .../AgentRules/useAgentRulesSectionHeader.tsx | 14 +------------- src/languages/de.ts | 6 +++++- src/languages/en.ts | 6 +++++- src/languages/es.ts | 6 +++++- src/languages/fr.ts | 6 +++++- src/languages/it.ts | 6 +++++- src/languages/ja.ts | 6 +++++- src/languages/nl.ts | 6 +++++- src/languages/pl.ts | 6 +++++- src/languages/pt-BR.ts | 6 +++++- src/languages/zh-hans.ts | 5 ++++- src/libs/AgentRulesUtils.ts | 14 +++++++++++++- .../settings/Security/CloseAccountPage.tsx | 14 ++++++++++++++ src/pages/workspace/WorkspaceMembersPage.tsx | 18 +++++++++++++++++- .../members/WorkspaceMemberDetailsPage.tsx | 15 +++++++++++++++ .../workspace/rules/AgentRulesSection.tsx | 1 - .../workspace/rules/tabs/RulesAgentsTab.tsx | 1 - 17 files changed, 109 insertions(+), 27 deletions(-) diff --git a/src/components/AgentRules/useAgentRulesSectionHeader.tsx b/src/components/AgentRules/useAgentRulesSectionHeader.tsx index 79d93f69ea97..41a068754e7c 100644 --- a/src/components/AgentRules/useAgentRulesSectionHeader.tsx +++ b/src/components/AgentRules/useAgentRulesSectionHeader.tsx @@ -10,8 +10,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isPolicyMemberWithoutPendingDelete} from '@libs/PolicyUtils'; -import CONST from '@src/CONST'; - import React from 'react'; import {View} from 'react-native'; @@ -19,12 +17,9 @@ type UseAgentRulesSectionHeaderProps = { policyID: string; subtitle: string; isBadgeCondensed?: boolean; - - /** Whether the workspace has any Agent rules. Used to flag when rules exist but no RuleBot is enforcing them. */ - hasRules?: boolean; }; -function useAgentRulesSectionHeader({policyID, subtitle, isBadgeCondensed = false, hasRules = false}: UseAgentRulesSectionHeaderProps) { +function useAgentRulesSectionHeader({policyID, subtitle, isBadgeCondensed = false}: UseAgentRulesSectionHeaderProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); @@ -38,12 +33,6 @@ function useAgentRulesSectionHeader({policyID, subtitle, isBadgeCondensed = fals // ruleBotAccountID stays set on the policy after RuleBot is removed from the workspace, so also require it to still be an active member before showing the "enforced by" line. const isRuleBotActiveMember = isPolicyMemberWithoutPendingDelete(ruleBot?.login, policy); - // The server assigns a RuleBot when it processes a rule creation, so a rule that is still optimistically pending its add can't have one yet. Only rules confirmed by the server count when deciding whether enforcement is missing. - const hasSyncedRule = Object.values(policy?.rules?.agentRules ?? {}).some((rule) => rule?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - - // Rules exist but there is no live RuleBot enforcing them: either none is assigned, or the assigned one was deleted / removed from the workspace. Flag this so admins know the rules are not being enforced. - const isRuleBotMissing = hasRules && hasSyncedRule && (!ruleBotAccountID || !isRuleBotActiveMember); - const renderTitle = () => ( {translate('workspace.rules.agentRules.title')} @@ -70,7 +59,6 @@ function useAgentRulesSectionHeader({policyID, subtitle, isBadgeCondensed = fals /> )} - {isRuleBotMissing && {translate('workspace.rules.agentRules.notEnforced')}} ); diff --git a/src/languages/de.ts b/src/languages/de.ts index 060e260d05f1..1d25291f8946 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7792,7 +7792,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc title: 'Agentenregeln', subtitle: 'Legen Sie Regeln fest, wie KI-Agenten mit Ausgaben in diesem Workspace umgehen.', enforcedBy: 'Agentregeln werden erzwungen durch', - notEnforced: 'Diese Regeln werden nicht durchgesetzt, da kein Agent zugewiesen ist. Füge eine Regel hinzu, um einen zuzuweisen.', ruleBotName: 'RuleBot', addRule: 'Agentenregel hinzufügen', findRule: 'Agentenregel finden', @@ -7801,6 +7800,11 @@ 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.', 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 13845e341d93..a04aedbeaef1 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8112,7 +8112,6 @@ const translations = { subtitle: 'Set rules for how AI agents handle expenses on this workspace.', revampSubtitle: 'Describe flexible rules that run when you need.', enforcedBy: 'Agent rules are enforced by', - notEnforced: 'These rules are not being enforced because no agent is assigned. Add a rule to assign one.', ruleBotName: 'RuleBot', addRule: 'Add agent rule', findRule: 'Find agent rule', @@ -8121,6 +8120,11 @@ 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.', 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 2698323805f3..a087a61e3913 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7704,7 +7704,6 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, title: 'Reglas del agente', subtitle: 'Configura reglas para cómo los agentes de IA gestionan los gastos en este espacio de trabajo.', enforcedBy: 'Las reglas del agente se aplican mediante', - notEnforced: 'Estas reglas no se están aplicando porque no hay ningún agente asignado. Añade una regla para asignar uno.', ruleBotName: 'RuleBot', addRule: 'Añadir regla de agente', findRule: 'Encontrar regla de agente', @@ -7715,6 +7714,11 @@ 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.', 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 db763a420ac3..9c9910aa71eb 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7820,7 +7820,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e title: 'Règles d’agent', subtitle: 'Définissez des règles pour déterminer comment les agents IA gèrent les dépenses dans cet espace de travail.', enforcedBy: 'Les règles des agents sont appliquées par', - notEnforced: 'Ces règles ne sont pas appliquées car aucun agent n’est assigné. Ajoutez une règle pour en assigner un.', ruleBotName: 'RuleBot', addRule: 'Ajouter une règle d’agent', findRule: 'Rechercher une règle d’agent', @@ -7829,6 +7828,11 @@ 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.', 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 ec9ab9e22e45..23e85aca3531 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7770,7 +7770,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, title: 'Regole agente', subtitle: 'Imposta le regole su come gli agenti IA gestiscono le spese in questo spazio di lavoro.', enforcedBy: 'Le regole degli agenti sono applicate da', - notEnforced: 'Queste regole non vengono applicate perché non è assegnato alcun agente. Aggiungi una regola per assegnarne uno.', ruleBotName: 'RuleBot', addRule: 'Aggiungi regola agente', findRule: 'Trova regola agente', @@ -7779,6 +7778,11 @@ 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.', 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 c0618578512e..6bbbd07dad55 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7677,7 +7677,6 @@ ${reportName}`, title: 'エージェントルール', subtitle: 'このワークスペースで AI エージェントが経費を処理する方法のルールを設定します。', enforcedBy: 'エージェントルールは次によって適用されます', - notEnforced: 'エージェントが割り当てられていないため、これらのルールは適用されていません。ルールを追加してエージェントを割り当ててください。', ruleBotName: 'RuleBot', addRule: 'エージェントルールを追加', findRule: 'エージェントルールを検索', @@ -7686,6 +7685,11 @@ ${reportName}`, editRuleTitle: 'ルールを編集', deleteRule: 'ルールを削除', deleteRuleConfirmation: 'このルールを削除してもよろしいですか?', + unableToRemoveTitle: '削除できません', + unableToRemovePrompt: (rulesRoute: string) => + `このエージェントを削除する前に、RuleBot が適用しているエージェントルールをワークスペースから削除する必要があります。`, + unableToCloseAccountTitle: 'アカウントを閉鎖できません', + unableToCloseAccountPrompt: 'このアカウントを閉鎖する前に、RuleBot が適用しているエージェントルールをワークスペースから削除する必要があります。', describeRuleTitle: 'AI エージェントに従わせるルールを記述してください', describeRuleHeadline: 'ルールを説明する', disclaimer: 'AI エージェントは間違える場合があります。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d3e61a3b4b8a..c838c1b2c85a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7751,7 +7751,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, title: 'Agentregels', subtitle: 'Stel regels in voor hoe AI-agenten met uitgaven omgaan in deze werkruimte.', enforcedBy: 'Agentregels worden afgedwongen door', - notEnforced: 'Deze regels worden niet afgedwongen omdat er geen agent is toegewezen. Voeg een regel toe om er een toe te wijzen.', ruleBotName: 'RuleBot', addRule: 'Agentregel toevoegen', findRule: 'Agentregel zoeken', @@ -7760,6 +7759,11 @@ 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.', 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 d56271a6687d..46e5b28715e7 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7731,7 +7731,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, title: 'Zasady agenta', subtitle: 'Ustaw zasady dotyczące tego, jak agenci AI obsługują wydatki w tym obszarze roboczym.', enforcedBy: 'Zasady agenta są egzekwowane przez', - notEnforced: 'Te zasady nie są egzekwowane, ponieważ nie przypisano żadnego agenta. Dodaj zasadę, aby przypisać agenta.', ruleBotName: 'RuleBot', addRule: 'Dodaj regułę agenta', findRule: 'Znajdź regułę agenta', @@ -7740,6 +7739,11 @@ 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.', 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 c4f339dd7ff5..b2300325757c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7743,7 +7743,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, title: 'Regras do agente', subtitle: 'Defina regras para como os agentes de IA lidam com despesas neste workspace.', enforcedBy: 'As regras do agente são aplicadas por', - notEnforced: 'Essas regras não estão sendo aplicadas porque nenhum agente foi atribuído. Adicione uma regra para atribuir um.', ruleBotName: 'RuleBot', addRule: 'Adicionar regra de agente', findRule: 'Encontrar regra de agente', @@ -7752,6 +7751,11 @@ 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.', 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 9b2af40f7404..6eb16eaeab60 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7505,7 +7505,6 @@ ${reportName}`, title: '代理规则', subtitle: '为此工作区设置 AI 代理处理报销的规则。', enforcedBy: '代理规则强制执行者:', - notEnforced: '这些规则未被强制执行,因为未分配代理。添加规则以分配一个代理。', ruleBotName: 'RuleBot', addRule: '添加代理规则', findRule: '查找代理规则', @@ -7514,6 +7513,10 @@ ${reportName}`, editRuleTitle: '编辑规则', deleteRule: '删除规则', deleteRuleConfirmation: '确定要删除此规则吗?', + unableToRemoveTitle: '无法移除', + unableToRemovePrompt: (rulesRoute: string) => `在移除此代理之前,需要先从工作区中移除由 RuleBot 执行的代理规则。`, + unableToCloseAccountTitle: '无法关闭账户', + unableToCloseAccountPrompt: '在关闭此账户之前,需要先从工作区中移除由 RuleBot 执行的代理规则。', describeRuleTitle: '描述你的 AI 代理需要遵循的规则', describeRuleHeadline: '描述您的规则', disclaimer: 'AI 智能体可能会犯错。', diff --git a/src/libs/AgentRulesUtils.ts b/src/libs/AgentRulesUtils.ts index a73dfad28911..ddc909e7e209 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 {OnyxEntry} from 'react-native-onyx'; + type AgentRulesCollection = Record | undefined; type AgentRuleWithID = AgentRule & { @@ -32,5 +35,14 @@ 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); +} + +export {getAgentRuleDisplayTitle, getVisibleAgentRules, hasAgentRules, isRuleBotEnforcingRules}; export type {AgentRuleWithID}; 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/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 6ee5d9799be5..a3c188fa700a 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( 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/rules/AgentRulesSection.tsx b/src/pages/workspace/rules/AgentRulesSection.tsx index e25f8b9923a4..0779fb9f2bdc 100644 --- a/src/pages/workspace/rules/AgentRulesSection.tsx +++ b/src/pages/workspace/rules/AgentRulesSection.tsx @@ -36,7 +36,6 @@ function AgentRulesSection({policyID, canWriteRules, showReadOnlyModal}: AgentRu policyID, subtitle: translate('workspace.rules.agentRules.subtitle'), isBadgeCondensed: true, - hasRules, }); return ( diff --git a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx index 494aa8fa6cc9..63b00ced4f96 100644 --- a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx @@ -43,7 +43,6 @@ function RulesAgentsTab({policyID, canWriteRules, showReadOnlyModal}: RulesAgent const {renderTitle, renderSubtitle} = useAgentRulesSectionHeader({ policyID, subtitle: translate('workspace.rules.agentRules.revampSubtitle'), - hasRules, }); const handleAddAgentRule = () => { From 4a9018d3c6f998c636f940d9c5a1013f1c6ef9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonet=20=28via=20MelvinBot=29?= Date: Fri, 24 Jul 2026 18:24:14 +0000 Subject: [PATCH 5/7] Stop exporting internal-only hasAgentRules to fix knip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nicolás Bonet --- src/libs/AgentRulesUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/AgentRulesUtils.ts b/src/libs/AgentRulesUtils.ts index ddc909e7e209..61ea63963da8 100644 --- a/src/libs/AgentRulesUtils.ts +++ b/src/libs/AgentRulesUtils.ts @@ -44,5 +44,5 @@ function isRuleBotEnforcingRules(accountID: number | undefined, policy: OnyxEntr return !!accountID && policy?.ruleBotAccountID === accountID && hasAgentRules(policy); } -export {getAgentRuleDisplayTitle, getVisibleAgentRules, hasAgentRules, isRuleBotEnforcingRules}; +export {getAgentRuleDisplayTitle, getVisibleAgentRules, isRuleBotEnforcingRules}; export type {AgentRuleWithID}; From 1c0426bef9f599fe8175558cec656b6c72bb1b33 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Fri, 24 Jul 2026 14:54:01 -0500 Subject: [PATCH 6/7] Block deleting RuleBot and demoting it from admin while agent rules exist Deleting the RuleBot agent (edit agent page, agents bulk delete, or close account while copiloting into it) or demoting it to a non-admin role (role selection page, members bulk role change) would leave the workspace's agent rules without an enforcer, so both are blocked with an explanatory modal until the rules are removed. Co-Authored-By: Claude Fable 5 --- src/languages/de.ts | 6 +++++ src/languages/en.ts | 5 +++++ src/languages/es.ts | 5 +++++ src/languages/fr.ts | 6 +++++ src/languages/it.ts | 5 +++++ src/languages/ja.ts | 5 +++++ src/languages/nl.ts | 5 +++++ src/languages/pl.ts | 5 +++++ src/languages/pt-BR.ts | 5 +++++ src/languages/zh-hans.ts | 4 ++++ src/libs/AgentRulesUtils.ts | 9 ++++++-- src/pages/settings/Agents/AgentsPage.tsx | 11 ++++++++++ src/pages/settings/Agents/EditAgentPage.tsx | 10 +++++++++ .../Security/SecuritySettingsPage.tsx | 10 +++++++++ src/pages/workspace/WorkspaceMembersPage.tsx | 13 +++++++++++ .../WorkspaceMemberDetailsRolePage.tsx | 22 +++++++++++++++++++ 16 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 1d25291f8946..899fb5ef09ab 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7805,6 +7805,12 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc `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 a04aedbeaef1..f7137c74c18d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8125,6 +8125,11 @@ const translations = { `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 a087a61e3913..5927128dfc5a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7719,6 +7719,11 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, `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 9c9910aa71eb..ca203bcd4d6e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7833,6 +7833,12 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e `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 23e85aca3531..50e5bac265eb 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7783,6 +7783,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, `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 6bbbd07dad55..84ac4cf02462 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7690,6 +7690,11 @@ ${reportName}`, `このエージェントを削除する前に、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 c838c1b2c85a..ed5698163bc9 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7764,6 +7764,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, `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 46e5b28715e7..1cb126b62dfe 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7744,6 +7744,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, `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 b2300325757c..2adcc47c8e33 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7756,6 +7756,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, `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 6eb16eaeab60..db70220118f8 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7517,6 +7517,10 @@ ${reportName}`, 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 61ea63963da8..93ab1191eb33 100644 --- a/src/libs/AgentRulesUtils.ts +++ b/src/libs/AgentRulesUtils.ts @@ -2,7 +2,7 @@ import CONST from '@src/CONST'; import type {Policy} from '@src/types/onyx'; import type {AgentRule} from '@src/types/onyx/Policy'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; type AgentRulesCollection = Record | undefined; @@ -44,5 +44,10 @@ function isRuleBotEnforcingRules(accountID: number | undefined, policy: OnyxEntr return !!accountID && policy?.ruleBotAccountID === accountID && hasAgentRules(policy); } -export {getAgentRuleDisplayTitle, getVisibleAgentRules, isRuleBotEnforcingRules}; +/** 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/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 a3c188fa700a..4d8cade4753f 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -512,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/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)); }; From 0511caced5b98054d5f2263583e9a1d2cb6e8cfb Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Fri, 24 Jul 2026 15:08:30 -0500 Subject: [PATCH 7/7] Add tests covering RuleBot removal and role-change restrictions Co-Authored-By: Claude Fable 5 --- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 37 +++++- tests/ui/WorkspaceMembersTest.tsx | 75 ++++++++++- tests/unit/AgentRulesUtilsTest.ts | 132 ++++++++++++++++++++ 3 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 tests/unit/AgentRulesUtilsTest.ts 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); + }); + }); +});