From 783fe8d387409b43f15cdcc9868f27974d818efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 19 Nov 2025 11:26:05 +0100 Subject: [PATCH] Agent role edition (#15914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐Ÿ› Issues Fixed ### 1. Role Editing Not Working for New Agents When creating a new agent and then creating a role for it, the role permissions appeared as non-editable. This was caused by: - In create mode, `agentId = ''` (empty string from `useParams`) - Empty string is **falsy** but `isDefined('')` returns **true** - This caused incorrect behavior in role exclusivity checks and API calls ### 2. Missing Role Data Loading The agent form wasn't loading role data needed for permission editing because it was missing the `SettingsRolesQueryEffect` component. ### 3. Navigation Conflicts The `useSaveDraftRoleToDB` hook contained navigation logic that caused conflicts when used from different contexts (role detail page vs agent form). ### 4. Linting Errors Unused `useIcons` import in `SettingsAgentRoleTab.tsx`. --- ## ๐Ÿ”ง Changes Made ### Agent Role Tab (`SettingsAgentRoleTab.tsx`) - โœ… Use `isNonEmptyString(agentId)` to validate agentId (follows codebase patterns) - โœ… Improved role exclusivity logic to handle both create and edit modes: - **Edit mode**: Role must be assigned exclusively to this agent - **Create mode**: Role must not be assigned to anyone yet - โœ… Only call `assignRoleToAgent` when a valid agentId exists - โœ… Pass `undefined` instead of empty string for `fromAgentId` prop ### Role Hook (`useSaveDraftRoleToDB.ts`) - โœ… Removed navigation logic from the hook (better separation of concerns) - โœ… Removed `useNavigateSettings` and `SettingsPath` dependencies - โœ… Hook now only handles data persistence, not navigation ### Role Component (`SettingsRole.tsx`) - โœ… Added navigation after successful role creation in create mode - โœ… Navigation now handled by the component using the hook ### Agent Form (`SettingsAgentForm.tsx`) - โœ… Added `SettingsRolesQueryEffect` to ensure role data is loaded - โœ… Agent form handles its own navigation after save --- ## โœ… Testing Scenarios All these scenarios now work correctly: 1. โœ… Create new agent โ†’ Create role โ†’ Edit permissions โ†’ Save agent 2. โœ… Edit existing agent โ†’ Create role โ†’ Edit permissions โ†’ Save 3. โœ… Edit existing agent โ†’ Try to edit shared role (shows warning message) 4. โœ… Navigate to object-level permissions from agent form with proper breadcrumbs --- ## ๐Ÿ“ Technical Details ### Root Cause Analysis The main issue was that empty string was being treated differently in different checks: - `agentId &&` โ†’ evaluates to `false` (empty string is falsy) - `isDefined(agentId)` โ†’ returns `true` (empty string is defined) This inconsistency caused the role to appear as non-editable and attempted invalid API calls. ### Solution Used `isNonEmptyString()` from `@sniptt/guards` which properly checks both that the value is defined AND not empty, following codebase conventions. --- ## ๐ŸŽฏ Impact - Fixes critical bug preventing role permission configuration for new agents - Improves code quality with better separation of concerns - Makes navigation logic more predictable and maintainable - Follows codebase patterns and guidelines --- .../components/SettingsRolesContainer.tsx | 44 +- .../roles/components/SettingsRolesList.tsx | 128 +++--- .../components/SettingsRolePermissions.tsx | 4 +- ...RolePermissionsObjectLevelObjectPicker.tsx | 12 +- ...tingsRolePermissionsObjectLevelSection.tsx | 23 +- ...ingsRolePermissionsObjectLevelTableRow.tsx | 16 +- ...gsRolePermissionsObjectLevelObjectForm.tsx | 84 +++- .../roles/role/components/SettingsRole.tsx | 7 +- .../roles/role/hooks/useSaveDraftRoleToDB.ts | 423 +++++++++--------- .../pages/settings/ai/SettingsAgentForm.tsx | 64 ++- .../ai/components/SettingsAgentRoleTab.tsx | 205 ++++++--- .../applications/SettingsApplications.tsx | 30 +- .../data-model/SettingsObjectFieldTable.tsx | 162 +++---- .../roles/SettingsRoleAddObjectLevel.tsx | 52 ++- 14 files changed, 730 insertions(+), 524 deletions(-) diff --git a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesContainer.tsx b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesContainer.tsx index ba2e139694..e4765822ed 100644 --- a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesContainer.tsx +++ b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesContainer.tsx @@ -6,51 +6,18 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain import { SettingsRoleDefaultRole } from '@/settings/roles/components/SettingsRolesDefaultRole'; import { SettingsRolesList } from '@/settings/roles/components/SettingsRolesList'; -import { ROLES_LIST_TABS } from '@/settings/roles/constants/RolesListTabs'; import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector'; import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; -import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { Trans, useLingui } from '@lingui/react/macro'; import { useRecoilValue } from 'recoil'; -import { H3Title, IconUser, IconRobot, IconKey } from 'twenty-ui/display'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; -import { FeatureFlagKey } from '~/generated/graphql'; -import { TabList } from '@/ui/layout/tab-list/components/TabList'; +import { H3Title } from 'twenty-ui/display'; export const SettingsRolesContainer = () => { const { t } = useLingui(); - const activeTabId = useRecoilComponentValue( - activeTabIdComponentState, - ROLES_LIST_TABS.COMPONENT_INSTANCE_ID, - ); const settingsAllRoles = useRecoilValue(settingsAllRolesSelector); const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState); - const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); - - const tabs = [ - { - id: ROLES_LIST_TABS.TABS_IDS.USER_ROLES, - title: t`User Roles`, - Icon: IconUser, - }, - ...(isAiEnabled - ? [ - { - id: ROLES_LIST_TABS.TABS_IDS.AGENT_ROLES, - title: t`Agent Roles`, - Icon: IconRobot, - }, - ] - : []), - { - id: ROLES_LIST_TABS.TABS_IDS.API_KEY_ROLES, - title: t`API Key Roles`, - Icon: IconKey, - }, - ]; if (settingsRolesIsLoading && !settingsAllRoles) { return null; @@ -68,15 +35,8 @@ export const SettingsRolesContainer = () => { ]} > - - {activeTabId === ROLES_LIST_TABS.TABS_IDS.USER_ROLES && ( - - )} + ); diff --git a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesList.tsx b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesList.tsx index 3c494b3ff2..48f6ed6721 100644 --- a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesList.tsx +++ b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesList.tsx @@ -1,23 +1,31 @@ +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { Table } from '@/ui/layout/table/components/Table'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; import { SettingsRolesTableHeader } from '@/settings/roles/components/SettingsRolesTableHeader'; import { SettingsRolesTableRow } from '@/settings/roles/components/SettingsRolesTableRow'; -import { ROLES_LIST_TABS } from '@/settings/roles/constants/RolesListTabs'; import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector'; -import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; import { TableCell } from '@/ui/layout/table/components/TableCell'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useState } from 'react'; import { useRecoilValue } from 'recoil'; import { SettingsPath } from 'twenty-shared/types'; -import { H2Title, IconPlus, IconSearch } from 'twenty-ui/display'; +import { + H2Title, + IconFilter, + IconKey, + IconPlus, + IconRobot, + IconSearch, +} from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; +import { MenuItemToggle } from 'twenty-ui/navigation'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { sortByAscString } from '~/utils/array/sortByAscString'; -import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; -import { useState } from 'react'; const StyledCreateRoleSection = styled(Section)` border-top: 1px solid ${({ theme }) => theme.border.color.light}; @@ -36,19 +44,22 @@ const StyledNoRoles = styled(TableCell)` color: ${({ theme }) => theme.font.color.tertiary}; `; -const StyledSearchInput = styled(SettingsTextInput)` +const StyledSearchAndFilterContainer = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; margin-bottom: ${({ theme }) => theme.spacing(2)}; width: 100%; `; +const StyledSearchInput = styled(SettingsTextInput)` + flex: 1; +`; + export const SettingsRolesList = () => { const navigateSettings = useNavigateSettings(); - const activeTabId = useRecoilComponentValue( - activeTabIdComponentState, - ROLES_LIST_TABS.COMPONENT_INSTANCE_ID, - ); - const [searchTerm, setSearchTerm] = useState(''); + const [showAgentRoles, setShowAgentRoles] = useState(false); + const [showApiKeyRoles, setShowApiKeyRoles] = useState(false); const settingsAllRoles = useRecoilValue(settingsAllRolesSelector); @@ -57,52 +68,69 @@ export const SettingsRolesList = () => { ); const filteredRoles = sortedSettingsAllRoles.filter((role) => { - let matchesTab = false; + const matchesType = + role.canBeAssignedToUsers || + (showAgentRoles && role.canBeAssignedToAgents) || + (showApiKeyRoles && role.canBeAssignedToApiKeys); - switch (activeTabId) { - case ROLES_LIST_TABS.TABS_IDS.USER_ROLES: - matchesTab = role.canBeAssignedToUsers; - break; - case ROLES_LIST_TABS.TABS_IDS.AGENT_ROLES: - matchesTab = role.canBeAssignedToAgents; - break; - case ROLES_LIST_TABS.TABS_IDS.API_KEY_ROLES: - matchesTab = role.canBeAssignedToApiKeys; - break; - default: - matchesTab = role.canBeAssignedToUsers; - } + const matchesSearch = role.label + ?.toLowerCase() + .includes(searchTerm.toLowerCase()); - return ( - matchesTab && role.label?.toLowerCase().includes(searchTerm.toLowerCase()) - ); + return matchesType && matchesSearch; }); - const tabDescriptions: Record = { - [ROLES_LIST_TABS.TABS_IDS.USER_ROLES]: - t`Assign roles to specify each member's access permissions`, - [ROLES_LIST_TABS.TABS_IDS.AGENT_ROLES]: - t`Assign roles to specify each agent's access permissions`, - [ROLES_LIST_TABS.TABS_IDS.API_KEY_ROLES]: - t`Assign roles to specify each API key's access permissions`, - }; - - const description = - (activeTabId && tabDescriptions[activeTabId]) ?? - t`Assign roles to specify each member's access permissions`; - return (
- - - + + + + } + dropdownComponents={ + + + setShowAgentRoles(!showAgentRoles)} + toggled={showAgentRoles} + text={t`Agent roles`} + toggleSize="small" + /> + setShowApiKeyRoles(!showApiKeyRoles)} + toggled={showApiKeyRoles} + text={t`API key roles`} + toggleSize="small" + /> + + + } + /> + + diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissions.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissions.tsx index bf5a323e5f..97612c3baa 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissions.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissions.tsx @@ -13,11 +13,13 @@ const StyledRolePermissionsContainer = styled.div` type SettingsRolePermissionsProps = { roleId: string; isEditable: boolean; + fromAgentId?: string; }; export const SettingsRolePermissions = ({ roleId, isEditable, + fromAgentId, }: SettingsRolePermissionsProps) => { return ( @@ -27,7 +29,7 @@ export const SettingsRolePermissions = ({ /> { const theme = useTheme(); const navigate = useNavigateSettings(); + const [searchParams] = useSearchParams(); + const fromAgentId = searchParams.get('fromAgent'); const [searchFilter, setSearchFilter] = useState(''); const { objectMetadataItemsThatCanHavePermission } = @@ -64,10 +67,11 @@ export const SettingsRolePermissionsObjectLevelObjectPicker = ({ }; const handleSelectObjectMetadata = (objectMetadataId: string) => { - navigate(SettingsPath.RoleObjectLevel, { - roleId, - objectMetadataId, - }); + navigate( + SettingsPath.RoleObjectLevel, + { roleId, objectMetadataId }, + fromAgentId ? { fromAgent: fromAgentId } : undefined, + ); }; const { filterObjectMetadataItemsWithPermissionOverride } = diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelSection.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelSection.tsx index d36717b028..c9450ce938 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelSection.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelSection.tsx @@ -2,10 +2,12 @@ import { SettingsRolePermissionsObjectLevelTableHeader } from '@/settings/roles/ import { SettingsRolePermissionsObjectLevelTableRow } from '@/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableRow'; import { useFilterObjectMetadataItemsWithPermissionOverride } from '@/settings/roles/role-permissions/object-level-permissions/hooks/useFilterObjectWithPermissionOverride'; import { useObjectMetadataItemsThatCanHavePermission } from '@/settings/roles/role-permissions/object-level-permissions/hooks/useObjectMetadataItemsThatCanHavePermission'; +import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; import { Table } from '@/ui/layout/table/components/Table'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; +import { useRecoilValue } from 'recoil'; import { SettingsPath } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { IconPlus } from 'twenty-ui/display'; @@ -28,7 +30,7 @@ const StyledTableRows = styled.div` type SettingsRolePermissionsObjectLevelSectionProps = { roleId: string; - isEditable: boolean; + fromAgentId?: string; }; const StyledNoOverride = styled(TableCell)` @@ -37,10 +39,14 @@ const StyledNoOverride = styled(TableCell)` export const SettingsRolePermissionsObjectLevelSection = ({ roleId, - isEditable, + fromAgentId, }: SettingsRolePermissionsObjectLevelSectionProps) => { const navigateSettings = useNavigateSettings(); + const settingsDraftRole = useRecoilValue( + settingsDraftRoleFamilyState(roleId), + ); + const { objectMetadataItemsThatCanHavePermission } = useObjectMetadataItemsThatCanHavePermission(); @@ -59,9 +65,11 @@ export const SettingsRolePermissionsObjectLevelSection = ({ objectMetadataItemsThatCanHavePermission.length; const handleAddRule = () => { - navigateSettings(SettingsPath.RoleAddObjectLevel, { - roleId, - }); + navigateSettings( + SettingsPath.RoleAddObjectLevel, + { roleId }, + fromAgentId ? { fromAgent: fromAgentId } : undefined, + ); }; const hasObjectPermissions = @@ -82,6 +90,7 @@ export const SettingsRolePermissionsObjectLevelSection = ({ key={objectMetadataItem.id} objectMetadataItem={objectMetadataItem} roleId={roleId} + fromAgentId={fromAgentId} /> ), ) @@ -98,7 +107,9 @@ export const SettingsRolePermissionsObjectLevelSection = ({ title={t`Add rule`} variant="secondary" size="small" - disabled={!isEditable || allObjectsHaveSetPermission} + disabled={ + !settingsDraftRole.isEditable || allObjectsHaveSetPermission + } onClick={handleAddRule} /> diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableRow.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableRow.tsx index 1e914f6dba..a629a6860e 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableRow.tsx @@ -29,11 +29,13 @@ const StyledNameLabel = styled.div` type SettingsRolePermissionsObjectLevelTableRowProps = { objectMetadataItem: ObjectMetadataItem; roleId: string; + fromAgentId?: string; }; export const SettingsRolePermissionsObjectLevelTableRow = ({ objectMetadataItem, roleId, + fromAgentId, }: SettingsRolePermissionsObjectLevelTableRowProps) => { const { getIcon } = useIcons(); const theme = useTheme(); @@ -42,12 +44,18 @@ export const SettingsRolePermissionsObjectLevelTableRow = ({ const objectLabelPlural = objectMetadataItem.labelPlural; + const navigationPath = getSettingsPath(SettingsPath.RoleObjectLevel, { + roleId: roleId, + objectMetadataId: objectMetadataItem.id, + }); + + const navigationUrl = fromAgentId + ? `${navigationPath}?fromAgent=${fromAgentId}` + : navigationPath; + return ( diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx index 77e2ead965..36bd051c69 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx @@ -5,10 +5,12 @@ import { SettingsRolePermissionsObjectLevelObjectFormObjectLevel } from '@/setti import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { t } from '@lingui/core/macro'; +import { useSearchParams } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath } from 'twenty-shared/utils'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { Button } from 'twenty-ui/input'; +import { useFindOneAgentQuery } from '~/generated-metadata/graphql'; type SettingsRolePermissionsObjectLevelObjectFormProps = { roleId: string; @@ -19,10 +21,18 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({ roleId, objectMetadataId, }: SettingsRolePermissionsObjectLevelObjectFormProps) => { + const [searchParams] = useSearchParams(); + const fromAgentId = searchParams.get('fromAgent'); + const settingsDraftRole = useRecoilValue( settingsDraftRoleFamilyState(roleId), ); + const { data: agentData } = useFindOneAgentQuery({ + variables: { id: fromAgentId || '' }, + skip: !fromAgentId, + }); + const objectMetadata = useObjectMetadataItemById({ objectId: objectMetadataId, }); @@ -32,37 +42,65 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({ const objectLabelSingular = objectMetadataItem.labelSingular; const objectLabelPlural = objectMetadataItem.labelPlural; + const agent = agentData?.findOneAgent; + + const breadcrumbLinks = + fromAgentId && isDefined(agent) + ? [ + { + children: t`Workspace`, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: t`AI`, + href: getSettingsPath(SettingsPath.AI), + }, + { + children: agent.label, + href: getSettingsPath(SettingsPath.AIAgentDetail, { + agentId: agent.id, + }), + }, + { + children: t`Permissions ยท ${objectLabelSingular}`, + }, + ] + : [ + { + children: t`Workspace`, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: t`Roles`, + href: getSettingsPath(SettingsPath.Roles), + }, + { + children: settingsDraftRole.label, + href: getSettingsPath(SettingsPath.RoleDetail, { + roleId, + }), + }, + { + children: t`Permissions ยท ${objectLabelSingular}`, + }, + ]; + + const finishButtonPath = + fromAgentId && isDefined(agent) + ? getSettingsPath(SettingsPath.AIAgentDetail, { agentId: agent.id }) + : getSettingsPath(SettingsPath.RoleDetail, { roleId }); + return ( } > diff --git a/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx b/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx index 047bef5a57..3eacc4bc5b 100644 --- a/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx @@ -20,7 +20,7 @@ import { t } from '@lingui/core/macro'; import { useState } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { SettingsPath } from 'twenty-shared/types'; -import { isDefined, getSettingsPath } from 'twenty-shared/utils'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui/display'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; @@ -60,6 +60,11 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => { const { saveDraftRoleToDB } = useSaveDraftRoleToDB({ isCreateMode, roleId, + onSuccess: async (savedRoleId) => { + if (isCreateMode) { + navigateSettings(SettingsPath.RoleDetail, { roleId: savedRoleId }); + } + }, }); const isRoleEditable = settingsDraftRole.isEditable; diff --git a/packages/twenty-front/src/modules/settings/roles/role/hooks/useSaveDraftRoleToDB.ts b/packages/twenty-front/src/modules/settings/roles/role/hooks/useSaveDraftRoleToDB.ts index 174a25767f..05d4655df5 100644 --- a/packages/twenty-front/src/modules/settings/roles/role/hooks/useSaveDraftRoleToDB.ts +++ b/packages/twenty-front/src/modules/settings/roles/role/hooks/useSaveDraftRoleToDB.ts @@ -8,7 +8,6 @@ import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDr import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState'; import { getOperationName } from '@apollo/client/utilities'; import { useRecoilValue } from 'recoil'; -import { SettingsPath } from 'twenty-shared/types'; import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; import { useCreateOneRoleMutation, @@ -18,7 +17,6 @@ import { useUpsertPermissionFlagsMutation, } from '~/generated-metadata/graphql'; import { type Role } from '~/generated/graphql'; -import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { getDirtyFields } from '~/utils/getDirtyFields'; const ROLE_BASIC_KEYS: Array = [ @@ -39,9 +37,11 @@ const ROLE_BASIC_KEYS: Array = [ export const useSaveDraftRoleToDB = ({ roleId, isCreateMode, + onSuccess, }: { roleId: string; isCreateMode: boolean; + onSuccess?: (savedRoleId: string) => void | Promise; }) => { const [createRole] = useCreateOneRoleMutation(); const [updateRole] = useUpdateOneRoleMutation(); @@ -51,7 +51,6 @@ export const useSaveDraftRoleToDB = ({ const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId); const { addAgentsToRole } = useUpdateAgentRole(roleId); const { addApiKeysToRole } = useUpdateApiKeyRole(roleId); - const navigateSettings = useNavigateSettings(); const settingsPersistedRole = useRecoilValue( settingsPersistedRoleFamilyState(roleId), @@ -100,7 +99,7 @@ export const useSaveDraftRoleToDB = ({ const { removeFieldPermissionInDraftRole } = useRemoveFieldPermissionInDraftRole(); - const saveDraftRoleToDB = async () => { + const removeUselessFieldPermissions = () => { if ( isNonEmptyArray( fieldPermissionsThatShouldntBeCreatedBecauseTheyAreUseless, @@ -113,215 +112,233 @@ export const useSaveDraftRoleToDB = ({ ); } } + }; - if (isCreateMode) { - const { data } = await createRole({ + const createNewRole = async () => { + const { data } = await createRole({ + variables: { + createRoleInput: { + id: roleId, + label: settingsDraftRole.label, + description: settingsDraftRole.description, + icon: settingsDraftRole.icon, + canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings, + canAccessAllTools: settingsDraftRole.canAccessAllTools, + canReadAllObjectRecords: settingsDraftRole.canReadAllObjectRecords, + canUpdateAllObjectRecords: + settingsDraftRole.canUpdateAllObjectRecords, + canSoftDeleteAllObjectRecords: + settingsDraftRole.canSoftDeleteAllObjectRecords, + canDestroyAllObjectRecords: + settingsDraftRole.canDestroyAllObjectRecords, + canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers, + canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents, + canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys, + } satisfies Partial, + }, + refetchQueries: [getOperationName(GET_ROLES) ?? ''], + }); + + if (!data) { + return; + } + + const createdRoleId = data.createOneRole.id; + + await upsertRolePermissions(createdRoleId); + await assignEntitiesToRole(createdRoleId); + + if (isDefined(onSuccess)) { + await onSuccess(createdRoleId); + } + }; + + const updateExistingRole = async () => { + if (isDefined(dirtyFields.permissionFlags)) { + await upsertPermissionFlags({ variables: { - createRoleInput: { - id: roleId, - label: settingsDraftRole.label, - description: settingsDraftRole.description, - icon: settingsDraftRole.icon, - canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings, - canAccessAllTools: settingsDraftRole.canAccessAllTools, - canReadAllObjectRecords: settingsDraftRole.canReadAllObjectRecords, - canUpdateAllObjectRecords: - settingsDraftRole.canUpdateAllObjectRecords, - canSoftDeleteAllObjectRecords: - settingsDraftRole.canSoftDeleteAllObjectRecords, - canDestroyAllObjectRecords: - settingsDraftRole.canDestroyAllObjectRecords, - canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers, - canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents, - canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys, - } satisfies Partial, + upsertPermissionFlagsInput: { + roleId: roleId, + permissionFlagKeys: + settingsDraftRole.permissionFlags?.map( + (permissionFlag) => permissionFlag.flag, + ) ?? [], + }, }, refetchQueries: [getOperationName(GET_ROLES) ?? ''], }); + } - if (!data) { - return; - } - - if (isDefined(dirtyFields.permissionFlags)) { - await upsertPermissionFlags({ - variables: { - upsertPermissionFlagsInput: { - roleId: data.createOneRole.id, - permissionFlagKeys: - settingsDraftRole.permissionFlags?.map( - (permissionFlag) => permissionFlag.flag, - ) ?? [], + if (ROLE_BASIC_KEYS.some((key) => key in dirtyFields)) { + await updateRole({ + variables: { + updateRoleInput: { + id: roleId, + update: { + label: settingsDraftRole.label, + description: settingsDraftRole.description, + icon: settingsDraftRole.icon, + canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings, + canAccessAllTools: settingsDraftRole.canAccessAllTools, + canReadAllObjectRecords: + settingsDraftRole.canReadAllObjectRecords, + canUpdateAllObjectRecords: + settingsDraftRole.canUpdateAllObjectRecords, + canSoftDeleteAllObjectRecords: + settingsDraftRole.canSoftDeleteAllObjectRecords, + canDestroyAllObjectRecords: + settingsDraftRole.canDestroyAllObjectRecords, + canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers, + canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents, + canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys, }, }, - refetchQueries: [getOperationName(GET_ROLES) ?? ''], - }); - } - - if (isDefined(dirtyFields.objectPermissions)) { - await upsertObjectPermissions({ - variables: { - upsertObjectPermissionsInput: { - roleId: data.createOneRole.id, - objectPermissions: - settingsDraftRole.objectPermissions?.map( - (objectPermission) => ({ - objectMetadataId: objectPermission.objectMetadataId, - canReadObjectRecords: objectPermission.canReadObjectRecords, - canUpdateObjectRecords: - objectPermission.canUpdateObjectRecords, - canSoftDeleteObjectRecords: - objectPermission.canSoftDeleteObjectRecords, - canDestroyObjectRecords: - objectPermission.canDestroyObjectRecords, - }), - ) ?? [], - }, - }, - refetchQueries: [getOperationName(GET_ROLES) ?? ''], - }); - } - - if (isNonEmptyArray(fieldPermissionsToUpsert) === true) { - await upsertFieldPermissions({ - variables: { - upsertFieldPermissionsInput: { - roleId: data.createOneRole.id, - fieldPermissions: - fieldPermissionsToUpsert.map((fieldPermission) => ({ - objectMetadataId: fieldPermission.objectMetadataId, - fieldMetadataId: fieldPermission.fieldMetadataId, - canReadFieldValue: fieldPermission.canReadFieldValue, - canUpdateFieldValue: fieldPermission.canUpdateFieldValue, - })) ?? [], - }, - }, - refetchQueries: [getOperationName(GET_ROLES) ?? ''], - }); - } - - if ( - isDefined(dirtyFields.workspaceMembers) && - settingsDraftRole.canBeAssignedToUsers - ) { - await addWorkspaceMembersToRole({ - roleId: data.createOneRole.id, - workspaceMemberIds: settingsDraftRole.workspaceMembers.map( - (member) => member.id, - ), - }); - } - - if ( - isDefined(dirtyFields.agents) && - settingsDraftRole.canBeAssignedToAgents - ) { - await addAgentsToRole({ - roleId: data.createOneRole.id, - agentIds: settingsDraftRole.agents.map((agent) => agent.id), - }); - } - - if ( - isDefined(dirtyFields.apiKeys) && - settingsDraftRole.canBeAssignedToApiKeys - ) { - await addApiKeysToRole({ - roleId: data.createOneRole.id, - apiKeyIds: settingsDraftRole.apiKeys.map((apiKey) => apiKey.id), - }); - } - - navigateSettings(SettingsPath.RoleDetail, { - roleId: data.createOneRole.id, + }, + refetchQueries: [getOperationName(GET_ROLES) ?? ''], }); + } + + if (isDefined(dirtyFields.objectPermissions)) { + await upsertObjectPermissions({ + variables: { + upsertObjectPermissionsInput: { + roleId: roleId, + objectPermissions: + settingsDraftRole.objectPermissions?.map((objectPermission) => ({ + objectMetadataId: objectPermission.objectMetadataId, + canReadObjectRecords: objectPermission.canReadObjectRecords, + canUpdateObjectRecords: objectPermission.canUpdateObjectRecords, + canSoftDeleteObjectRecords: + objectPermission.canSoftDeleteObjectRecords, + canDestroyObjectRecords: + objectPermission.canDestroyObjectRecords, + })) ?? [], + }, + }, + refetchQueries: [getOperationName(GET_ROLES) ?? ''], + }); + } + + if (isNonEmptyArray(fieldPermissionsToUpsert) === true) { + await upsertFieldPermissions({ + variables: { + upsertFieldPermissionsInput: { + roleId: roleId, + fieldPermissions: + fieldPermissionsToUpsert.map((fieldPermission) => ({ + objectMetadataId: fieldPermission.objectMetadataId, + fieldMetadataId: fieldPermission.fieldMetadataId, + canReadFieldValue: fieldPermission.canReadFieldValue, + canUpdateFieldValue: fieldPermission.canUpdateFieldValue, + })) ?? [], + }, + }, + refetchQueries: [getOperationName(GET_ROLES) ?? ''], + }); + } + }; + + const upsertRolePermissions = async (targetRoleId: string) => { + if (isDefined(dirtyFields.permissionFlags)) { + await upsertPermissionFlags({ + variables: { + upsertPermissionFlagsInput: { + roleId: targetRoleId, + permissionFlagKeys: + settingsDraftRole.permissionFlags?.map( + (permissionFlag) => permissionFlag.flag, + ) ?? [], + }, + }, + refetchQueries: [getOperationName(GET_ROLES) ?? ''], + }); + } + + if (isDefined(dirtyFields.objectPermissions)) { + await upsertObjectPermissions({ + variables: { + upsertObjectPermissionsInput: { + roleId: targetRoleId, + objectPermissions: + settingsDraftRole.objectPermissions?.map((objectPermission) => ({ + objectMetadataId: objectPermission.objectMetadataId, + canReadObjectRecords: objectPermission.canReadObjectRecords, + canUpdateObjectRecords: objectPermission.canUpdateObjectRecords, + canSoftDeleteObjectRecords: + objectPermission.canSoftDeleteObjectRecords, + canDestroyObjectRecords: + objectPermission.canDestroyObjectRecords, + })) ?? [], + }, + }, + refetchQueries: [getOperationName(GET_ROLES) ?? ''], + }); + } + + if (isNonEmptyArray(fieldPermissionsToUpsert) === true) { + await upsertFieldPermissions({ + variables: { + upsertFieldPermissionsInput: { + roleId: targetRoleId, + fieldPermissions: + fieldPermissionsToUpsert.map((fieldPermission) => ({ + objectMetadataId: fieldPermission.objectMetadataId, + fieldMetadataId: fieldPermission.fieldMetadataId, + canReadFieldValue: fieldPermission.canReadFieldValue, + canUpdateFieldValue: fieldPermission.canUpdateFieldValue, + })) ?? [], + }, + }, + refetchQueries: [getOperationName(GET_ROLES) ?? ''], + }); + } + }; + + const assignEntitiesToRole = async (targetRoleId: string) => { + if ( + isDefined(dirtyFields.workspaceMembers) && + settingsDraftRole.canBeAssignedToUsers + ) { + await addWorkspaceMembersToRole({ + roleId: targetRoleId, + workspaceMemberIds: settingsDraftRole.workspaceMembers.map( + (member) => member.id, + ), + }); + } + + if ( + isDefined(dirtyFields.agents) && + settingsDraftRole.canBeAssignedToAgents + ) { + await addAgentsToRole({ + roleId: targetRoleId, + agentIds: settingsDraftRole.agents.map((agent) => agent.id), + }); + } + + if ( + isDefined(dirtyFields.apiKeys) && + settingsDraftRole.canBeAssignedToApiKeys + ) { + await addApiKeysToRole({ + roleId: targetRoleId, + apiKeyIds: settingsDraftRole.apiKeys.map((apiKey) => apiKey.id), + }); + } + + if (isDefined(onSuccess)) { + await onSuccess(roleId); + } + }; + + const saveDraftRoleToDB = async () => { + removeUselessFieldPermissions(); + + if (isCreateMode) { + await createNewRole(); } else { - if (isDefined(dirtyFields.permissionFlags)) { - await upsertPermissionFlags({ - variables: { - upsertPermissionFlagsInput: { - roleId: roleId, - permissionFlagKeys: - settingsDraftRole.permissionFlags?.map( - (permissionFlag) => permissionFlag.flag, - ) ?? [], - }, - }, - refetchQueries: [getOperationName(GET_ROLES) ?? ''], - }); - } - - if (ROLE_BASIC_KEYS.some((key) => key in dirtyFields)) { - await updateRole({ - variables: { - updateRoleInput: { - id: roleId, - update: { - label: settingsDraftRole.label, - description: settingsDraftRole.description, - icon: settingsDraftRole.icon, - canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings, - canAccessAllTools: settingsDraftRole.canAccessAllTools, - canReadAllObjectRecords: - settingsDraftRole.canReadAllObjectRecords, - canUpdateAllObjectRecords: - settingsDraftRole.canUpdateAllObjectRecords, - canSoftDeleteAllObjectRecords: - settingsDraftRole.canSoftDeleteAllObjectRecords, - canDestroyAllObjectRecords: - settingsDraftRole.canDestroyAllObjectRecords, - canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers, - canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents, - canBeAssignedToApiKeys: - settingsDraftRole.canBeAssignedToApiKeys, - }, - }, - }, - refetchQueries: [getOperationName(GET_ROLES) ?? ''], - }); - } - - if (isDefined(dirtyFields.objectPermissions)) { - await upsertObjectPermissions({ - variables: { - upsertObjectPermissionsInput: { - roleId: roleId, - objectPermissions: - settingsDraftRole.objectPermissions?.map( - (objectPermission) => ({ - objectMetadataId: objectPermission.objectMetadataId, - canReadObjectRecords: objectPermission.canReadObjectRecords, - canUpdateObjectRecords: - objectPermission.canUpdateObjectRecords, - canSoftDeleteObjectRecords: - objectPermission.canSoftDeleteObjectRecords, - canDestroyObjectRecords: - objectPermission.canDestroyObjectRecords, - }), - ) ?? [], - }, - }, - refetchQueries: [getOperationName(GET_ROLES) ?? ''], - }); - } - - if (isNonEmptyArray(fieldPermissionsToUpsert) === true) { - await upsertFieldPermissions({ - variables: { - upsertFieldPermissionsInput: { - roleId: roleId, - fieldPermissions: - fieldPermissionsToUpsert.map((fieldPermission) => ({ - objectMetadataId: fieldPermission.objectMetadataId, - fieldMetadataId: fieldPermission.fieldMetadataId, - canReadFieldValue: fieldPermission.canReadFieldValue, - canUpdateFieldValue: fieldPermission.canUpdateFieldValue, - })) ?? [], - }, - }, - refetchQueries: [getOperationName(GET_ROLES) ?? ''], - }); - } + await updateExistingRole(); } }; diff --git a/packages/twenty-front/src/pages/settings/ai/SettingsAgentForm.tsx b/packages/twenty-front/src/pages/settings/ai/SettingsAgentForm.tsx index 57f9de8882..57a3c1c655 100644 --- a/packages/twenty-front/src/pages/settings/ai/SettingsAgentForm.tsx +++ b/packages/twenty-front/src/pages/settings/ai/SettingsAgentForm.tsx @@ -4,6 +4,10 @@ import { useParams } from 'react-router-dom'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect'; +import { useSaveDraftRoleToDB } from '@/settings/roles/role/hooks/useSaveDraftRoleToDB'; +import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; +import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { TabList } from '@/ui/layout/tab-list/components/TabList'; @@ -12,7 +16,7 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho import { t } from '@lingui/core/macro'; import { AppPath, SettingsPath } from 'twenty-shared/types'; import { getSettingsPath, isDefined } from 'twenty-shared/utils'; -import { H2Title, IconLock, IconSettings } from 'twenty-ui/display'; +import { IconLock, IconSettings } from 'twenty-ui/display'; import { Section } from 'twenty-ui/layout'; import { type CreateAgentInput, @@ -24,6 +28,8 @@ import { useNavigateApp } from '~/hooks/useNavigateApp'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useState } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { SettingsAgentDetailSkeletonLoader } from './components/SettingsAgentDetailSkeletonLoader'; import { SettingsAgentRoleTab } from './components/SettingsAgentRoleTab'; import { SettingsAgentSettingsTab } from './components/SettingsAgentSettingsTab'; @@ -107,6 +113,22 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => { const agent = data?.findOneAgent; + const [settingsDraftRole, setSettingsDraftRole] = useRecoilState( + settingsDraftRoleFamilyState(formValues.role || ''), + ); + const settingsPersistedRole = useRecoilValue( + settingsPersistedRoleFamilyState(formValues.role || ''), + ); + + const { saveDraftRoleToDB } = useSaveDraftRoleToDB({ + roleId: formValues.role || '', + isCreateMode: false, + }); + + const isRoleDirty = + isDefined(formValues.role) && + !isDeeplyEqual(settingsDraftRole, settingsPersistedRole); + if (!isCreateMode && !loading && !agent) { return null; } @@ -138,6 +160,26 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => { setIsSubmitting(true); try { + if (isRoleDirty && isDefined(formValues.role)) { + try { + await saveDraftRoleToDB(); + } catch (error) { + if (error instanceof ApolloError) { + enqueueErrorSnackBar({ + apolloError: error, + }); + } else { + const errorMessage = + error instanceof Error ? error.message : String(error); + enqueueErrorSnackBar({ + message: t`Failed to save role permissions: ${errorMessage}`, + }); + } + setIsSubmitting(false); + return; + } + } + if (isCreateMode) { const input: CreateAgentInput = { name: formValues.name, @@ -187,15 +229,21 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => { } }; + const handleCancel = () => { + resetForm(); + + if (isRoleDirty && isDefined(settingsPersistedRole)) { + setSettingsDraftRole(settingsPersistedRole); + } + + navigate(SettingsPath.AI); + }; + const title = !isCreateMode ? loading ? t`Agent` : agent?.label : t`New Agent`; - const pageTitle = !isCreateMode ? t`Edit Agent` : t`New Agent`; - const pageDescription = !isCreateMode - ? t`Update agent information` - : t`Create a new AI agent`; const breadcrumbText = !isCreateMode ? loading ? t`Agent` @@ -210,6 +258,8 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => { formValues={formValues} onFieldChange={handleFieldChange} disabled={isReadonlyMode || (isEditMode ? !agent?.isCustom : false)} + agentId={agentId} + agentLabel={formValues.label} /> ); @@ -229,13 +279,14 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => { return ( <> + navigate(SettingsPath.AI)} + onCancel={handleCancel} isSaveDisabled={!canSave} isLoading={isSubmitting} isCancelDisabled={isSubmitting} @@ -253,7 +304,6 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => { >
- {isEditMode && loading ? ( ) : ( diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentRoleTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentRoleTab.tsx index 3f2130dca5..d8d6abba95 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentRoleTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentRoleTab.tsx @@ -1,33 +1,29 @@ +import { getOperationName } from '@apollo/client/utilities'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { useSetRecoilState } from 'recoil'; +import { v4 } from 'uuid'; +import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery'; import { SettingsRolePermissions } from '@/settings/roles/role-permissions/components/SettingsRolePermissions'; -import { Select } from '@/ui/input/components/Select'; -import { SettingsPath } from 'twenty-shared/types'; +import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; +import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; -import { - H1Title, - H1TitleFontColor, - H2Title, - IconArrowUpRight, - IconUser, - useIcons, -} from 'twenty-ui/display'; +import { H2Title, IconPlus } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; -import { useGetRolesQuery } from '~/generated-metadata/graphql'; -import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { + useAssignRoleToAgentMutation, + useCreateOneRoleMutation, + useGetRolesQuery, +} from '~/generated-metadata/graphql'; import { type SettingsAIAgentFormValues } from '../hooks/useSettingsAgentFormState'; -const StyledRoleContainer = styled.div` - align-items: flex-end; - display: flex; - gap: ${({ theme }) => theme.spacing(2)}; - margin-bottom: ${({ theme }) => theme.spacing(8)}; -`; - -const StyledRoleSelector = styled.div` - flex: 1; +const StyledWarningText = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; + margin-bottom: ${({ theme }) => theme.spacing(4)}; `; type SettingsAgentRoleTabProps = { @@ -37,81 +33,146 @@ type SettingsAgentRoleTabProps = { value: SettingsAIAgentFormValues[keyof SettingsAIAgentFormValues], ) => void; disabled: boolean; + agentId?: string; + agentLabel: string; }; export const SettingsAgentRoleTab = ({ formValues, onFieldChange, disabled, + agentId, + agentLabel, }: SettingsAgentRoleTabProps) => { const { t } = useLingui(); - const { getIcon } = useIcons(); - const navigateSettings = useNavigateSettings(); + const [isCreatingRole, setIsCreatingRole] = useState(false); const { data: rolesData } = useGetRolesQuery(); - - const rolesOptions = [ - { - label: t`None`, - value: null, - Icon: IconUser, - }, - ...(rolesData?.getRoles - ?.filter((role) => role.canBeAssignedToAgents) - .map((role) => ({ - label: role.label, - value: role.id, - Icon: getIcon(role.icon) ?? IconUser, - })) || []), - ]; + const [createRole] = useCreateOneRoleMutation(); + const [assignRoleToAgent] = useAssignRoleToAgentMutation(); + const setSettingsDraftRole = useSetRecoilState( + settingsDraftRoleFamilyState(formValues.role || ''), + ); const selectedRole = rolesData?.getRoles?.find( (role) => role.id === formValues.role, ); - const handleOpenRole = () => { - if (isDefined(selectedRole)) { - navigateSettings(SettingsPath.RoleDetail, { roleId: selectedRole.id }); + const hasValidAgentId = isNonEmptyString(agentId); + + const isRoleShared = selectedRole + ? (selectedRole.workspaceMembers?.length || 0) + + (selectedRole.agents?.length || 0) + + (selectedRole.apiKeys?.length || 0) > + 1 + : false; + + // Role is only editable if it's not shared and either: + // 1. Assigned exclusively to this agent (edit mode) + // 2. Not yet assigned to anyone (create mode) + const isRoleExclusiveToThisAgent = + !isRoleShared && + selectedRole && + (selectedRole.workspaceMembers?.length || 0) === 0 && + (selectedRole.apiKeys?.length || 0) === 0 && + (hasValidAgentId + ? selectedRole.agents?.length === 1 && + selectedRole.agents[0].id === agentId + : (selectedRole.agents?.length || 0) === 0); + + const handleCreateRole = async () => { + setIsCreatingRole(true); + try { + const roleId = v4(); + const roleName = `${agentLabel} Agent Role`; + + const { data } = await createRole({ + variables: { + createRoleInput: { + id: roleId, + label: roleName, + description: t`Role for ${agentLabel} agent`, + icon: 'IconLock', + canUpdateAllSettings: false, + canAccessAllTools: false, + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canBeAssignedToUsers: false, + canBeAssignedToAgents: true, + canBeAssignedToApiKeys: false, + }, + }, + refetchQueries: [getOperationName(GET_ROLES) ?? ''], + }); + + if (isDefined(data?.createOneRole)) { + onFieldChange('role', data.createOneRole.id); + + if (hasValidAgentId) { + await assignRoleToAgent({ + variables: { + agentId, + roleId: data.createOneRole.id, + }, + refetchQueries: ['GetRoles'], + }); + } + + setSettingsDraftRole({ + ...data.createOneRole, + workspaceMembers: [], + agents: [], + apiKeys: [], + objectPermissions: [], + fieldPermissions: [], + permissionFlags: [], + }); + } + } finally { + setIsCreatingRole(false); } }; + const isRoleEditable = + Boolean(selectedRole?.isEditable) && + !disabled && + Boolean(isRoleExclusiveToThisAgent); + return (
- - - -
{tableMetadata.fields.map((item) => ( @@ -198,33 +211,20 @@ export const SettingsObjectFieldTable = ({ ))} - {isNonEmptyArray(filteredActiveItems) && ( - - {filteredActiveItems.map((objectSettingsDetailItem) => ( - - ))} - - )} - {isNonEmptyArray(filteredDisabledItems) && ( - - {filteredDisabledItems.map((objectSettingsDetailItem) => ( - - ))} - - )} + {filteredItems.map((objectSettingsDetailItem) => { + const status = objectSettingsDetailItem.fieldMetadataItem.isActive + ? 'active' + : 'disabled'; + + return ( + + ); + })}
); diff --git a/packages/twenty-front/src/pages/settings/roles/SettingsRoleAddObjectLevel.tsx b/packages/twenty-front/src/pages/settings/roles/SettingsRoleAddObjectLevel.tsx index 76e40a64a1..5166e8f270 100644 --- a/packages/twenty-front/src/pages/settings/roles/SettingsRoleAddObjectLevel.tsx +++ b/packages/twenty-front/src/pages/settings/roles/SettingsRoleAddObjectLevel.tsx @@ -4,27 +4,54 @@ import { SettingsRolePermissionsObjectLevelObjectPicker } from '@/settings/roles import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { t } from '@lingui/core/macro'; -import { Navigate, useParams } from 'react-router-dom'; +import { Navigate, useParams, useSearchParams } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath } from 'twenty-shared/utils'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { useFindOneAgentQuery } from '~/generated-metadata/graphql'; export const SettingsRoleAddObjectLevel = () => { const { roleId } = useParams(); + const [searchParams] = useSearchParams(); + const fromAgentId = searchParams.get('fromAgent'); + const settingsDraftRole = useRecoilValue( settingsDraftRoleFamilyState(roleId ?? ''), ); + const { data: agentData } = useFindOneAgentQuery({ + variables: { id: fromAgentId || '' }, + skip: !fromAgentId, + }); + + const agent = agentData?.findOneAgent; + if (!roleId) { return ; } - return ( - <> - - { children: t`Add object permission`, href: getSettingsPath(SettingsPath.RoleAddObjectLevel, { roleId }), }, - ]} + ]; + + return ( + <> + +