diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx index 94a53549e6..d1738a6e7e 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx @@ -1,34 +1,13 @@ import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; import { SettingsRolePermissionsSettingsTableHeader } from '@/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsTableHeader'; import { SettingsRolePermissionsSettingsTableRow } from '@/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsTableRow'; -import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission'; +import { useSettingsRolePermissionFlagConfig } from '@/settings/roles/role-permissions/permission-flags/hooks/useSettingsRolePermissionFlagConfig'; import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; -import { useMemo } from 'react'; import { useRecoilState } from 'recoil'; -import { - H2Title, - IconApps, - IconCode, - IconCreditCard, - IconHierarchy, - IconKey, - IconLayoutSidebarRightCollapse, - IconLockOpen, - IconSettings, - IconSettingsAutomation, - IconShield, - IconSparkles, - IconSpy, - IconUsers, -} from 'twenty-ui/display'; +import { H2Title, IconSettings } from 'twenty-ui/display'; import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout'; -import { - FeatureFlagKey, - PermissionFlagType, -} from '~/generated-metadata/graphql'; const StyledTable = styled.div` border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; @@ -56,174 +35,13 @@ export const SettingsRolePermissionsSettingsSection = ({ settingsDraftRoleFamilyState(roleId), ); - const isAIEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); - const isApplicationEnabled = useIsFeatureEnabled( - FeatureFlagKey.IS_APPLICATION_ENABLED, - ); - - const settingsPermissionsConfig = useMemo(() => { - const allPermissions: SettingsRolePermissionsSettingPermission[] = [ - { - key: PermissionFlagType.API_KEYS_AND_WEBHOOKS, - name: t`API Keys & Webhooks`, - description: t`Manage API keys and webhooks`, - Icon: IconCode, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.WORKSPACE, - name: t`Workspace`, - description: t`Set global workspace preferences`, - Icon: IconSettings, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.WORKSPACE_MEMBERS, - name: t`Users`, - description: t`Add or remove users`, - Icon: IconUsers, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.ROLES, - name: t`Roles`, - description: t`Define user roles and access levels`, - Icon: IconLockOpen, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.DATA_MODEL, - name: t`Data Model`, - description: t`Edit data structure and fields`, - Icon: IconHierarchy, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.SECURITY, - name: t`Security`, - description: t`Manage security policies`, - Icon: IconKey, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.WORKFLOWS, - name: t`Workflows`, - description: t`Manage workflows`, - Icon: IconSettingsAutomation, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.SSO_BYPASS, - name: t`SSO Bypass`, - description: t`Enable bypass options`, - Icon: IconShield, - isRelevantForAgents: false, - isRelevantForApiKeys: false, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.IMPERSONATE, - name: t`Impersonate`, - description: t`Impersonate workspace users`, - Icon: IconSpy, - isRelevantForAgents: false, - isRelevantForApiKeys: false, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.APPLICATIONS, - name: t`Applications`, - description: t`Install and manage applications`, - Icon: IconApps, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.LAYOUTS, - name: t`Layouts`, - description: t`Customize page layouts and UI structure`, - Icon: IconLayoutSidebarRightCollapse, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.BILLING, - name: t`Billing`, - description: t`Manage billing and subscriptions`, - Icon: IconCreditCard, - isRelevantForAgents: false, - isRelevantForApiKeys: false, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.AI_SETTINGS, - name: t`AI`, - description: t`Create and configure AI agents`, - Icon: IconSparkles, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - ]; - - return allPermissions.filter((permission) => { - if (permission.key === PermissionFlagType.AI_SETTINGS && !isAIEnabled) { - return false; - } - if ( - permission.key === PermissionFlagType.APPLICATIONS && - !isApplicationEnabled - ) { - return false; - } - - // Filter based on role assignment capabilities - const canBeAssignedOnlyToAgents = - settingsDraftRole.canBeAssignedToAgents && - !settingsDraftRole.canBeAssignedToUsers && - !settingsDraftRole.canBeAssignedToApiKeys; - - const canBeAssignedOnlyToApiKeys = - settingsDraftRole.canBeAssignedToApiKeys && - !settingsDraftRole.canBeAssignedToUsers && - !settingsDraftRole.canBeAssignedToAgents; - - const canBeAssignedOnlyToUsers = - settingsDraftRole.canBeAssignedToUsers && - !settingsDraftRole.canBeAssignedToAgents && - !settingsDraftRole.canBeAssignedToApiKeys; - - if (canBeAssignedOnlyToAgents && !permission.isRelevantForAgents) { - return false; - } - - if (canBeAssignedOnlyToApiKeys && !permission.isRelevantForApiKeys) { - return false; - } - - if (canBeAssignedOnlyToUsers && !permission.isRelevantForUsers) { - return false; - } - - return true; - }); - }, [isAIEnabled, isApplicationEnabled, settingsDraftRole]); + const settingsPermissionsConfig = useSettingsRolePermissionFlagConfig({ + assignmentCapabilities: { + canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents, + canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers, + canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys, + }, + }); const shouldShowAllAccessToggle = !settingsDraftRole.canBeAssignedToAgents || diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx index b84b103c53..738ab91236 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx @@ -1,32 +1,14 @@ import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; import { SettingsRolePermissionsSettingsTableHeader } from '@/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsTableHeader'; import { SettingsRolePermissionsSettingsTableRow } from '@/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsTableRow'; -import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission'; +import { useActionRolePermissionFlagConfig } from '@/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig'; import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; import { useRecoilState } from 'recoil'; -import { - H2Title, - IconApi, - IconAt, - IconDownload, - IconFileExport, - IconFileImport, - IconFileUpload, - IconMail, - IconSparkles, - IconTable, - IconTool, - IconUser, -} from 'twenty-ui/display'; +import { H2Title, IconTool } from 'twenty-ui/display'; import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout'; -import { - FeatureFlagKey, - PermissionFlagType, -} from '~/generated-metadata/graphql'; const StyledTable = styled.div` border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; @@ -54,144 +36,12 @@ export const SettingsRolePermissionsToolSection = ({ settingsDraftRoleFamilyState(roleId), ); - const isAIEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); - - const allPermissions: SettingsRolePermissionsSettingPermission[] = [ - { - key: PermissionFlagType.AI, - name: t`Ask AI`, - description: t`Chat with AI agents and use AI features`, - Icon: IconSparkles, - isToolPermission: true, - isRelevantForAgents: false, - isRelevantForApiKeys: true, - isRelevantForUsers: true, + const toolPermissionsConfig = useActionRolePermissionFlagConfig({ + assignmentCapabilities: { + canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents, + canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers, + canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys, }, - { - key: PermissionFlagType.UPLOAD_FILE, - name: t`Upload Files`, - description: t`Allow uploading files and attachments`, - Icon: IconFileUpload, - isToolPermission: true, - isRelevantForAgents: false, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.DOWNLOAD_FILE, - name: t`Download Files`, - description: t`Allow downloading files and attachments`, - Icon: IconDownload, - isToolPermission: true, - isRelevantForAgents: false, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.SEND_EMAIL_TOOL, - name: t`Send Email`, - description: t`Send emails via connected accounts`, - Icon: IconMail, - isToolPermission: true, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.HTTP_REQUEST_TOOL, - name: t`HTTP Request`, - description: t`Make HTTP requests to external APIs`, - Icon: IconApi, - isToolPermission: true, - isRelevantForAgents: true, - isRelevantForApiKeys: false, - isRelevantForUsers: false, - }, - { - key: PermissionFlagType.IMPORT_CSV, - name: t`Import CSV`, - description: t`Allow importing data from CSV files`, - Icon: IconFileImport, - isToolPermission: true, - isRelevantForAgents: false, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.EXPORT_CSV, - name: t`Export CSV`, - description: t`Allow exporting data to CSV files`, - Icon: IconFileExport, - isToolPermission: true, - isRelevantForAgents: false, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.CONNECTED_ACCOUNTS, - name: t`Sync Account`, - description: t`Sync email and calendar accounts`, - Icon: IconAt, - isToolPermission: true, - isRelevantForAgents: false, - isRelevantForApiKeys: false, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.PROFILE_INFORMATION, - name: t`Edit Profile`, - description: t`Edit own profile information`, - Icon: IconUser, - isToolPermission: true, - isRelevantForAgents: false, - isRelevantForApiKeys: false, - isRelevantForUsers: true, - }, - { - key: PermissionFlagType.VIEWS, - name: t`Manage Views`, - description: t`Create, edit, and delete workspace views`, - Icon: IconTable, - isToolPermission: true, - isRelevantForAgents: true, - isRelevantForApiKeys: true, - isRelevantForUsers: true, - }, - ]; - - const toolPermissionsConfig = allPermissions.filter((permission) => { - if (permission.key === PermissionFlagType.AI && !isAIEnabled) { - return false; - } - - const canBeAssignedOnlyToAgents = - settingsDraftRole.canBeAssignedToAgents && - !settingsDraftRole.canBeAssignedToUsers && - !settingsDraftRole.canBeAssignedToApiKeys; - - const canBeAssignedOnlyToApiKeys = - settingsDraftRole.canBeAssignedToApiKeys && - !settingsDraftRole.canBeAssignedToUsers && - !settingsDraftRole.canBeAssignedToAgents; - - const canBeAssignedOnlyToUsers = - settingsDraftRole.canBeAssignedToUsers && - !settingsDraftRole.canBeAssignedToAgents && - !settingsDraftRole.canBeAssignedToApiKeys; - - if (canBeAssignedOnlyToAgents && !permission.isRelevantForAgents) { - return false; - } - - if (canBeAssignedOnlyToApiKeys && !permission.isRelevantForApiKeys) { - return false; - } - - if (canBeAssignedOnlyToUsers && !permission.isRelevantForUsers) { - return false; - } - - return true; }); const shouldShowAllAccessToggle = diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig.ts b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig.ts new file mode 100644 index 0000000000..1499533584 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig.ts @@ -0,0 +1,184 @@ +import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { t } from '@lingui/core/macro'; +import { useMemo } from 'react'; +import { + IconApi, + IconAt, + IconDownload, + IconFileExport, + IconFileImport, + IconFileUpload, + IconMail, + IconSparkles, + IconTable, + IconUser, +} from 'twenty-ui/display'; +import { + FeatureFlagKey, + PermissionFlagType, +} from '~/generated-metadata/graphql'; + +type UseActionRolePermissionFlagConfigParams = { + assignmentCapabilities?: { + canBeAssignedToAgents?: boolean; + canBeAssignedToUsers?: boolean; + canBeAssignedToApiKeys?: boolean; + }; +}; + +export const useActionRolePermissionFlagConfig = ({ + assignmentCapabilities, +}: UseActionRolePermissionFlagConfigParams = {}): SettingsRolePermissionsSettingPermission[] => { + const isAIEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); + + const { + canBeAssignedToAgents = false, + canBeAssignedToUsers = false, + canBeAssignedToApiKeys = false, + } = assignmentCapabilities ?? {}; + + const hasAssignmentCapabilities = assignmentCapabilities !== undefined; + + return useMemo(() => { + const allPermissions: SettingsRolePermissionsSettingPermission[] = [ + { + key: PermissionFlagType.AI, + name: t`Ask AI`, + description: t`Chat with AI agents and use AI features`, + Icon: IconSparkles, + isToolPermission: true, + isRelevantForAgents: false, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.UPLOAD_FILE, + name: t`Upload Files`, + description: t`Allow uploading files and attachments`, + Icon: IconFileUpload, + isToolPermission: true, + isRelevantForAgents: false, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.DOWNLOAD_FILE, + name: t`Download Files`, + description: t`Allow downloading files and attachments`, + Icon: IconDownload, + isToolPermission: true, + isRelevantForAgents: false, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.SEND_EMAIL_TOOL, + name: t`Send Email`, + description: t`Send emails via connected accounts`, + Icon: IconMail, + isToolPermission: true, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.HTTP_REQUEST_TOOL, + name: t`HTTP Request`, + description: t`Make HTTP requests to external APIs`, + Icon: IconApi, + isToolPermission: true, + isRelevantForAgents: true, + isRelevantForApiKeys: false, + isRelevantForUsers: false, + }, + { + key: PermissionFlagType.IMPORT_CSV, + name: t`Import CSV`, + description: t`Allow importing data from CSV files`, + Icon: IconFileImport, + isToolPermission: true, + isRelevantForAgents: false, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.EXPORT_CSV, + name: t`Export CSV`, + description: t`Allow exporting data to CSV files`, + Icon: IconFileExport, + isToolPermission: true, + isRelevantForAgents: false, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.CONNECTED_ACCOUNTS, + name: t`Sync Account`, + description: t`Sync email and calendar accounts`, + Icon: IconAt, + isToolPermission: true, + isRelevantForAgents: false, + isRelevantForApiKeys: false, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.PROFILE_INFORMATION, + name: t`Edit Profile`, + description: t`Edit own profile information`, + Icon: IconUser, + isToolPermission: true, + isRelevantForAgents: false, + isRelevantForApiKeys: false, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.VIEWS, + name: t`Manage Views`, + description: t`Create, edit, and delete workspace views`, + Icon: IconTable, + isToolPermission: true, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + ]; + + const canBeAssignedOnlyToAgents = + canBeAssignedToAgents && !canBeAssignedToUsers && !canBeAssignedToApiKeys; + + const canBeAssignedOnlyToApiKeys = + canBeAssignedToApiKeys && !canBeAssignedToUsers && !canBeAssignedToAgents; + + const canBeAssignedOnlyToUsers = + canBeAssignedToUsers && !canBeAssignedToAgents && !canBeAssignedToApiKeys; + + return allPermissions.filter((permission) => { + if (permission.key === PermissionFlagType.AI && !isAIEnabled) { + return false; + } + + if (hasAssignmentCapabilities) { + if (canBeAssignedOnlyToAgents && !permission.isRelevantForAgents) { + return false; + } + + if (canBeAssignedOnlyToApiKeys && !permission.isRelevantForApiKeys) { + return false; + } + + if (canBeAssignedOnlyToUsers && !permission.isRelevantForUsers) { + return false; + } + } + + return true; + }); + }, [ + hasAssignmentCapabilities, + canBeAssignedToAgents, + canBeAssignedToUsers, + canBeAssignedToApiKeys, + isAIEnabled, + ]); +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/hooks/useSettingsRolePermissionFlagConfig.ts b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/hooks/useSettingsRolePermissionFlagConfig.ts new file mode 100644 index 0000000000..80c59860a2 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/hooks/useSettingsRolePermissionFlagConfig.ts @@ -0,0 +1,214 @@ +import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { t } from '@lingui/core/macro'; +import { useMemo } from 'react'; +import { + IconApps, + IconCode, + IconCreditCard, + IconHierarchy, + IconKey, + IconLayoutSidebarRightCollapse, + IconLockOpen, + IconSettings, + IconSettingsAutomation, + IconShield, + IconSparkles, + IconSpy, + IconUsers, +} from 'twenty-ui/display'; +import { + FeatureFlagKey, + PermissionFlagType, +} from '~/generated-metadata/graphql'; + +type UseSettingsRolePermissionFlagConfigParams = { + assignmentCapabilities?: { + canBeAssignedToAgents?: boolean; + canBeAssignedToUsers?: boolean; + canBeAssignedToApiKeys?: boolean; + }; +}; + +export const useSettingsRolePermissionFlagConfig = ({ + assignmentCapabilities, +}: UseSettingsRolePermissionFlagConfigParams = {}): SettingsRolePermissionsSettingPermission[] => { + const isAIEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); + const isApplicationEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_APPLICATION_ENABLED, + ); + + const { + canBeAssignedToAgents = false, + canBeAssignedToUsers = false, + canBeAssignedToApiKeys = false, + } = assignmentCapabilities ?? {}; + + const hasAssignmentCapabilities = assignmentCapabilities !== undefined; + + return useMemo(() => { + const allPermissions: SettingsRolePermissionsSettingPermission[] = [ + { + key: PermissionFlagType.API_KEYS_AND_WEBHOOKS, + name: t`API Keys & Webhooks`, + description: t`Manage API keys and webhooks`, + Icon: IconCode, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.WORKSPACE, + name: t`Workspace`, + description: t`Set global workspace preferences`, + Icon: IconSettings, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.WORKSPACE_MEMBERS, + name: t`Users`, + description: t`Add or remove users`, + Icon: IconUsers, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.ROLES, + name: t`Roles`, + description: t`Define user roles and access levels`, + Icon: IconLockOpen, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.DATA_MODEL, + name: t`Data Model`, + description: t`Edit data structure and fields`, + Icon: IconHierarchy, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.SECURITY, + name: t`Security`, + description: t`Manage security policies`, + Icon: IconKey, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.WORKFLOWS, + name: t`Workflows`, + description: t`Manage workflows`, + Icon: IconSettingsAutomation, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.SSO_BYPASS, + name: t`SSO Bypass`, + description: t`Enable bypass options`, + Icon: IconShield, + isRelevantForAgents: false, + isRelevantForApiKeys: false, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.IMPERSONATE, + name: t`Impersonate`, + description: t`Impersonate workspace users`, + Icon: IconSpy, + isRelevantForAgents: false, + isRelevantForApiKeys: false, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.APPLICATIONS, + name: t`Applications`, + description: t`Install and manage applications`, + Icon: IconApps, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.LAYOUTS, + name: t`Layouts`, + description: t`Customize page layouts and UI structure`, + Icon: IconLayoutSidebarRightCollapse, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.BILLING, + name: t`Billing`, + description: t`Manage billing and subscriptions`, + Icon: IconCreditCard, + isRelevantForAgents: false, + isRelevantForApiKeys: false, + isRelevantForUsers: true, + }, + { + key: PermissionFlagType.AI_SETTINGS, + name: t`AI`, + description: t`Create and configure AI agents`, + Icon: IconSparkles, + isRelevantForAgents: true, + isRelevantForApiKeys: true, + isRelevantForUsers: true, + }, + ]; + + const canBeAssignedOnlyToAgents = + canBeAssignedToAgents && !canBeAssignedToUsers && !canBeAssignedToApiKeys; + + const canBeAssignedOnlyToApiKeys = + canBeAssignedToApiKeys && !canBeAssignedToUsers && !canBeAssignedToAgents; + + const canBeAssignedOnlyToUsers = + canBeAssignedToUsers && !canBeAssignedToAgents && !canBeAssignedToApiKeys; + + return allPermissions.filter((permission) => { + if (permission.key === PermissionFlagType.AI_SETTINGS && !isAIEnabled) { + return false; + } + if ( + permission.key === PermissionFlagType.APPLICATIONS && + !isApplicationEnabled + ) { + return false; + } + + if (hasAssignmentCapabilities) { + if (canBeAssignedOnlyToAgents && !permission.isRelevantForAgents) { + return false; + } + + if (canBeAssignedOnlyToApiKeys && !permission.isRelevantForApiKeys) { + return false; + } + + if (canBeAssignedOnlyToUsers && !permission.isRelevantForUsers) { + return false; + } + } + + return true; + }); + }, [ + hasAssignmentCapabilities, + canBeAssignedToAgents, + canBeAssignedToUsers, + canBeAssignedToApiKeys, + isAIEnabled, + isApplicationEnabled, + ]); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepBody.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepBody.tsx index a1376f0e27..18365953de 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepBody.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepBody.tsx @@ -17,11 +17,13 @@ const StyledWorkflowStepBody = styled.div` export const WorkflowStepBody = ({ children, + className, }: { children: React.ReactNode; + className?: string; }) => { return ( - + { + closeDropdown(dropdownId); + if (isDefined(agentId)) { + navigateSettings(SettingsPath.AIAgentDetail, { agentId }); + } + }; + + const handleViewRole = () => { + closeDropdown(dropdownId); + if (isDefined(workflowAiAgentActionAgent?.roleId)) { + navigateSettings(SettingsPath.RoleDetail, { + roleId: workflowAiAgentActionAgent.roleId, + }); + } + }; + const selectedItemId = useRecoilComponentValue( selectedItemIdComponentState, dropdownId, @@ -94,6 +130,26 @@ export const WorkflowStepFooter = ({ /> )} + {hasViewAgentOption && ( + + + + )} + {hasViewRoleOption && ( + + + + )} {!shouldPinDeleteButton && ( ({ }), })); +jest.mock('@/workflow/hooks/useWorkflowWithCurrentVersion', () => ({ + useWorkflowWithCurrentVersion: () => undefined, +})); + +jest.mock( + '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose', + () => ({ + useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose: () => ({ + resetPermissionState: jest.fn(), + }), + }), +); + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const workflowVisualizerComponentInstanceId = + 'workflow-visualizer-instance-id'; + + return ( + + + {children} + + + ); +}; + describe('useDeleteStep', () => { beforeEach(() => { jest.clearAllMocks(); @@ -55,7 +89,9 @@ describe('useDeleteStep', () => { }, }); - const { result } = renderHook(() => useDeleteStep()); + const { result } = renderHook(() => useDeleteStep(), { + wrapper, + }); await result.current.deleteStep(mockStepId); expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled(); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts index babf9ac83b..da510b29d0 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts @@ -1,19 +1,36 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow'; import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { useDeleteWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep'; +import { useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose'; +import { isDefined } from 'twenty-shared/utils'; export const useDeleteStep = () => { + const { resetPermissionState } = + useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose(); const { deleteWorkflowVersionStep } = useDeleteWorkflowVersionStep(); const { deleteStepsOutputSchema } = useStepsOutputSchema(); const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersionOrThrow(); const { closeCommandMenu } = useCommandMenu(); + const workflowVisualizerWorkflowId = useRecoilComponentValue( + workflowVisualizerWorkflowIdComponentState, + ); + const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId); const deleteStep = async (stepId: string) => { const workflowVersionId = await getUpdatableWorkflowVersion(); + const isAiAgentStep = + isDefined(workflow?.currentVersion?.steps) && + workflow.currentVersion.steps.some( + (step) => step.id === stepId && step.type === 'AI_AGENT', + ); + await deleteWorkflowVersionStep({ workflowVersionId, stepId, @@ -25,6 +42,10 @@ export const useDeleteStep = () => { stepIds: [stepId], workflowVersionId, }); + + if (isAiAgentStep) { + resetPermissionState(); + } }; return { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionList.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionList.tsx new file mode 100644 index 0000000000..51cad5fde2 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionList.tsx @@ -0,0 +1,120 @@ +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { type SettingsRoleObjectPermissionKey } from '@/settings/roles/role-permissions/objects-permissions/constants/SettingsRoleObjectPermissionIconConfig'; +import { t } from '@lingui/core/macro'; +import { type ObjectPermission } from '~/generated/graphql'; +import { filterBySearchQuery } from '~/utils/filterBySearchQuery'; +import { CRUD_PERMISSIONS } from '../constants/WorkflowAiAgentCrudPermissions'; +import { WorkflowAiAgentPermissionsPermissionRow } from './WorkflowAiAgentPermissionsPermissionRow'; +import { StyledLabel, StyledList } from './WorkflowAiAgentPermissionsStyles'; + +const CRUD_PERMISSION_ORDER = CRUD_PERMISSIONS.reduce< + Record +>( + (orderMap, permission, index) => { + orderMap[permission.key] = index; + return orderMap; + }, + {} as Record, +); + +type WorkflowAiAgentPermissionListProps = { + readonly: boolean; + objectPermissions: ObjectPermission[]; + onDeletePermission: ( + objectMetadataId: string, + permissionKey: SettingsRoleObjectPermissionKey, + ) => void; + searchQuery: string; +}; + +export const WorkflowAiAgentPermissionList = ({ + readonly, + objectPermissions, + onDeletePermission, + searchQuery, +}: WorkflowAiAgentPermissionListProps) => { + const { alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems } = + useFilteredObjectMetadataItems(); + + const sortedExistingPermissions = objectPermissions + .reduce< + Array<{ + objectMetadataId: string; + objectLabel: string; + permissionKey: SettingsRoleObjectPermissionKey; + permissionLabel: string; + }> + >((permissions, objectPermission) => { + const objectMetadata = objectMetadataItems.find( + (item) => item.id === objectPermission.objectMetadataId, + ); + + if (!objectMetadata) { + return permissions; + } + + CRUD_PERMISSIONS.forEach((crud) => { + if (objectPermission[crud.key] === true) { + permissions.push({ + objectMetadataId: objectPermission.objectMetadataId, + objectLabel: objectMetadata.labelPlural, + permissionKey: crud.key, + permissionLabel: crud.label(objectMetadata.labelPlural), + }); + } + }); + + return permissions; + }, []) + .sort((permissionA, permissionB) => { + if ( + permissionA.objectMetadataId === permissionB.objectMetadataId && + permissionA.permissionKey !== permissionB.permissionKey + ) { + return ( + CRUD_PERMISSION_ORDER[permissionA.permissionKey] - + CRUD_PERMISSION_ORDER[permissionB.permissionKey] + ); + } + + return permissionA.objectLabel.localeCompare(permissionB.objectLabel); + }); + + const filteredPermissions = filterBySearchQuery({ + items: sortedExistingPermissions, + searchQuery, + getSearchableValues: (permission) => [ + permission.permissionLabel, + permission.objectLabel, + ], + }); + + if (filteredPermissions.length === 0) { + return null; + } + + return ( +
+ {t`CRUD`} + + {filteredPermissions.map((permission) => ( + + onDeletePermission( + permission.objectMetadataId, + permission.permissionKey, + ) + } + /> + ))} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsCrudList.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsCrudList.tsx new file mode 100644 index 0000000000..7ceb99e92a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsCrudList.tsx @@ -0,0 +1,49 @@ +import { type SettingsRoleObjectPermissionKey } from '@/settings/roles/role-permissions/objects-permissions/constants/SettingsRoleObjectPermissionIconConfig'; +import { t } from '@lingui/core/macro'; +import { type ObjectPermission } from '~/generated-metadata/graphql'; +import { WorkflowAiAgentPermissionsPermissionRow } from './WorkflowAiAgentPermissionsPermissionRow'; +import { StyledLabel, StyledList } from './WorkflowAiAgentPermissionsStyles'; +type WorkflowAiAgentPermissionsCrudListProps = { + permissions: Array<{ + key: SettingsRoleObjectPermissionKey; + label: string; + }>; + objectPermissions?: ObjectPermission; + readonly: boolean; + onAddPermission: ( + objectMetadataId: string, + permissionKey: SettingsRoleObjectPermissionKey, + ) => void; + objectMetadataId: string; +}; + +export const WorkflowAiAgentPermissionsCrudList = ({ + permissions, + objectPermissions, + readonly, + onAddPermission, + objectMetadataId, +}: WorkflowAiAgentPermissionsCrudListProps) => { + return ( +
+ {t`CRUD`} + + {permissions.map((permission) => { + const isEnabled = Boolean(objectPermissions?.[permission.key]); + + return ( + onAddPermission(objectMetadataId, permission.key)} + /> + ); + })} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsFlagList.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsFlagList.tsx new file mode 100644 index 0000000000..e70c3e2760 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsFlagList.tsx @@ -0,0 +1,61 @@ +import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission'; +import { type PermissionFlagType } from '~/generated-metadata/graphql'; +import { WorkflowAiAgentPermissionsFlagRow } from './WorkflowAiAgentPermissionsFlagRow'; +import { StyledLabel, StyledList } from './WorkflowAiAgentPermissionsStyles'; + +type WorkflowAiAgentPermissionsFlagListProps = { + title: string; + permissions: SettingsRolePermissionsSettingPermission[]; + enabledPermissionFlagKeys: PermissionFlagType[]; + readonly: boolean; + showDeleteButton?: boolean; + onAddPermissionFlag?: (permissionKey: PermissionFlagType) => void; + onDeletePermissionFlag?: (permissionKey: PermissionFlagType) => void; +}; + +export const WorkflowAiAgentPermissionsFlagList = ({ + title, + permissions, + enabledPermissionFlagKeys, + readonly, + showDeleteButton = false, + onAddPermissionFlag, + onDeletePermissionFlag, +}: WorkflowAiAgentPermissionsFlagListProps) => { + const hasPermissions = permissions.length > 0; + + if (!hasPermissions) { + return null; + } + + return ( +
+ {title} + + {permissions.map((permission) => { + const isEnabled = enabledPermissionFlagKeys.includes(permission.key); + + return ( + onAddPermissionFlag?.(permission.key) + } + onDelete={ + showDeleteButton + ? () => onDeletePermissionFlag?.(permission.key) + : undefined + } + /> + ); + })} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsFlagRow.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsFlagRow.tsx new file mode 100644 index 0000000000..0adba3a114 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsFlagRow.tsx @@ -0,0 +1,60 @@ +import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission'; +import { useTheme } from '@emotion/react'; +import { IconTrash } from 'twenty-ui/display'; +import { IconButton } from 'twenty-ui/input'; +import { + StyledDeleteButton, + StyledIconContainer, + StyledRow, + StyledRowLeftContent, + StyledText, +} from './WorkflowAiAgentPermissionsStyles'; + +type WorkflowAiAgentPermissionsFlagRowProps = { + permission: SettingsRolePermissionsSettingPermission; + isEnabled: boolean; + readonly: boolean; + showDeleteButton?: boolean; + onAdd?: () => void; + onDelete?: () => void; +}; + +export const WorkflowAiAgentPermissionsFlagRow = ({ + permission, + isEnabled, + readonly, + showDeleteButton = false, + onAdd, + onDelete, +}: WorkflowAiAgentPermissionsFlagRowProps) => { + const theme = useTheme(); + const isClickable = !readonly && !isEnabled && Boolean(onAdd); + const isDisabled = isEnabled && !showDeleteButton; + + return ( + + + + + + {permission.name} + + {isEnabled && showDeleteButton && ( + + { + event.stopPropagation(); + onDelete?.(); + }} + /> + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsObjectRow.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsObjectRow.tsx new file mode 100644 index 0000000000..e831156e27 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsObjectRow.tsx @@ -0,0 +1,44 @@ +import { useTheme } from '@emotion/react'; +import { useIcons } from 'twenty-ui/display'; +import { + StyledIconChevronRight, + StyledIconContainer, + StyledRow, + StyledRowLeftContent, + StyledText, +} from './WorkflowAiAgentPermissionsStyles'; + +type WorkflowAiAgentPermissionsObjectRowProps = { + objectMetadata: { + id: string; + icon?: string | null; + labelPlural: string; + }; + onClick?: () => void; + readonly: boolean; +}; + +export const WorkflowAiAgentPermissionsObjectRow = ({ + objectMetadata, + onClick, + readonly, +}: WorkflowAiAgentPermissionsObjectRowProps) => { + const theme = useTheme(); + const { getIcon } = useIcons(); + const IconComponent = getIcon(objectMetadata.icon); + + return ( + + + + + + {objectMetadata.labelPlural} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsObjectsList.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsObjectsList.tsx new file mode 100644 index 0000000000..9be3abe087 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsObjectsList.tsx @@ -0,0 +1,34 @@ +import { WorkflowAiAgentPermissionsObjectRow } from './WorkflowAiAgentPermissionsObjectRow'; +import { StyledLabel, StyledList } from './WorkflowAiAgentPermissionsStyles'; + +type WorkflowAiAgentPermissionsObjectsListProps = { + objects: Array<{ + id: string; + icon?: string | null; + labelPlural: string; + }>; + onObjectClick: (objectId: string) => void; + readonly: boolean; +}; + +export const WorkflowAiAgentPermissionsObjectsList = ({ + objects, + onObjectClick, + readonly, +}: WorkflowAiAgentPermissionsObjectsListProps) => { + return ( +
+ Objects + + {objects.map((objectMetadata) => ( + onObjectClick(objectMetadata.id)} + readonly={readonly} + /> + ))} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsPermissionRow.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsPermissionRow.tsx new file mode 100644 index 0000000000..cf21cfdfa0 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsPermissionRow.tsx @@ -0,0 +1,67 @@ +import { PermissionIcon } from '@/settings/roles/role-permissions/objects-permissions/components/PermissionIcon'; +import { type SettingsRoleObjectPermissionKey } from '@/settings/roles/role-permissions/objects-permissions/constants/SettingsRoleObjectPermissionIconConfig'; +import { IconTrash } from 'twenty-ui/display'; +import { IconButton } from 'twenty-ui/input'; +import { + StyledDeleteButton, + StyledIconContainer, + StyledRow, + StyledRowLeftContent, + StyledText, +} from './WorkflowAiAgentPermissionsStyles'; + +type WorkflowAiAgentPermissionsPermissionRowProps = { + permission: { + key: SettingsRoleObjectPermissionKey; + label: string; + }; + isEnabled: boolean; + readonly: boolean; + showDeleteButton?: boolean; + alwaysShowGranted?: boolean; + onAdd?: () => void; + onDelete?: () => void; +}; + +export const WorkflowAiAgentPermissionsPermissionRow = ({ + permission, + isEnabled, + readonly, + showDeleteButton = true, + alwaysShowGranted = false, + onAdd, + onDelete, +}: WorkflowAiAgentPermissionsPermissionRowProps) => { + const isClickable = !readonly && !isEnabled; + const isDisabled = isEnabled && !showDeleteButton; + + return ( + + + + + + {permission.label} + + {isEnabled && showDeleteButton && ( + + { + e.stopPropagation(); + onDelete?.(); + }} + /> + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsStyles.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsStyles.ts new file mode 100644 index 0000000000..3c65ce73b1 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsStyles.ts @@ -0,0 +1,64 @@ +import styled from '@emotion/styled'; +import { IconChevronRight, Label } from 'twenty-ui/display'; + +export const StyledText = styled.div` + color: ${({ theme }) => theme.font.color.secondary}; +`; + +export const StyledLabel = styled(Label)` + margin: ${({ theme }) => theme.spacing(3, 3, 0)}; +`; + +export const StyledRow = styled.div<{ isDisabled?: boolean }>` + align-items: center; + display: flex; + justify-content: space-between; + width: 100%; + padding: ${({ theme }) => theme.spacing(1)}; + box-sizing: border-box; + border-radius: ${({ theme }) => theme.spacing(1)}; + cursor: ${({ isDisabled }) => (isDisabled ? 'not-allowed' : 'pointer')}; + opacity: ${({ isDisabled }) => (isDisabled ? 0.5 : 1)}; + pointer-events: ${({ isDisabled }) => (isDisabled ? 'none' : 'auto')}; + + :hover { + background-color: ${({ theme, isDisabled }) => + isDisabled ? 'inherit' : theme.background.transparent.light}; + } + + :hover [data-delete-button] { + opacity: 1; + } +`; + +export const StyledDeleteButton = styled.div` + opacity: 0; + transition: opacity ${({ theme }) => theme.animation.duration.fast}ms; +`; + +export const StyledRowLeftContent = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +export const StyledList = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(0.5)}; + padding: ${({ theme }) => theme.spacing(2)}; +`; + +export const StyledIconContainer = styled.div` + background-color: ${({ theme }) => theme.background.tertiary}; + padding: ${({ theme }) => theme.spacing(1)}; + border-radius: ${({ theme }) => theme.spacing(1)}; + display: flex; + justify-content: center; + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; +`; + +export const StyledIconChevronRight = styled(IconChevronRight)` + color: ${({ theme }) => theme.font.color.tertiary}; +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsTab.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsTab.tsx new file mode 100644 index 0000000000..f79f025acb --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsTab.tsx @@ -0,0 +1,264 @@ +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { useActionRolePermissionFlagConfig } from '@/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow'; +import { useWorkflowAiAgentPermissionActions } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useWorkflowAiAgentPermissionActions'; +import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState'; +import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState'; +import { workflowAiAgentPermissionsSelectedObjectIdState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState'; +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { useState } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { IconChevronLeft } from 'twenty-ui/display'; +import { IconButton } from 'twenty-ui/input'; +import { type Agent, useGetRolesQuery } from '~/generated-metadata/graphql'; +import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader'; +import { filterBySearchQuery } from '~/utils/filterBySearchQuery'; + +import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey'; +import { WorkflowAiAgentPermissionList } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionList'; +import { CRUD_PERMISSIONS } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentCrudPermissions'; +import { WorkflowAiAgentPermissionsCrudList } from './WorkflowAiAgentPermissionsCrudList'; +import { WorkflowAiAgentPermissionsFlagList } from './WorkflowAiAgentPermissionsFlagList'; +import { WorkflowAiAgentPermissionsObjectsList } from './WorkflowAiAgentPermissionsObjectsList'; +import { getFilteredPermissions } from './workflowAiAgentPermissions.utils'; +const StyledSearchInput = styled(TextInput)` + width: 100%; + height: 40px; + border-block: 1px solid ${({ theme }) => theme.border.color.medium}; + input { + height: 40px; + line-height: 40px; + border: none; + border-radius: 0; + width: 100%; + } +`; + +const StyledBackButtonText = styled.span` + color: ${({ theme }) => theme.font.color.secondary}; +`; + +const StyledBackButton = styled.button` + width: 100%; + align-items: center; + background: none; + border: none; + color: ${({ theme }) => theme.font.color.secondary}; + cursor: pointer; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(3)}; + text-align: left; + + &:hover { + color: ${({ theme }) => theme.font.color.primary}; + } +`; + +const StyledContainer = styled.div` + width: 100%; +`; + +type WorkflowAiAgentPermissionsTabProps = { + action: WorkflowAiAgentAction; + readonly: boolean; + isAgentLoading: boolean; + refetchAgent: () => Promise<{ data?: { findOneAgent?: Agent } }>; +}; + +export const WorkflowAiAgentPermissionsTab = ({ + readonly, + isAgentLoading, + refetchAgent, +}: WorkflowAiAgentPermissionsTabProps) => { + const workflowAiAgentActionAgent = useRecoilValue( + workflowAiAgentActionAgentState, + ); + + const [ + workflowAiAgentPermissionsSelectedObjectId, + setWorkflowAiAgentPermissionsSelectedObjectId, + ] = useRecoilState(workflowAiAgentPermissionsSelectedObjectIdState); + const [ + workflowAiAgentPermissionsIsAddingPermission, + setWorkflowAiAgentPermissionsIsAddingPermission, + ] = useRecoilState(workflowAiAgentPermissionsIsAddingPermissionState); + + const { alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems } = + useFilteredObjectMetadataItems(); + + const { + data: rolesData, + loading: rolesLoading, + refetch: refetchRoles, + } = useGetRolesQuery(); + + const [searchQuery, setSearchQuery] = useState(''); + const role = rolesData?.getRoles.find( + (item) => item.id === workflowAiAgentActionAgent?.roleId, + ); + const objectPermissions = role?.objectPermissions || []; + const permissionFlagKeys = + role?.permissionFlags?.map((permissionFlag) => permissionFlag.flag) ?? []; + const hasRoleWithPermissions = + isDefined(workflowAiAgentActionAgent?.roleId) && + (objectPermissions.length > 0 || permissionFlagKeys.length > 0); + const actionPermissionsConfig = useActionRolePermissionFlagConfig({ + assignmentCapabilities: { canBeAssignedToAgents: true }, + }); + + type ObjectMetadataListItem = (typeof objectMetadataItems)[number]; + const filteredObjects = filterBySearchQuery({ + items: objectMetadataItems, + searchQuery, + getSearchableValues: (item) => [item.labelSingular, item.labelPlural], + }); + + const { + filteredPermissions: filteredActionPermissions, + filteredEnabledPermissions: filteredEnabledActionPermissions, + } = getFilteredPermissions({ + permissions: actionPermissionsConfig, + permissionFlagKeys, + searchQuery, + }); + + const refetchAgentAndRoles = async () => { + await refetchRoles(); + const result = await refetchAgent(); + return { + refetchedAgent: result?.data?.findOneAgent, + }; + }; + + const { + handleAddPermission, + handleDeletePermission, + handleAddPermissionFlag, + handleDeletePermissionFlag, + } = useWorkflowAiAgentPermissionActions({ + readonly, + objectPermissions, + permissionFlagKeys, + refetchAgentAndRoles, + }); + + if (isAgentLoading || rolesLoading) { + return ; + } + + if (!isDefined(workflowAiAgentActionAgent)) { + return null; + } + + const selectedObject = isDefined(workflowAiAgentPermissionsSelectedObjectId) + ? objectMetadataItems.find( + (item) => item.id === workflowAiAgentPermissionsSelectedObjectId, + ) + : undefined; + + const objectPermissionForSelected = isDefined(selectedObject) + ? objectPermissions.find( + (permission) => permission.objectMetadataId === selectedObject.id, + ) + : undefined; + + const handleBack = () => { + isDefined(workflowAiAgentPermissionsSelectedObjectId) && + setWorkflowAiAgentPermissionsSelectedObjectId(undefined); + !isDefined(workflowAiAgentPermissionsSelectedObjectId) && + setWorkflowAiAgentPermissionsIsAddingPermission(false); + }; + + const handleObjectClick = (objectId: string) => { + setWorkflowAiAgentPermissionsSelectedObjectId(objectId); + }; + + const shouldShowBackButton = + isDefined(workflowAiAgentPermissionsSelectedObjectId) || + workflowAiAgentPermissionsIsAddingPermission; + const shouldShowCrudList = isDefined(selectedObject); + const shouldShowSelectionLists = + (!hasRoleWithPermissions || workflowAiAgentPermissionsIsAddingPermission) && + !isDefined(selectedObject); + const shouldShowExistingPermissions = + hasRoleWithPermissions && !workflowAiAgentPermissionsIsAddingPermission; + + return ( + + {shouldShowBackButton && ( + + + {t`Add permission`} + + )} + + setSearchQuery(value)} + placeholder={t`Type anything...`} + onKeyDown={(event) => { + if (isNonTextWritingKey(event.key)) { + event.stopPropagation(); + } + }} + /> + + {shouldShowCrudList && ( + ({ + key: p.key, + label: p.label(selectedObject.labelPlural), + })), + searchQuery, + getSearchableValues: (permission) => [permission.label], + })} + objectPermissions={objectPermissionForSelected} + readonly={readonly} + onAddPermission={handleAddPermission} + objectMetadataId={selectedObject.id} + /> + )} + + {shouldShowSelectionLists && ( + <> + + + + )} + + {shouldShowExistingPermissions && ( + <> + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPromptTab.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPromptTab.tsx new file mode 100644 index 0000000000..6f724a6845 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPromptTab.tsx @@ -0,0 +1,84 @@ +import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; +import { Select } from '@/ui/input/components/Select'; +import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState'; +import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; +import { t } from '@lingui/core/macro'; +import { useRecoilValue } from 'recoil'; +import { + type AgentResponseSchema, + type ModelConfiguration, +} from 'twenty-shared/ai'; +import { type SelectOption } from 'twenty-ui/input'; +import { SettingsAgentModelCapabilities } from '~/pages/settings/ai/components/SettingsAgentModelCapabilities'; +import { SettingsAgentResponseFormat } from '~/pages/settings/ai/components/SettingsAgentResponseFormat'; + +type WorkflowAiAgentPromptTabProps = { + readonly: boolean; + aiModelOptions: SelectOption[]; + onPromptChange: (value: string) => void; + onModelChange: (modelId: string) => void; + onModelConfigurationChange: (configuration: ModelConfiguration) => void; + onResponseFormatChange: (format: { + type: 'text' | 'json'; + schema?: AgentResponseSchema; + }) => void; +}; + +export const WorkflowAiAgentPromptTab = ({ + readonly, + aiModelOptions, + onPromptChange, + onModelChange, + onModelConfigurationChange, + onResponseFormatChange, +}: WorkflowAiAgentPromptTabProps) => { + const workflowAiAgentActionAgent = useRecoilValue( + workflowAiAgentActionAgentState, + ); + return ( + <> + + + {workflowAiAgentActionAgent ? ( + <> + - - - - - - )} - - {!actionOptions.readonly && } + )} ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/workflowAiAgentPermissions.utils.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/workflowAiAgentPermissions.utils.ts new file mode 100644 index 0000000000..cdd6267d3a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/workflowAiAgentPermissions.utils.ts @@ -0,0 +1,40 @@ +import type { PermissionFlagType } from '~/generated-metadata/graphql'; +import { filterBySearchQuery } from '~/utils/filterBySearchQuery'; + +export type FilteredPermissionsResult = { + filteredPermissions: T[]; + filteredEnabledPermissions: T[]; +}; + +export const getFilteredPermissions = < + T extends { key: PermissionFlagType; name: string }, +>({ + permissions, + permissionFlagKeys, + searchQuery, +}: { + permissions: T[]; + permissionFlagKeys: PermissionFlagType[]; + searchQuery: string; +}): FilteredPermissionsResult => { + const filteredPermissions = filterBySearchQuery({ + items: permissions, + searchQuery, + getSearchableValues: (permission) => [permission.name], + }); + + const enabledPermissions = permissions.filter((permission) => + permissionFlagKeys.includes(permission.key), + ); + + const filteredEnabledPermissions = filterBySearchQuery({ + items: enabledPermissions, + searchQuery, + getSearchableValues: (permission) => [permission.name], + }); + + return { + filteredPermissions, + filteredEnabledPermissions, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentCrudPermissions.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentCrudPermissions.ts new file mode 100644 index 0000000000..6c77dbcc27 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentCrudPermissions.ts @@ -0,0 +1,23 @@ +import { type SettingsRoleObjectPermissionKey } from '@/settings/roles/role-permissions/objects-permissions/constants/SettingsRoleObjectPermissionIconConfig'; + +export const CRUD_PERMISSIONS: Array<{ + key: SettingsRoleObjectPermissionKey; + label: (objectLabel: string) => string; +}> = [ + { + key: 'canReadObjectRecords', + label: (objectLabel: string) => `See ${objectLabel}`, + }, + { + key: 'canUpdateObjectRecords', + label: (objectLabel: string) => `Edit ${objectLabel}`, + }, + { + key: 'canSoftDeleteObjectRecords', + label: (objectLabel: string) => `Delete ${objectLabel}`, + }, + { + key: 'canDestroyObjectRecords', + label: (objectLabel: string) => `Destroy ${objectLabel}`, + }, +]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentTabListComponentId.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentTabListComponentId.ts new file mode 100644 index 0000000000..e5ce74192e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentTabListComponentId.ts @@ -0,0 +1,2 @@ +export const WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID = + 'workflow-ai-agent-tab-list'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentTabs.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentTabs.ts new file mode 100644 index 0000000000..ece30629ba --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentTabs.ts @@ -0,0 +1,4 @@ +export const WORKFLOW_AI_AGENT_TABS = { + PROMPT: 'prompt', + PERMISSIONS: 'permissions', +} as const; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose.ts new file mode 100644 index 0000000000..40b06137bf --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose.ts @@ -0,0 +1,27 @@ +import { useListenToSidePanelClosing } from '@/ui/layout/right-drawer/hooks/useListenToSidePanelClosing'; +import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState'; +import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState'; +import { workflowAiAgentPermissionsSelectedObjectIdState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState'; +import { useResetRecoilState, useSetRecoilState } from 'recoil'; + +export const useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose = () => { + const resetSelectedObjectId = useResetRecoilState( + workflowAiAgentPermissionsSelectedObjectIdState, + ); + const setIsAddingPermission = useSetRecoilState( + workflowAiAgentPermissionsIsAddingPermissionState, + ); + const resetAgentState = useResetRecoilState(workflowAiAgentActionAgentState); + + const resetPermissionState = () => { + resetSelectedObjectId(); + setIsAddingPermission(false); + resetAgentState(); + }; + + useListenToSidePanelClosing(resetPermissionState); + + return { + resetPermissionState, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useWorkflowAiAgentPermissionActions.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useWorkflowAiAgentPermissionActions.ts new file mode 100644 index 0000000000..f1364b2c27 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useWorkflowAiAgentPermissionActions.ts @@ -0,0 +1,365 @@ +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { type SettingsRoleObjectPermissionKey } from '@/settings/roles/role-permissions/objects-permissions/constants/SettingsRoleObjectPermissionIconConfig'; +import { useActionRolePermissionFlagConfig } from '@/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig'; +import { useSettingsRolePermissionFlagConfig } from '@/settings/roles/role-permissions/permission-flags/hooks/useSettingsRolePermissionFlagConfig'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { CRUD_PERMISSIONS } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentCrudPermissions'; +import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState'; +import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState'; +import { workflowAiAgentPermissionsSelectedObjectIdState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState'; +import { t } from '@lingui/core/macro'; +import { useMemo } from 'react'; +import { useRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; +import { + useAssignRoleToAgentMutation, + useCreateOneRoleMutation, + useUpsertObjectPermissionsMutation, + useUpsertPermissionFlagsMutation, + type Agent, + type ObjectPermission, + type PermissionFlagType, +} from '~/generated-metadata/graphql'; + +type UseWorkflowAiAgentPermissionActionsParams = { + readonly: boolean; + objectPermissions: ObjectPermission[]; + permissionFlagKeys: PermissionFlagType[]; + refetchAgentAndRoles: () => Promise<{ refetchedAgent?: Agent }>; +}; + +export const useWorkflowAiAgentPermissionActions = ({ + readonly, + objectPermissions, + permissionFlagKeys, + refetchAgentAndRoles, +}: UseWorkflowAiAgentPermissionActionsParams) => { + const { enqueueSuccessSnackBar } = useSnackBar(); + const [workflowAiAgentActionAgent, setWorkflowAiAgentActionAgent] = + useRecoilState(workflowAiAgentActionAgentState); + const { alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems } = + useFilteredObjectMetadataItems(); + const settingsPermissionsConfig = useSettingsRolePermissionFlagConfig({ + assignmentCapabilities: { canBeAssignedToAgents: true }, + }); + const actionPermissionsConfig = useActionRolePermissionFlagConfig({ + assignmentCapabilities: { canBeAssignedToAgents: true }, + }); + + const [, setWorkflowAiAgentPermissionsSelectedObjectId] = useRecoilState( + workflowAiAgentPermissionsSelectedObjectIdState, + ); + const [, setWorkflowAiAgentPermissionsIsAddingPermission] = useRecoilState( + workflowAiAgentPermissionsIsAddingPermissionState, + ); + + const [createRole] = useCreateOneRoleMutation(); + const [assignRoleToAgent] = useAssignRoleToAgentMutation(); + const [upsertObjectPermissions] = useUpsertObjectPermissionsMutation(); + const [upsertPermissionFlags] = useUpsertPermissionFlagsMutation(); + + const roleId = workflowAiAgentActionAgent?.roleId; + + const permissionFlagLabelMap = useMemo( + () => + [...settingsPermissionsConfig, ...actionPermissionsConfig].reduce< + Partial> + >((acc, permission) => { + acc[permission.key] = permission.name; + return acc; + }, {}), + [settingsPermissionsConfig, actionPermissionsConfig], + ); + + const ensureRoleId = async (): Promise => { + if (isDefined(roleId)) { + return roleId; + } + + if (!isDefined(workflowAiAgentActionAgent) || readonly) { + return undefined; + } + + const agentDisplayName = + workflowAiAgentActionAgent.label ?? + workflowAiAgentActionAgent.name ?? + t`Agent`; + const roleName = `${agentDisplayName} role (${workflowAiAgentActionAgent.id.substring(0, 8)})`; + const generatedRoleId = v4(); + + await createRole({ + variables: { + createRoleInput: { + id: generatedRoleId, + label: roleName, + description: t`Auto-generated role for ${agentDisplayName}`, + icon: 'IconRobot', + canAccessAllTools: false, + canUpdateAllSettings: false, + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canBeAssignedToAgents: true, + canBeAssignedToUsers: false, + canBeAssignedToApiKeys: false, + }, + }, + }); + + await assignRoleToAgent({ + variables: { + agentId: workflowAiAgentActionAgent.id, + roleId: generatedRoleId, + }, + }); + + await refetchAgentAndRoles(); + + return generatedRoleId; + }; + + const handleAddPermission = async ( + objectMetadataId: string, + permissionKey: SettingsRoleObjectPermissionKey, + ) => { + const ensuredRoleId = await ensureRoleId(); + + if (!ensuredRoleId) { + return; + } + + const objectPermission = objectPermissions.find( + (permission) => permission.objectMetadataId === objectMetadataId, + ); + + const updatedPermission: ObjectPermission = { + objectMetadataId, + canReadObjectRecords: + permissionKey === 'canReadObjectRecords' + ? true + : (objectPermission?.canReadObjectRecords ?? + (permissionKey === 'canUpdateObjectRecords' || + permissionKey === 'canSoftDeleteObjectRecords' || + permissionKey === 'canDestroyObjectRecords' + ? true + : false)), + canUpdateObjectRecords: + permissionKey === 'canUpdateObjectRecords' + ? true + : (objectPermission?.canUpdateObjectRecords ?? false), + canSoftDeleteObjectRecords: + permissionKey === 'canSoftDeleteObjectRecords' + ? true + : (objectPermission?.canSoftDeleteObjectRecords ?? false), + canDestroyObjectRecords: + permissionKey === 'canDestroyObjectRecords' + ? true + : (objectPermission?.canDestroyObjectRecords ?? false), + }; + + const allObjectPermissions = objectPermissions + .filter((permission) => permission.objectMetadataId !== objectMetadataId) + .map((permission) => ({ + objectMetadataId: permission.objectMetadataId, + canReadObjectRecords: permission.canReadObjectRecords ?? false, + canUpdateObjectRecords: permission.canUpdateObjectRecords ?? false, + canSoftDeleteObjectRecords: + permission.canSoftDeleteObjectRecords ?? false, + canDestroyObjectRecords: permission.canDestroyObjectRecords ?? false, + })); + + allObjectPermissions.push({ + objectMetadataId: updatedPermission.objectMetadataId, + canReadObjectRecords: updatedPermission.canReadObjectRecords ?? false, + canUpdateObjectRecords: updatedPermission.canUpdateObjectRecords ?? false, + canSoftDeleteObjectRecords: + updatedPermission.canSoftDeleteObjectRecords ?? false, + canDestroyObjectRecords: + updatedPermission.canDestroyObjectRecords ?? false, + }); + + await upsertObjectPermissions({ + variables: { + upsertObjectPermissionsInput: { + roleId: ensuredRoleId, + objectPermissions: allObjectPermissions, + }, + }, + }); + + await refetchAgentAndRoles(); + setWorkflowAiAgentPermissionsIsAddingPermission(false); + setWorkflowAiAgentPermissionsSelectedObjectId(undefined); + }; + + const handleDeletePermission = async ( + objectMetadataId: string, + permissionKey: SettingsRoleObjectPermissionKey, + ) => { + if (!isDefined(roleId) || readonly) { + return; + } + + const objectPermission = objectPermissions.find( + (permission) => permission.objectMetadataId === objectMetadataId, + ); + + if (!isDefined(objectPermission)) { + return; + } + + const objectMetadata = objectMetadataItems.find( + (item) => item.id === objectMetadataId, + ); + + if (!isDefined(objectMetadata)) { + return; + } + + const updatedPermission: ObjectPermission = { + objectMetadataId, + canReadObjectRecords: + permissionKey === 'canReadObjectRecords' + ? false + : (objectPermission.canReadObjectRecords ?? false), + canUpdateObjectRecords: + permissionKey === 'canUpdateObjectRecords' + ? false + : (objectPermission.canUpdateObjectRecords ?? false), + canSoftDeleteObjectRecords: + permissionKey === 'canSoftDeleteObjectRecords' + ? false + : (objectPermission.canSoftDeleteObjectRecords ?? false), + canDestroyObjectRecords: + permissionKey === 'canDestroyObjectRecords' + ? false + : (objectPermission.canDestroyObjectRecords ?? false), + }; + + if (permissionKey === 'canReadObjectRecords') { + updatedPermission.canUpdateObjectRecords = false; + updatedPermission.canSoftDeleteObjectRecords = false; + updatedPermission.canDestroyObjectRecords = false; + } + + const allObjectPermissions = objectPermissions + .filter((permission) => permission.objectMetadataId !== objectMetadataId) + .map((permission) => ({ + objectMetadataId: permission.objectMetadataId, + canReadObjectRecords: permission.canReadObjectRecords ?? false, + canUpdateObjectRecords: permission.canUpdateObjectRecords ?? false, + canSoftDeleteObjectRecords: + permission.canSoftDeleteObjectRecords ?? false, + canDestroyObjectRecords: permission.canDestroyObjectRecords ?? false, + })); + + allObjectPermissions.push({ + objectMetadataId: updatedPermission.objectMetadataId, + canReadObjectRecords: updatedPermission.canReadObjectRecords ?? false, + canUpdateObjectRecords: updatedPermission.canUpdateObjectRecords ?? false, + canSoftDeleteObjectRecords: + updatedPermission.canSoftDeleteObjectRecords ?? false, + canDestroyObjectRecords: + updatedPermission.canDestroyObjectRecords ?? false, + }); + + await upsertObjectPermissions({ + variables: { + upsertObjectPermissionsInput: { + roleId, + objectPermissions: allObjectPermissions, + }, + }, + }); + + await refetchAgentAndRoles(); + const permissionLabel = CRUD_PERMISSIONS.find( + (permission) => permission.key === permissionKey, + )?.label(objectMetadata.labelPlural); + + if (isDefined(permissionLabel)) { + enqueueSuccessSnackBar({ + message: t`${permissionLabel} Permission removed`, + }); + } + }; + + const handleAddPermissionFlag = async ( + permissionFlagKey: PermissionFlagType, + ) => { + if (readonly) { + return; + } + + const hadRoleBefore = isDefined(roleId); + const ensuredRoleId = await ensureRoleId(); + + if (!ensuredRoleId || permissionFlagKeys.includes(permissionFlagKey)) { + return; + } + + await upsertPermissionFlags({ + variables: { + upsertPermissionFlagsInput: { + roleId: ensuredRoleId, + permissionFlagKeys: [...permissionFlagKeys, permissionFlagKey], + }, + }, + }); + + const { refetchedAgent } = await refetchAgentAndRoles(); + if ( + !hadRoleBefore && + isDefined(refetchedAgent) && + isDefined(setWorkflowAiAgentActionAgent) + ) { + setWorkflowAiAgentActionAgent(refetchedAgent); + } + setWorkflowAiAgentPermissionsIsAddingPermission(false); + setWorkflowAiAgentPermissionsSelectedObjectId(undefined); + }; + + const handleDeletePermissionFlag = async ( + permissionFlagKey: PermissionFlagType, + ) => { + if (!isDefined(roleId) || readonly) { + return; + } + + if (!permissionFlagKeys.includes(permissionFlagKey)) { + return; + } + + const permissionFlagKeysAfterRemoval = permissionFlagKeys.filter( + (flag) => flag !== permissionFlagKey, + ); + + await upsertPermissionFlags({ + variables: { + upsertPermissionFlagsInput: { + roleId, + permissionFlagKeys: permissionFlagKeysAfterRemoval, + }, + }, + }); + + await refetchAgentAndRoles(); + + const permissionLabel = permissionFlagLabelMap[permissionFlagKey]; + + if (isDefined(permissionLabel)) { + enqueueSuccessSnackBar({ + message: t`${permissionLabel} permission removed`, + }); + } + }; + + return { + handleAddPermission, + handleDeletePermission, + handleAddPermissionFlag, + handleDeletePermissionFlag, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState.ts new file mode 100644 index 0000000000..7cf6f45f01 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState.ts @@ -0,0 +1,7 @@ +import { atom } from 'recoil'; +import { type Agent } from '~/generated-metadata/graphql'; + +export const workflowAiAgentActionAgentState = atom({ + key: 'workflowAiAgentActionAgentState', + default: undefined, +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState.ts new file mode 100644 index 0000000000..f961c278d8 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const workflowAiAgentPermissionsIsAddingPermissionState = atom({ + key: 'workflowAiAgentPermissionsIsAddingPermissionState', + default: false, +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState.ts new file mode 100644 index 0000000000..4c2a70ca37 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +export const workflowAiAgentPermissionsSelectedObjectIdState = atom< + string | undefined +>({ + key: 'workflowAiAgentPermissionsSelectedObjectIdState', + default: undefined, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts index 520ebba746..efceab02db 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { isDefined } from 'twenty-shared/utils'; import { In, IsNull, Not, Repository } from 'typeorm'; import { @@ -174,4 +175,39 @@ export class AiAgentRoleService { roleToAssignIsSameAsCurrentRole: Boolean(existingRoleTarget), }; } + + public async deleteAgentOnlyRoleIfUnused({ + roleId, + roleTargetId, + workspaceId, + }: { + roleId: string; + roleTargetId: string; + workspaceId: string; + }): Promise { + const role = await this.roleRepository.findOne({ + where: { id: roleId, workspaceId }, + }); + + if ( + !isDefined(role) || + !role.canBeAssignedToAgents || + role.canBeAssignedToUsers || + role.canBeAssignedToApiKeys + ) { + return; + } + + const remainingAssignments = await this.roleTargetsRepository.count({ + where: { + roleId, + workspaceId, + id: Not(roleTargetId), + }, + }); + + if (remainingAssignments === 0) { + await this.roleRepository.delete({ id: roleId, workspaceId }); + } + } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts index b24381452e..67ac325d85 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts @@ -1,8 +1,11 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; @@ -21,8 +24,11 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { let twentyORMGlobalManager: jest.Mocked; let serverlessFunctionService: jest.Mocked; let agentRepository: jest.Mocked; + let roleTargetsRepository: jest.Mocked; + let roleRepository: jest.Mocked; let objectMetadataRepository: jest.Mocked; let workflowCommonWorkspaceService: jest.Mocked; + let aiAgentRoleService: jest.Mocked; beforeEach(async () => { serverlessFunctionService = { @@ -38,6 +44,16 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { delete: jest.fn(), }; + roleTargetsRepository = { + findOne: jest.fn(), + count: jest.fn(), + }; + + roleRepository = { + findOne: jest.fn(), + delete: jest.fn(), + }; + objectMetadataRepository = { findOne: jest.fn(), }; @@ -46,6 +62,10 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { getObjectMetadataItemWithFieldsMaps: jest.fn(), } as unknown as jest.Mocked; + aiAgentRoleService = { + deleteAgentOnlyRoleIfUnused: jest.fn(), + } as unknown as jest.Mocked; + twentyORMGlobalManager = { getRepositoryForWorkspace: jest.fn(), } as unknown as jest.Mocked; @@ -65,6 +85,14 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { provide: getRepositoryToken(AgentEntity), useValue: agentRepository, }, + { + provide: getRepositoryToken(RoleTargetsEntity), + useValue: roleTargetsRepository, + }, + { + provide: getRepositoryToken(RoleEntity), + useValue: roleRepository, + }, { provide: getRepositoryToken(ObjectMetadataEntity), useValue: objectMetadataRepository, @@ -73,6 +101,10 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { provide: WorkflowCommonWorkspaceService, useValue: workflowCommonWorkspaceService, }, + { + provide: AiAgentRoleService, + useValue: aiAgentRoleService, + }, { provide: ScopedWorkspaceContextFactory, useValue: {}, @@ -154,6 +186,50 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { workspaceId: mockWorkspaceId, }); }); + + it('should delete attached role when it is agent-only and unassigned elsewhere', async () => { + const step = { + id: 'step-id', + name: 'AI Agent Step', + type: WorkflowActionType.AI_AGENT, + valid: true, + nextStepIds: [], + settings: { + input: { + agentId: 'agent-id', + prompt: '', + }, + outputSchema: {}, + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + }, + } as unknown as WorkflowAction; + + agentRepository.findOne.mockResolvedValue({ id: 'agent-id' }); + roleTargetsRepository.findOne.mockResolvedValue({ + id: 'role-target-id', + roleId: 'role-id', + }); + + await service.runWorkflowVersionStepDeletionSideEffects({ + step, + workspaceId: mockWorkspaceId, + }); + + expect(agentRepository.delete).toHaveBeenCalledWith({ + id: 'agent-id', + workspaceId: mockWorkspaceId, + }); + expect( + aiAgentRoleService.deleteAgentOnlyRoleIfUnused, + ).toHaveBeenCalledWith({ + roleId: 'role-id', + roleTargetId: 'role-target-id', + workspaceId: mockWorkspaceId, + }); + }); }); describe('runStepCreationSideEffectsAndBuildStep', () => { diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index 9c459fd52b..d5613683c3 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -9,9 +9,11 @@ import { v4 } from 'uuid'; import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/serverless/drivers/constants/base-typescript-project-input-schema'; import { type WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto'; +import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { @@ -53,9 +55,12 @@ export class WorkflowVersionStepOperationsWorkspaceService { private readonly serverlessFunctionService: ServerlessFunctionService, @InjectRepository(AgentEntity) private readonly agentRepository: Repository, + @InjectRepository(RoleTargetsEntity) + private readonly roleTargetsRepository: Repository, @InjectRepository(ObjectMetadataEntity) private readonly objectMetadataRepository: Repository, private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + private readonly aiAgentRoleService: AiAgentRoleService, ) {} async runWorkflowVersionStepDeletionSideEffects({ @@ -90,7 +95,22 @@ export class WorkflowVersionStepOperationsWorkspaceService { }); if (isDefined(agent)) { + const roleTarget = await this.roleTargetsRepository.findOne({ + where: { + agentId: agent.id, + workspaceId, + }, + }); + await this.agentRepository.delete({ id: agent.id, workspaceId }); + + if (isDefined(roleTarget?.roleId) && isDefined(roleTarget?.id)) { + await this.aiAgentRoleService.deleteAgentOnlyRoleIfUnused({ + roleId: roleTarget.roleId, + roleTargetId: roleTarget.id, + workspaceId, + }); + } } break; } diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts index d3ec03edad..888c13f7f6 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts @@ -2,8 +2,11 @@ import { Module } from '@nestjs/common'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; +import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module'; @@ -19,7 +22,13 @@ import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workfl WorkflowSchemaModule, ServerlessFunctionModule, WorkflowCommonModule, - NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity, AgentEntity]), + AiAgentRoleModule, + NestjsQueryTypeOrmModule.forFeature([ + ObjectMetadataEntity, + AgentEntity, + RoleTargetsEntity, + RoleEntity, + ]), ], providers: [ WorkflowVersionStepWorkspaceService,