From 79bcd90d8dbef430febc6b214b312a50305a5446 Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Thu, 4 Sep 2025 08:58:50 +0530 Subject: [PATCH] Feat: role applicability controls (#14239) Closes [#1404](https://github.com/twentyhq/core-team-issues/issues/1404) --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../src/generated-metadata/graphql.ts | 56 ++- .../twenty-front/src/generated/graphql.ts | 19 + .../modules/ai/components/AIChatMessage.tsx | 52 ++- .../src/modules/ai/components/AIChatTab.tsx | 2 + .../auth/states/currentWorkspaceState.ts | 2 +- .../SettingsDevelopersRoleSelector.tsx | 21 +- .../components/SettingsRolesContainer.tsx | 11 +- .../roles/components/SettingsRolesList.tsx | 72 +++- .../settings/roles/constants/RolesListTabs.ts | 8 + .../fragments/apiKeyForRoleFragment.ts | 10 + .../roles/graphql/fragments/roleFragment.ts | 3 + .../roles/graphql/queries/getRolesQuery.ts | 10 + .../roles/hooks/useUpdateAgentRole.ts | 63 ++++ .../roles/hooks/useUpdateApiKeyRole.ts | 65 ++++ .../components/RoleAssignmentSection.tsx | 113 ++++++ .../components/SettingsRoleAssignment.tsx | 338 ++++++++---------- ...ettingsRoleAssignmentConfirmationModal.tsx | 24 +- ...oleAssignmentConfirmationModalSubtitle.tsx | 18 +- ...ingsRoleAssignmentEntityPickerDropdown.tsx | 132 +++++++ .../SettingsRoleAssignmentTable.tsx | 176 +++++++++ .../SettingsRoleAssignmentTableRow.tsx | 104 ++++-- .../constants/RoleTargetConfig.ts | 67 ++++ ...entConfirmationModalSelectedRoleTarget.ts} | 3 +- .../roles/role-assignment/types/role-maps.ts | 5 + .../role-assignment/utils/build-role-maps.ts | 24 ++ .../getRoleWithRemovedFieldPermission.spec.ts | 5 + ...getRoleWithUpsertedFieldPermission.spec.ts | 5 + .../components/SettingsRoleApplicability.tsx | 110 ++++++ .../components/SettingsRoleSettings.tsx | 16 + .../components/SettingsRoleCreateEffect.tsx | 5 + .../roles/role/hooks/useSaveDraftRoleToDB.ts | 39 +- .../states/settingsDraftRoleFamilyState.ts | 5 + .../pages/settings/ai/SettingsAgentForm.tsx | 3 +- .../forms/components/SettingsAIAgentForm.tsx | 19 +- .../api-keys/SettingsDevelopersApiKeysNew.tsx | 6 +- .../src/testing/mock-data/roles.ts | 10 + .../src/testing/mock-data/roles/roles-mock.ts | 10 + ...756434280823-AddRoleApplicabilityFields.ts | 35 ++ .../api-key/api-key-role.service.spec.ts | 7 + .../api-key/api-key-role.service.ts | 47 ++- .../core-modules/api-key/api-key.exception.ts | 1 + ...-key-graphql-api-exception-handler.util.ts | 4 + .../agent-role/agent-role.service.spec.ts | 8 +- .../agent-role/agent-role.service.ts | 68 +++- .../agent/agent-tool-generator.service.ts | 1 + .../metadata-modules/agent/agent.exception.ts | 1 + .../metadata-modules/agent/agent.resolver.ts | 5 +- .../metadata-modules/agent/agent.service.ts | 33 +- .../from-role-entity-to-flat-role.util.ts | 3 + .../permissions/permissions.exception.ts | 4 + ...sion-graphql-api-exception-handler.util.ts | 2 + .../role/dtos/create-role-input.dto.ts | 15 + .../metadata-modules/role/dtos/role.dto.ts | 31 ++ .../role/dtos/update-role-input.dto.ts | 15 + .../metadata-modules/role/role.entity.ts | 9 + .../metadata-modules/role/role.module.ts | 2 + .../metadata-modules/role/role.resolver.ts | 39 +- .../metadata-modules/role/role.service.ts | 6 + .../utils/fromRoleEntityToRoleDto.util.ts | 3 + .../user-role/user-role.service.ts | 11 + .../comparators/workspace-agent.comparator.ts | 18 +- .../services/workspace-sync-agent.service.ts | 139 +++---- .../services/workspace-sync-role.service.ts | 59 ++- .../agents/workflow-creation-agent.ts | 2 + .../standard-agent-definition.interface.ts | 1 + .../standard-roles/index.ts | 2 + .../standard-roles/roles/admin-role.ts | 3 + .../roles/workflow-manager-role.ts | 20 ++ .../standard-role-definition.interface.ts | 2 + .../workspace-sync-metadata.module.ts | 2 + .../roles.integration-spec.ts | 2 +- .../src/workflow/schemas/workflow.schema.ts | 2 +- 72 files changed, 1889 insertions(+), 344 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/roles/constants/RolesListTabs.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/graphql/fragments/apiKeyForRoleFragment.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/hooks/useUpdateAgentRole.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/hooks/useUpdateApiKeyRole.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/role-assignment/components/RoleAssignmentSection.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTable.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-assignment/constants/RoleTargetConfig.ts rename packages/twenty-front/src/modules/settings/roles/role-assignment/types/{SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember.ts => SettingsRoleAssignmentConfirmationModalSelectedRoleTarget.ts} (50%) create mode 100644 packages/twenty-front/src/modules/settings/roles/role-assignment/types/role-maps.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/role-assignment/utils/build-role-maps.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/role-settings/components/SettingsRoleApplicability.tsx create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1756434280823-AddRoleApplicabilityFields.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/workflow-manager-role.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 9e29b38ec4..c3a0309c48 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -141,6 +141,14 @@ export type ApiKey = { workspaceId: Scalars['UUID']; }; +export type ApiKeyForRole = { + __typename?: 'ApiKeyForRole'; + expiresAt: Scalars['DateTime']; + id: Scalars['UUID']; + name: Scalars['String']; + revokedAt?: Maybe; +}; + export type ApiKeyToken = { __typename?: 'ApiKeyToken'; token: Scalars['String']; @@ -711,6 +719,9 @@ export type CreateRemoteServerInput = { export type CreateRoleInput = { canAccessAllTools?: InputMaybe; + canBeAssignedToAgents?: InputMaybe; + canBeAssignedToApiKeys?: InputMaybe; + canBeAssignedToUsers?: InputMaybe; canDestroyAllObjectRecords?: InputMaybe; canReadAllObjectRecords?: InputMaybe; canSoftDeleteAllObjectRecords?: InputMaybe; @@ -2942,7 +2953,12 @@ export type RevokeApiKeyDto = { export type Role = { __typename?: 'Role'; + agents: Array; + apiKeys: Array; canAccessAllTools: Scalars['Boolean']; + canBeAssignedToAgents: Scalars['Boolean']; + canBeAssignedToApiKeys: Scalars['Boolean']; + canBeAssignedToUsers: Scalars['Boolean']; canDestroyAllObjectRecords: Scalars['Boolean']; canReadAllObjectRecords: Scalars['Boolean']; canSoftDeleteAllObjectRecords: Scalars['Boolean']; @@ -3374,6 +3390,9 @@ export type UpdateRoleInput = { export type UpdateRolePayload = { canAccessAllTools?: InputMaybe; + canBeAssignedToAgents?: InputMaybe; + canBeAssignedToApiKeys?: InputMaybe; + canBeAssignedToUsers?: InputMaybe; canDestroyAllObjectRecords?: InputMaybe; canReadAllObjectRecords?: InputMaybe; canSoftDeleteAllObjectRecords?: InputMaybe; @@ -4562,20 +4581,22 @@ export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{ export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: { __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean } }; +export type ApiKeyForRoleFragmentFragment = { __typename?: 'ApiKeyForRole', id: string, name: string, expiresAt: string, revokedAt?: string | null }; + export type FieldPermissionFragmentFragment = { __typename?: 'FieldPermission', objectMetadataId: string, fieldMetadataId: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null, id: string, roleId: string }; export type ObjectPermissionFragmentFragment = { __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }; export type PermissionFlagFragmentFragment = { __typename?: 'PermissionFlag', id: string, flag: PermissionFlagType, roleId: string }; -export type RoleFragmentFragment = { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean }; +export type RoleFragmentFragment = { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean }; export type CreateOneRoleMutationVariables = Exact<{ createRoleInput: CreateRoleInput; }>; -export type CreateOneRoleMutation = { __typename?: 'Mutation', createOneRole: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } }; +export type CreateOneRoleMutation = { __typename?: 'Mutation', createOneRole: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } }; export type DeleteOneRoleMutationVariables = Exact<{ roleId: Scalars['UUID']; @@ -4589,7 +4610,7 @@ export type UpdateOneRoleMutationVariables = Exact<{ }>; -export type UpdateOneRoleMutation = { __typename?: 'Mutation', updateOneRole: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } }; +export type UpdateOneRoleMutation = { __typename?: 'Mutation', updateOneRole: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } }; export type UpdateWorkspaceMemberRoleMutationVariables = Exact<{ workspaceMemberId: Scalars['UUID']; @@ -4597,7 +4618,7 @@ export type UpdateWorkspaceMemberRoleMutationVariables = Exact<{ }>; -export type UpdateWorkspaceMemberRoleMutation = { __typename?: 'Mutation', updateWorkspaceMemberRole: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, roles?: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean }> | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } }; +export type UpdateWorkspaceMemberRoleMutation = { __typename?: 'Mutation', updateWorkspaceMemberRole: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, roles?: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean }> | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } }; export type UpsertFieldPermissionsMutationVariables = Exact<{ upsertFieldPermissionsInput: UpsertFieldPermissionsInput; @@ -4623,7 +4644,7 @@ export type UpsertPermissionFlagsMutation = { __typename?: 'Mutation', upsertPer export type GetRolesQueryVariables = Exact<{ [key: string]: never; }>; -export type GetRolesQuery = { __typename?: 'Query', getRoles: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }>, permissionFlags?: Array<{ __typename?: 'PermissionFlag', id: string, flag: PermissionFlagType, roleId: string }> | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, fieldPermissions?: Array<{ __typename?: 'FieldPermission', objectMetadataId: string, fieldMetadataId: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null, id: string, roleId: string }> | null }> }; +export type GetRolesQuery = { __typename?: 'Query', getRoles: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, createdAt: string, updatedAt: string }>, apiKeys: Array<{ __typename?: 'ApiKeyForRole', id: string, name: string, expiresAt: string, revokedAt?: string | null }>, permissionFlags?: Array<{ __typename?: 'PermissionFlag', id: string, flag: PermissionFlagType, roleId: string }> | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, fieldPermissions?: Array<{ __typename?: 'FieldPermission', objectMetadataId: string, fieldMetadataId: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null, id: string, roleId: string }> | null }> }; export type CreateApprovedAccessDomainMutationVariables = Exact<{ input: CreateApprovedAccessDomainInput; @@ -4754,7 +4775,7 @@ export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationVaria export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation = { __typename?: 'Mutation', verifyTwoFactorAuthenticationMethodForAuthenticatedUser: { __typename?: 'VerifyTwoFactorAuthenticationMethodOutput', success: boolean } }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId?: string | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId?: string | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; @@ -4773,7 +4794,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId?: string | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId?: string | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null }; @@ -5183,7 +5204,7 @@ export type UpdateWorkspaceMutationVariables = Exact<{ }>; -export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: string, customDomain?: string | null, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isTwoFactorAuthenticationEnforced: boolean, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } }; +export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: string, customDomain?: string | null, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isTwoFactorAuthenticationEnforced: boolean, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } }; export type UploadWorkspaceLogoMutationVariables = Exact<{ file: Scalars['Upload']; @@ -5294,6 +5315,14 @@ export const WebhookFragmentFragmentDoc = gql` secret } `; +export const ApiKeyForRoleFragmentFragmentDoc = gql` + fragment ApiKeyForRoleFragment on ApiKeyForRole { + id + name + expiresAt + revokedAt +} + `; export const FieldPermissionFragmentFragmentDoc = gql` fragment FieldPermissionFragment on FieldPermission { objectMetadataId @@ -5382,6 +5411,9 @@ export const RoleFragmentFragmentDoc = gql` canUpdateAllObjectRecords canSoftDeleteAllObjectRecords canDestroyAllObjectRecords + canBeAssignedToUsers + canBeAssignedToAgents + canBeAssignedToApiKeys } `; export const ViewFieldFragmentFragmentDoc = gql` @@ -9562,6 +9594,12 @@ export const GetRolesDocument = gql` workspaceMembers { ...WorkspaceMemberQueryFragment } + agents { + ...AgentFields + } + apiKeys { + ...ApiKeyForRoleFragment + } permissionFlags { ...PermissionFlagFragment } @@ -9575,6 +9613,8 @@ export const GetRolesDocument = gql` } ${RoleFragmentFragmentDoc} ${WorkspaceMemberQueryFragmentFragmentDoc} +${AgentFieldsFragmentDoc} +${ApiKeyForRoleFragmentFragmentDoc} ${PermissionFlagFragmentFragmentDoc} ${ObjectPermissionFragmentFragmentDoc} ${FieldPermissionFragmentFragmentDoc}`; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 4ed084cdab..bcbb3e46d1 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -141,6 +141,14 @@ export type ApiKey = { workspaceId: Scalars['UUID']; }; +export type ApiKeyForRole = { + __typename?: 'ApiKeyForRole'; + expiresAt: Scalars['DateTime']; + id: Scalars['UUID']; + name: Scalars['String']; + revokedAt?: Maybe; +}; + export type ApiKeyToken = { __typename?: 'ApiKeyToken'; token: Scalars['String']; @@ -675,6 +683,9 @@ export type CreatePageLayoutInput = { export type CreateRoleInput = { canAccessAllTools?: InputMaybe; + canBeAssignedToAgents?: InputMaybe; + canBeAssignedToApiKeys?: InputMaybe; + canBeAssignedToUsers?: InputMaybe; canDestroyAllObjectRecords?: InputMaybe; canReadAllObjectRecords?: InputMaybe; canSoftDeleteAllObjectRecords?: InputMaybe; @@ -2788,7 +2799,12 @@ export type RevokeApiKeyDto = { export type Role = { __typename?: 'Role'; + agents: Array; + apiKeys: Array; canAccessAllTools: Scalars['Boolean']; + canBeAssignedToAgents: Scalars['Boolean']; + canBeAssignedToApiKeys: Scalars['Boolean']; + canBeAssignedToUsers: Scalars['Boolean']; canDestroyAllObjectRecords: Scalars['Boolean']; canReadAllObjectRecords: Scalars['Boolean']; canSoftDeleteAllObjectRecords: Scalars['Boolean']; @@ -3212,6 +3228,9 @@ export type UpdateRoleInput = { export type UpdateRolePayload = { canAccessAllTools?: InputMaybe; + canBeAssignedToAgents?: InputMaybe; + canBeAssignedToApiKeys?: InputMaybe; + canBeAssignedToUsers?: InputMaybe; canDestroyAllObjectRecords?: InputMaybe; canReadAllObjectRecords?: InputMaybe; canSoftDeleteAllObjectRecords?: InputMaybe; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx index 8ccfbf8216..0ade4f4423 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx @@ -43,7 +43,57 @@ const StyledMessageText = styled.div<{ isUser?: boolean }>` isUser ? theme.font.color.light : theme.font.color.primary}; font-weight: ${({ isUser }) => (isUser ? 500 : 400)}; width: fit-content; - white-space: pre-line; + max-width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; + + code { + overflow: auto; + white-space: pre-wrap; + word-wrap: break-word; + max-width: 100%; + line-height: 1.4; + padding: ${({ theme }) => theme.spacing(1)}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + background: ${({ theme }) => theme.background.tertiary}; + } + + pre { + background: ${({ theme }) => theme.background.tertiary}; + padding: ${({ theme }) => theme.spacing(2)}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + overflow-x: auto; + max-width: 100%; + + code { + padding: 0; + border-radius: 0; + background: none; + } + } + + p { + margin: ${({ theme }) => theme.spacing(1)} 0; + line-height: 1.5; + } + + ul, + ol { + margin: ${({ theme }) => theme.spacing(1)} 0; + padding-left: ${({ theme }) => theme.spacing(4)}; + } + + li { + margin: ${({ theme }) => theme.spacing(0.5)} 0; + } + + blockquote { + border-left: 3px solid ${({ theme }) => theme.border.color.medium}; + margin: ${({ theme }) => theme.spacing(2)} 0; + padding-left: ${({ theme }) => theme.spacing(2)}; + color: ${({ theme }) => theme.font.color.secondary}; + } `; const StyledMessageFooter = styled.div` diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx index 19b0b62076..c452890817 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx @@ -118,6 +118,8 @@ export const AIChatTab = ({ placeholder={t`Enter a question...`} value={input} onChange={handleInputChange} + minRows={1} + maxRows={20} /> {!isWorkflowAgentNodeChat && ( diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 22f022542d..138503087f 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -25,7 +25,7 @@ export type CurrentWorkspace = Pick< | 'metadataVersion' | 'isTwoFactorAuthenticationEnforced' > & { - defaultRole?: Omit | null; + defaultRole?: Omit | null; defaultAgent?: { id: string } | null; }; diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersRoleSelector.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersRoleSelector.tsx index f61ff52383..3652403319 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersRoleSelector.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersRoleSelector.tsx @@ -1,7 +1,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { Select } from '@/ui/input/components/Select'; import { useRecoilValue } from 'recoil'; -import { useIcons } from 'twenty-ui/display'; +import { type IconComponent, useIcons } from 'twenty-ui/display'; import { type Role } from '~/generated-metadata/graphql'; type SettingsDevelopersRoleSelectorProps = { @@ -40,11 +40,20 @@ export const SettingsDevelopersRoleSelector = ({ return null; } - const options = roles.map((role) => ({ - label: role.label, - value: role.id, - Icon: getIcon(role.icon) ?? undefined, - })); + const options = roles.reduce< + Array<{ label: string; value: string; Icon?: IconComponent }> + >((acc, role) => { + { + if (role.canBeAssignedToApiKeys) { + acc.push({ + label: role.label, + value: role.id, + Icon: getIcon(role.icon) ?? undefined, + }); + } + return acc; + } + }, []); const selectValue = value || (doesDefaultRoleExistInRoles ? defaultRole?.id : roles[0].id); 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 d054fb3274..4af03e09bb 100644 --- a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesContainer.tsx +++ b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesContainer.tsx @@ -6,9 +6,12 @@ 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 } from 'twenty-ui/display'; @@ -16,6 +19,10 @@ 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); @@ -36,7 +43,9 @@ 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 09326b52c3..6e57cc7a7d 100644 --- a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesList.tsx +++ b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesList.tsx @@ -4,13 +4,25 @@ 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 { SettingsPath } from '@/types/SettingsPath'; +import { TabList } from '@/ui/layout/tab-list/components/TabList'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useRecoilValue } from 'recoil'; -import { H2Title, IconPlus } from 'twenty-ui/display'; +import { + H2Title, + IconKey, + IconPlus, + IconRobot, + IconUser, +} from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; +import { FeatureFlagKey } from '~/generated/graphql'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { sortByAscString } from '~/utils/array/sortByAscString'; @@ -33,26 +45,74 @@ const StyledNoRoles = styled(TableCell)` export const SettingsRolesList = () => { const navigateSettings = useNavigateSettings(); + const activeTabId = useRecoilComponentValue( + activeTabIdComponentState, + ROLES_LIST_TABS.COMPONENT_INSTANCE_ID, + ); const settingsAllRoles = useRecoilValue(settingsAllRolesSelector); + const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); const sortedSettingsAllRoles = [...settingsAllRoles].sort((a, b) => sortByAscString(a.label, b.label), ); + const filteredRoles = sortedSettingsAllRoles.filter((role) => { + switch (activeTabId) { + case ROLES_LIST_TABS.TABS_IDS.USER_ROLES: + return role.canBeAssignedToUsers; + case ROLES_LIST_TABS.TABS_IDS.AGENT_ROLES: + return role.canBeAssignedToAgents; + case ROLES_LIST_TABS.TABS_IDS.API_KEY_ROLES: + return role.canBeAssignedToApiKeys; + default: + return role.canBeAssignedToUsers; + } + }); + + 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, + }, + ]; + + const description = isAiEnabled + ? t`Manage roles and permissions for team members, agents, and API keys` + : t`Manage roles and permissions for team members and API keys`; + return (
- + + + - {sortedSettingsAllRoles.length === 0 ? ( + {filteredRoles.length === 0 ? ( {t`No roles found`} ) : ( - sortedSettingsAllRoles.map((role) => ( + filteredRoles.map((role) => ( )) )} diff --git a/packages/twenty-front/src/modules/settings/roles/constants/RolesListTabs.ts b/packages/twenty-front/src/modules/settings/roles/constants/RolesListTabs.ts new file mode 100644 index 0000000000..ee0160e8cf --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/constants/RolesListTabs.ts @@ -0,0 +1,8 @@ +export const ROLES_LIST_TABS = { + COMPONENT_INSTANCE_ID: 'settings-roles-list-tabs', + TABS_IDS: { + USER_ROLES: 'user-roles', + AGENT_ROLES: 'agent-roles', + API_KEY_ROLES: 'api-key-roles', + }, +} as const; diff --git a/packages/twenty-front/src/modules/settings/roles/graphql/fragments/apiKeyForRoleFragment.ts b/packages/twenty-front/src/modules/settings/roles/graphql/fragments/apiKeyForRoleFragment.ts new file mode 100644 index 0000000000..284ed7437e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/graphql/fragments/apiKeyForRoleFragment.ts @@ -0,0 +1,10 @@ +import { gql } from '@apollo/client'; + +export const API_KEY_FOR_ROLE_FRAGMENT = gql` + fragment ApiKeyForRoleFragment on ApiKeyForRole { + id + name + expiresAt + revokedAt + } +`; diff --git a/packages/twenty-front/src/modules/settings/roles/graphql/fragments/roleFragment.ts b/packages/twenty-front/src/modules/settings/roles/graphql/fragments/roleFragment.ts index fd733237fa..26f031ec6a 100644 --- a/packages/twenty-front/src/modules/settings/roles/graphql/fragments/roleFragment.ts +++ b/packages/twenty-front/src/modules/settings/roles/graphql/fragments/roleFragment.ts @@ -13,5 +13,8 @@ export const ROLE_FRAGMENT = gql` canUpdateAllObjectRecords canSoftDeleteAllObjectRecords canDestroyAllObjectRecords + canBeAssignedToUsers + canBeAssignedToAgents + canBeAssignedToApiKeys } `; diff --git a/packages/twenty-front/src/modules/settings/roles/graphql/queries/getRolesQuery.ts b/packages/twenty-front/src/modules/settings/roles/graphql/queries/getRolesQuery.ts index 4f81123b99..52b5f655f6 100644 --- a/packages/twenty-front/src/modules/settings/roles/graphql/queries/getRolesQuery.ts +++ b/packages/twenty-front/src/modules/settings/roles/graphql/queries/getRolesQuery.ts @@ -1,3 +1,5 @@ +import { AGENT_FRAGMENT } from '@/ai/graphql/fragments/agentFragment'; +import { API_KEY_FOR_ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/apiKeyForRoleFragment'; import { FIELD_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/fieldPermissionFragment'; import { OBJECT_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/objectPermissionFragment'; import { PERMISSION_FLAG_FRAGMENT } from '@/settings/roles/graphql/fragments/permissionFlagFragment'; @@ -8,6 +10,8 @@ import { gql } from '@apollo/client'; export const GET_ROLES = gql` ${WORKSPACE_MEMBER_QUERY_FRAGMENT} ${ROLE_FRAGMENT} + ${AGENT_FRAGMENT} + ${API_KEY_FOR_ROLE_FRAGMENT} ${PERMISSION_FLAG_FRAGMENT} ${OBJECT_PERMISSION_FRAGMENT} ${FIELD_PERMISSION_FRAGMENT} @@ -17,6 +21,12 @@ export const GET_ROLES = gql` workspaceMembers { ...WorkspaceMemberQueryFragment } + agents { + ...AgentFields + } + apiKeys { + ...ApiKeyForRoleFragment + } permissionFlags { ...PermissionFlagFragment } diff --git a/packages/twenty-front/src/modules/settings/roles/hooks/useUpdateAgentRole.ts b/packages/twenty-front/src/modules/settings/roles/hooks/useUpdateAgentRole.ts new file mode 100644 index 0000000000..f90d0d6376 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/hooks/useUpdateAgentRole.ts @@ -0,0 +1,63 @@ +import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; +import { useRecoilState } from 'recoil'; +import { + useAssignRoleToAgentMutation, + type Agent, +} from '~/generated-metadata/graphql'; + +export const useUpdateAgentRole = (roleId: string) => { + const [settingsDraftRole, setSettingsDraftRole] = useRecoilState( + settingsDraftRoleFamilyState(roleId), + ); + + const [assignRoleToAgentMutation] = useAssignRoleToAgentMutation(); + + const updateAgentRoleDraftState = ({ agent }: { agent: Agent }) => { + setSettingsDraftRole({ + ...settingsDraftRole, + agents: [...settingsDraftRole.agents, agent], + }); + }; + + const addAgentToRoleAndUpdateState = async ({ + agentId, + }: { + agentId: string; + }) => { + const { data } = await assignRoleToAgentMutation({ + variables: { + agentId, + roleId, + }, + awaitRefetchQueries: true, + refetchQueries: ['GetRoles'], + }); + + return data?.assignRoleToAgent; + }; + + const addAgentsToRole = async ({ + roleId, + agentIds, + }: { + roleId: string; + agentIds: string[]; + }) => { + await Promise.all( + agentIds.map((agentId) => + assignRoleToAgentMutation({ + variables: { + roleId, + agentId, + }, + }), + ), + ); + }; + + return { + addAgentToRoleAndUpdateState, + updateAgentRoleDraftState, + addAgentsToRole, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/roles/hooks/useUpdateApiKeyRole.ts b/packages/twenty-front/src/modules/settings/roles/hooks/useUpdateApiKeyRole.ts new file mode 100644 index 0000000000..0051ec9020 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/hooks/useUpdateApiKeyRole.ts @@ -0,0 +1,65 @@ +import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; +import { useRecoilState } from 'recoil'; +import { useAssignRoleToApiKeyMutation } from '~/generated-metadata/graphql'; +import { type ApiKeyForRole } from '~/generated/graphql'; + +export const useUpdateApiKeyRole = (roleId: string) => { + const [settingsDraftRole, setSettingsDraftRole] = useRecoilState( + settingsDraftRoleFamilyState(roleId), + ); + + const [assignRoleToApiKeyMutation] = useAssignRoleToApiKeyMutation(); + + const updateApiKeyRoleDraftState = ({ + apiKey, + }: { + apiKey: ApiKeyForRole; + }) => { + setSettingsDraftRole({ + ...settingsDraftRole, + apiKeys: [...settingsDraftRole.apiKeys, apiKey], + }); + }; + + const addApiKeyToRoleAndUpdateState = async ({ + apiKeyId, + }: { + apiKeyId: string; + }) => { + const { data } = await assignRoleToApiKeyMutation({ + variables: { + apiKeyId, + roleId, + }, + awaitRefetchQueries: true, + refetchQueries: ['GetRoles'], + }); + + return data?.assignRoleToApiKey; + }; + + const addApiKeysToRole = async ({ + roleId, + apiKeyIds, + }: { + roleId: string; + apiKeyIds: string[]; + }) => { + await Promise.all( + apiKeyIds.map((apiKeyId) => + assignRoleToApiKeyMutation({ + variables: { + roleId, + apiKeyId, + }, + }), + ), + ); + }; + + return { + addApiKeyToRoleAndUpdateState, + updateApiKeyRoleDraftState, + addApiKeysToRole, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/RoleAssignmentSection.tsx b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/RoleAssignmentSection.tsx new file mode 100644 index 0000000000..9a3ea7b1d2 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/RoleAssignmentSection.tsx @@ -0,0 +1,113 @@ +import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; +import { SettingsRoleAssignmentEntityPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown'; +import { SettingsRoleAssignmentTable } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentTable'; +import { SettingsRoleAssignmentWorkspaceMemberPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import styled from '@emotion/styled'; +import { AppTooltip, IconPlus, TooltipDelay } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { type Agent, type Role } from '~/generated-metadata/graphql'; +import { type ApiKeyForRole } from '~/generated/graphql'; +import { ROLE_TARGET_CONFIG } from '../constants/RoleTargetConfig'; + +const StyledAssignToMemberContainer = styled.div` + display: flex; + justify-content: flex-end; + padding-block: ${({ theme }) => theme.spacing(2)}; +`; + +type RoleAssignmentSectionProps = { + roleTargetType: keyof typeof ROLE_TARGET_CONFIG; + roleId: string; + settingsDraftRole: Role; + currentWorkspaceMember?: CurrentWorkspaceMember; + onSelect: ( + roleTarget: CurrentWorkspaceMember | Agent | ApiKeyForRole, + roleTargetType: keyof typeof ROLE_TARGET_CONFIG, + ) => void; + allWorkspaceMembersHaveThisRole?: boolean; +}; + +export const RoleAssignmentSection = ({ + roleTargetType, + roleId, + settingsDraftRole, + currentWorkspaceMember, + onSelect, + allWorkspaceMembersHaveThisRole, +}: RoleAssignmentSectionProps) => { + const config = ROLE_TARGET_CONFIG[roleTargetType]; + const { closeDropdown } = useCloseDropdown(); + + if (!config.canBeAssigned(settingsDraftRole)) { + return null; + } + + const assignedIds = config.getAssignedIds(settingsDraftRole); + const excludedIds = config.getExcludedIds( + assignedIds, + currentWorkspaceMember?.id, + ); + + return ( +
+ + + +
+
+ {config.tooltip && ( +
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignment.tsx b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignment.tsx index ff036f4ae2..0d2a860133 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignment.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignment.tsx @@ -1,72 +1,33 @@ import { - type CurrentWorkspaceMember, currentWorkspaceMemberState, + type CurrentWorkspaceMember, } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates'; +import { useUpdateAgentRole } from '@/settings/roles/hooks/useUpdateAgentRole'; +import { useUpdateApiKeyRole } from '@/settings/roles/hooks/useUpdateApiKeyRole'; import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole'; +import { RoleAssignmentSection } from '@/settings/roles/role-assignment/components/RoleAssignmentSection'; import { SettingsRoleAssignmentConfirmationModal } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModal'; -import { SettingsRoleAssignmentTableHeader } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentTableHeader'; -import { SettingsRoleAssignmentWorkspaceMemberPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown'; -import { type SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember'; +import { type SettingsRoleAssignmentConfirmationModalSelectedRoleTarget } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedRoleTarget'; import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector'; import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; import { SettingsPath } from '@/types/SettingsPath'; -import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; -import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; -import { TableCell } from '@/ui/layout/table/components/TableCell'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import styled from '@emotion/styled'; -import { t } from '@lingui/core/macro'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useState } from 'react'; import { useRecoilValue } from 'recoil'; import { - AppTooltip, - H2Title, - IconPlus, - IconSearch, - TooltipDelay, -} from 'twenty-ui/display'; -import { Button } from 'twenty-ui/input'; -import { Section } from 'twenty-ui/layout'; -import { type Role, type WorkspaceMember } from '~/generated-metadata/graphql'; + useFindManyAgentsQuery, + useGetApiKeysQuery, + type Agent, +} from '~/generated-metadata/graphql'; +import { FeatureFlagKey, type ApiKeyForRole } from '~/generated/graphql'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID } from '../constants/RoleAssignmentConfirmationModalId'; -import { SettingsRoleAssignmentTableRow } from './SettingsRoleAssignmentTableRow'; - -const StyledAssignToMemberContainer = styled.div` - display: flex; - justify-content: flex-end; - padding-top: ${({ theme }) => theme.spacing(2)}; - padding-bottom: ${({ theme }) => theme.spacing(2)}; -`; - -const StyledSearchContainer = styled.div` - padding-bottom: ${({ theme }) => theme.spacing(2)}; -`; - -const StyledSearchInput = styled(SettingsTextInput)` - input { - background: ${({ theme }) => theme.background.transparent.lighter}; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - } -`; - -const StyledTable = styled.div` - border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; -`; - -const StyledTableRows = styled.div` - gap: ${({ theme }) => theme.spacing(0.5)}; - padding-bottom: ${({ theme }) => theme.spacing(2)}; - padding-top: ${({ theme }) => theme.spacing(2)}; -`; - -const StyledNoMembers = styled(TableCell)` - color: ${({ theme }) => theme.font.color.tertiary}; -`; +import { ROLE_TARGET_CONFIG } from '../constants/RoleTargetConfig'; +import { buildRoleMaps } from '../utils/build-role-maps'; type SettingsRoleAssignmentProps = { roleId: string; @@ -77,6 +38,8 @@ export const SettingsRoleAssignment = ({ roleId, isCreateMode, }: SettingsRoleAssignmentProps) => { + const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); + const settingsDraftRole = useRecoilValue( settingsDraftRoleFamilyState(roleId), ); @@ -87,49 +50,29 @@ export const SettingsRoleAssignment = ({ updateWorkspaceMemberRoleDraftState, } = useUpdateWorkspaceMemberRole(roleId); + const { addAgentToRoleAndUpdateState, updateAgentRoleDraftState } = + useUpdateAgentRole(roleId); + + const { addApiKeyToRoleAndUpdateState, updateApiKeyRoleDraftState } = + useUpdateApiKeyRole(roleId); + + const { data: agentsData } = useFindManyAgentsQuery(); + const { data: apiKeysData } = useGetApiKeysQuery(); + const { openModal, closeModal } = useModal(); - const [selectedWorkspaceMember, setSelectedWorkspaceMember] = - useState( + const [selectedRoleTarget, setSelectRoleTarget] = + useState( null, ); - const dropdownId = 'role-member-select'; - - const { closeDropdown } = useCloseDropdown(); - const [searchFilter, setSearchFilter] = useState(''); const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - const settingsAllRoles = useRecoilValue(settingsAllRolesSelector); - const workspaceMemberRoleMap = new Map< - string, - { id: string; label: string } - >(); - settingsAllRoles.forEach((role: Role) => { - role.workspaceMembers.forEach((member: WorkspaceMember) => { - workspaceMemberRoleMap.set(member.id, { id: role.id, label: role.label }); - }); - }); + const roleMaps = buildRoleMaps(settingsAllRoles); - const filteredWorkspaceMembers = !searchFilter - ? settingsDraftRole.workspaceMembers - : settingsDraftRole.workspaceMembers.filter((member) => { - const searchTerm = searchFilter.toLowerCase(); - const firstName = member.name.firstName?.toLowerCase() || ''; - const lastName = member.name.lastName?.toLowerCase() || ''; - const email = member.userEmail?.toLowerCase() || ''; - - return ( - firstName.includes(searchTerm) || - lastName.includes(searchTerm) || - email.includes(searchTerm) - ); - }); - - const assignedWorkspaceMemberIds = settingsDraftRole.workspaceMembers.map( - (workspaceMember) => workspaceMember.id, - ); + const assignedWorkspaceMemberIds = + ROLE_TARGET_CONFIG.member.getAssignedIds(settingsDraftRole); const assignableWorkspaceMembers = currentWorkspaceMembers.filter( (member) => member.id !== currentWorkspaceMember?.id, @@ -140,21 +83,7 @@ export const SettingsRoleAssignment = ({ ); const handleModalClose = () => { - setSelectedWorkspaceMember(null); - }; - - const handleSelectWorkspaceMember = ( - workspaceMember: CurrentWorkspaceMember, - ) => { - const existingRole = workspaceMemberRoleMap.get(workspaceMember.id); - - setSelectedWorkspaceMember({ - id: workspaceMember.id, - name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`, - role: existingRole, - }); - openModal(ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID); - closeDropdown(dropdownId); + setSelectRoleTarget(null); }; const isModalOpened = useRecoilComponentValue( @@ -163,124 +92,141 @@ export const SettingsRoleAssignment = ({ ); const handleConfirm = async () => { - if (!selectedWorkspaceMember || !isModalOpened) return; + if (!selectedRoleTarget || !isModalOpened) return; if (!isCreateMode) { - await addWorkspaceMemberToRoleAndUpdateState({ - workspaceMemberId: selectedWorkspaceMember.id, - }); - } else { - const workspaceMember = currentWorkspaceMembers.find( - (member) => member.id === selectedWorkspaceMember.id, - ); - - if (!workspaceMember) { - throw new Error('Workspace member not found'); + switch (selectedRoleTarget.entityType) { + case 'member': + await addWorkspaceMemberToRoleAndUpdateState({ + workspaceMemberId: selectedRoleTarget.id, + }); + break; + case 'agent': + await addAgentToRoleAndUpdateState({ + agentId: selectedRoleTarget.id, + }); + break; + case 'apiKey': + await addApiKeyToRoleAndUpdateState({ + apiKeyId: selectedRoleTarget.id, + }); + break; } + } else { + switch (selectedRoleTarget.entityType) { + case 'member': { + const member = currentWorkspaceMembers.find( + (member) => member.id === selectedRoleTarget.id, + ); - updateWorkspaceMemberRoleDraftState({ - workspaceMember: { - id: workspaceMember.id, - name: workspaceMember.name, - colorScheme: '', - userEmail: '', - }, - }); + if (!member) { + throw new Error('Workspace member not found'); + } + + updateWorkspaceMemberRoleDraftState({ + workspaceMember: { + id: member.id, + name: member.name, + colorScheme: '', + userEmail: '', + }, + }); + break; + } + case 'agent': { + const agent = agentsData?.findManyAgents.find( + (agent) => agent.id === selectedRoleTarget.id, + ); + + if (!agent) { + throw new Error('Agent not found'); + } + + updateAgentRoleDraftState({ + agent, + }); + break; + } + case 'apiKey': { + const apiKeyData = apiKeysData?.apiKeys.find( + (apiKey) => apiKey.id === selectedRoleTarget.id, + ); + + if (!apiKeyData) { + throw new Error('API key not found'); + } + + updateApiKeyRoleDraftState({ + apiKey: apiKeyData as ApiKeyForRole, + }); + break; + } + } } handleModalClose(); }; + const handleSelectEntity = ( + entity: CurrentWorkspaceMember | Agent | ApiKeyForRole, + entityType: keyof typeof ROLE_TARGET_CONFIG, + ) => { + const config = ROLE_TARGET_CONFIG[entityType]; + const existingRole = config.getRoleMap(roleMaps).get(entity.id); + const name = config.getName(entity as never); + + setSelectRoleTarget({ + id: entity.id, + name, + role: existingRole, + entityType: entityType as 'member' | 'agent' | 'apiKey', + }); + + openModal(ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID); + }; + const handleRoleClick = (roleId: string) => { navigateSettings(SettingsPath.RoleDetail, { roleId }); handleModalClose(); closeModal(ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID); }; - const handleSearchChange = (text: string) => { - setSearchFilter(text); - }; + if (!settingsDraftRole) { + return null; + } return ( <> -
- - - - - - - - {filteredWorkspaceMembers.length > 0 ? ( - filteredWorkspaceMembers.map((workspaceMember) => ( - - )) - ) : ( - - {!searchFilter - ? t`No members assigned` - : t`No members match your search`} - - )} - - + {Object.keys(ROLE_TARGET_CONFIG).map( + (roleTargetType) => + (isAiEnabled || roleTargetType !== 'agent') && ( + + ), + )} - - -
-
-
-
- - {selectedWorkspaceMember && ( + {selectedRoleTarget && ( )} diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModal.tsx b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModal.tsx index 714e17c283..600d49232d 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModal.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModal.tsx @@ -1,35 +1,41 @@ import { SettingsRoleAssignmentConfirmationModalSubtitle } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModalSubtitle'; import { ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID } from '@/settings/roles/role-assignment/constants/RoleAssignmentConfirmationModalId'; -import { type SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember'; +import { type SettingsRoleAssignmentConfirmationModalSelectedRoleTarget } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedRoleTarget'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { t } from '@lingui/core/macro'; type SettingsRoleAssignmentConfirmationModalProps = { - selectedWorkspaceMember: SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember; + selectedRoleTarget: SettingsRoleAssignmentConfirmationModalSelectedRoleTarget; onClose: () => void; onConfirm: () => void; onRoleClick: (roleId: string) => void; + newRoleName: string; }; export const SettingsRoleAssignmentConfirmationModal = ({ - selectedWorkspaceMember, + selectedRoleTarget, onClose, onConfirm, onRoleClick, + newRoleName, }: SettingsRoleAssignmentConfirmationModalProps) => { - const workspaceMemberName = selectedWorkspaceMember.name; + const roleTargetName = selectedRoleTarget.name; - const title = t`Assign ${workspaceMemberName}?`; + const title = t`Assign ${roleTargetName}?`; return ( + selectedRoleTarget.role ? ( + + ) : ( + t`${roleTargetName} will be assigned to the "${newRoleName}" role.` + ) } onClose={onClose} onConfirmClick={onConfirm} diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModalSubtitle.tsx b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModalSubtitle.tsx index 02f08372bb..4c158cc188 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModalSubtitle.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModalSubtitle.tsx @@ -1,6 +1,7 @@ import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates'; import { SettingsCard } from '@/settings/components/SettingsCard'; -import { type SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember'; +import { type SettingsRoleAssignmentConfirmationModalSelectedRoleTarget } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedRoleTarget'; + import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; import { useRecoilValue } from 'recoil'; @@ -11,28 +12,30 @@ const StyledSettingsCardContainer = styled.div` `; type SettingsRoleAssignmentConfirmationModalSubtitleProps = { - selectedWorkspaceMember: SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember; + selectedRoleTarget: SettingsRoleAssignmentConfirmationModalSelectedRoleTarget; onRoleClick: (roleId: string) => void; }; export const SettingsRoleAssignmentConfirmationModalSubtitle = ({ - selectedWorkspaceMember, + selectedRoleTarget, onRoleClick, }: SettingsRoleAssignmentConfirmationModalSubtitleProps) => { const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState); const enrichedSelectedWorkspaceMember = currentWorkspaceMembers.find( - (member) => member.id === selectedWorkspaceMember.id, + (member) => member.id === selectedRoleTarget.id, ); - const workspaceMemberName = `${enrichedSelectedWorkspaceMember?.name.firstName} ${enrichedSelectedWorkspaceMember?.name.lastName}`; + const workspaceMemberName = enrichedSelectedWorkspaceMember + ? `${enrichedSelectedWorkspaceMember?.name.firstName} ${enrichedSelectedWorkspaceMember?.name.lastName}` + : selectedRoleTarget.name; return ( <> {t`${workspaceMemberName} will be unassigned from the following role:`} } onClick={() => - selectedWorkspaceMember.role && - onRoleClick(selectedWorkspaceMember.role.id) + selectedRoleTarget.role && onRoleClick(selectedRoleTarget.role.id) } /> diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown.tsx b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown.tsx new file mode 100644 index 0000000000..b6e7fa29c6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown.tsx @@ -0,0 +1,132 @@ +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { + type Agent, + useFindManyAgentsQuery, + useGetApiKeysQuery, +} from '~/generated-metadata/graphql'; +import { type ApiKeyForRole } from '~/generated/graphql'; + +const StyledLoadingContainer = styled.div` + padding: ${({ theme }) => theme.spacing(2)}; + text-align: center; +`; + +const StyledDropdownItem = styled.div` + padding: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + + &:hover { + background-color: ${({ theme }) => theme.background.transparent.lighter}; + } +`; + +const StyledItemName = styled.div` + color: ${({ theme }) => theme.font.color.secondary}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledEmptyState = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + padding: ${({ theme }) => theme.spacing(2)}; + text-align: center; +`; + +type EntityData = Agent | ApiKeyForRole; + +type SettingsRoleAssignmentEntityPickerDropdownProps = { + entityType: 'agent' | 'apiKey'; + excludedIds: string[]; + onSelect: (entity: EntityData) => void; +}; + +export const SettingsRoleAssignmentEntityPickerDropdown = ({ + entityType, + excludedIds, + onSelect, +}: SettingsRoleAssignmentEntityPickerDropdownProps) => { + const [searchFilter, setSearchFilter] = useState(''); + const { t } = useLingui(); + + const isAgent = entityType === 'agent'; + + const { data: agentsData, loading: agentsLoading } = useFindManyAgentsQuery({ + skip: !isAgent, + }); + const { data: apiKeysData, loading: apiKeysLoading } = useGetApiKeysQuery({ + skip: isAgent, + }); + + const loading = isAgent ? agentsLoading : apiKeysLoading; + + const entities = ((isAgent + ? agentsData?.findManyAgents.filter((agent) => agent.isCustom) + : apiKeysData?.apiKeys) || []) as EntityData[]; + + const placeholder = isAgent ? t`Search agents` : t`Search API keys`; + + const getEmptyStateMessage = () => { + if (searchFilter !== '') { + return isAgent + ? t`No agents match your search` + : t`No API keys match your search`; + } else { + return isAgent ? t`No agents available` : t`No API keys available`; + } + }; + + const filteredEntities = entities.filter((entity) => { + const isExcluded = excludedIds.includes(entity.id); + + if (isExcluded) { + return false; + } + + if (isAgent) { + const agent = entity as Agent; + return ( + agent.name.toLowerCase().includes(searchFilter.toLowerCase()) || + agent.label.toLowerCase().includes(searchFilter.toLowerCase()) + ); + } else { + return (entity as ApiKeyForRole).name + .toLowerCase() + .includes(searchFilter.toLowerCase()); + } + }); + + return ( + + setSearchFilter(event.target.value)} + placeholder={placeholder} + /> + + + {loading ? ( + {t`Loading...`} + ) : filteredEntities.length > 0 ? ( + filteredEntities.map((entity) => ( + onSelect(entity as EntityData)} + > + + {isAgent ? (entity as Agent).label : entity.name} + + + )) + ) : ( + {getEmptyStateMessage()} + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTable.tsx b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTable.tsx new file mode 100644 index 0000000000..a2a9c9f731 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTable.tsx @@ -0,0 +1,176 @@ +import { + SettingsRoleAssignmentTableRow, + type RoleTarget, +} from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentTableRow'; +import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { useState } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { H2Title, IconSearch } from 'twenty-ui/display'; +import { type Agent, type WorkspaceMember } from '~/generated-metadata/graphql'; +import { type ApiKeyForRole } from '~/generated/graphql'; + +const StyledTable = styled.div` + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; +`; + +const StyledTableRows = styled.div` + gap: ${({ theme }) => theme.spacing(0.5)}; + padding-block: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledEmptyState = styled(TableCell)` + color: ${({ theme }) => theme.font.color.tertiary}; +`; + +const StyledSearchContainer = styled.div` + padding-bottom: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledSearchInput = styled(SettingsTextInput)` + input { + background: ${({ theme }) => theme.background.transparent.lighter}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + } +`; + +type RoleTargetType = 'member' | 'agent' | 'apiKey'; + +type SettingsRoleAssignmentTableProps = { + roleTargetType: T; + roleId: string; +}; + +export const SettingsRoleAssignmentTable = ({ + roleTargetType, + roleId, +}: SettingsRoleAssignmentTableProps) => { + const settingsDraftRole = useRecoilValue( + settingsDraftRoleFamilyState(roleId), + ); + const [searchFilter, setSearchFilter] = useState(''); + + const handleSearchChange = (text: string) => { + setSearchFilter(text); + }; + + const tableConfig = { + member: { + columns: [t`Name`, t`Email`], + displayName: t`Members`, + emptyStateText: t`No members assigned`, + roleTargets: settingsDraftRole.workspaceMembers, + }, + agent: { + columns: [t`Name`, t`Description`], + displayName: t`Agents`, + emptyStateText: t`No agents assigned`, + roleTargets: settingsDraftRole.agents, + }, + apiKey: { + columns: [t`Name`, t`Expires`], + displayName: t`API keys`, + emptyStateText: t`No API keys assigned`, + roleTargets: settingsDraftRole.apiKeys, + }, + }; + + const roleTargetDisplayName = tableConfig[roleTargetType].displayName; + + const roleTargets = tableConfig[roleTargetType].roleTargets; + + const getSearchableFields = ( + roleTarget: WorkspaceMember | Agent | ApiKeyForRole, + ): string[] => { + switch (roleTargetType) { + case 'member': { + const member = roleTarget as WorkspaceMember; + return [ + member.name.firstName?.toLowerCase() || '', + member.name.lastName?.toLowerCase() || '', + member.userEmail?.toLowerCase() || '', + ]; + } + case 'agent': { + const agent = roleTarget as Agent; + return [ + agent.name?.toLowerCase() || '', + agent.label?.toLowerCase() || '', + agent.description?.toLowerCase() || '', + ]; + } + case 'apiKey': { + const apiKey = roleTarget as ApiKeyForRole; + return [apiKey.name?.toLowerCase() || '']; + } + } + }; + + const filteredRoleTargets = !searchFilter + ? roleTargets + : roleTargets.filter((roleTarget) => { + const searchTerm = searchFilter.toLowerCase(); + const searchableFields = getSearchableFields(roleTarget); + return searchableFields.some((field) => field.includes(searchTerm)); + }); + + const createRoleTarget = ( + roleTarget: WorkspaceMember | Agent | ApiKeyForRole, + ): RoleTarget => { + switch (roleTargetType) { + case 'member': + return { type: roleTargetType, data: roleTarget as WorkspaceMember }; + case 'agent': + return { type: roleTargetType, data: roleTarget as Agent }; + case 'apiKey': + return { type: roleTargetType, data: roleTarget as ApiKeyForRole }; + } + }; + + return ( + <> + + + + + + + {tableConfig[roleTargetType].columns.map((column) => ( + {column} + ))} + + + {filteredRoleTargets.map((roleTarget) => ( + + ))} + + {filteredRoleTargets.length === 0 && ( + + {tableConfig[roleTargetType].emptyStateText} + + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTableRow.tsx b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTableRow.tsx index a47eaa63c2..cc80061f80 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentTableRow.tsx @@ -1,16 +1,26 @@ import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { UserContext } from '@/users/contexts/UserContext'; +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; -import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui/display'; -import { type WorkspaceMember } from '~/generated-metadata/graphql'; +import { + Avatar, + IconKey, + OverflowingTextWithTooltip, + useIcons, +} from 'twenty-ui/display'; +import { type Agent, type WorkspaceMember } from '~/generated-metadata/graphql'; +import { type ApiKeyForRole } from '~/generated/graphql'; +import { dateLocaleState } from '~/localization/states/dateLocaleState'; +import { formatDateString } from '~/utils/string/formatDateString'; const StyledIconWrapper = styled.div` align-items: center; display: flex; flex-shrink: 0; - margin-right: ${({ theme }) => theme.spacing(2)}; `; const StyledNameCell = styled.div` @@ -23,6 +33,7 @@ const StyledNameContainer = styled.div` align-items: center; display: flex; overflow: hidden; + gap: ${({ theme }) => theme.spacing(2)}; width: 100%; `; @@ -30,40 +41,91 @@ const StyledTableCell = styled(TableCell)` overflow: hidden; `; +export type RoleTarget = + | { type: 'member'; data: WorkspaceMember } + | { type: 'agent'; data: Agent } + | { type: 'apiKey'; data: ApiKeyForRole }; + type SettingsRoleAssignmentTableRowProps = { - workspaceMember: WorkspaceMember; + roleTarget: RoleTarget; }; export const SettingsRoleAssignmentTableRow = ({ - workspaceMember, + roleTarget, }: SettingsRoleAssignmentTableRowProps) => { + const theme = useTheme(); const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState); - const enrichedWorkspaceMember = currentWorkspaceMembers.find( - (member) => member.id === workspaceMember.id, - ); + const { getIcon } = useIcons(); + const { dateFormat, timeZone } = useContext(UserContext); + const dateLocale = useRecoilValue(dateLocaleState); + + const renderIcon = () => { + switch (roleTarget.type) { + case 'member': { + const enrichedWorkspaceMember = currentWorkspaceMembers.find( + (member) => member.id === roleTarget.data.id, + ); + return ( + + ); + } + case 'agent': { + const Icon = getIcon(roleTarget.data.icon || 'IconRobot'); + return ; + } + case 'apiKey': { + return ; + } + } + }; + + const renderName = () => { + switch (roleTarget.type) { + case 'member': + return `${roleTarget.data.name.firstName} ${roleTarget.data.name.lastName}`; + case 'agent': + return roleTarget.data.label; + case 'apiKey': + return roleTarget.data.name; + } + }; + + const renderSecondaryInfo = () => { + switch (roleTarget.type) { + case 'member': + return roleTarget.data.userEmail; + case 'agent': + return roleTarget.data.description; + case 'apiKey': + return roleTarget.data.expiresAt + ? formatDateString({ + value: roleTarget.data.expiresAt, + timeZone, + dateFormat, + localeCatalog: dateLocale.localeCatalog, + }) + : 'Never expires'; + } + }; return ( - - - + {renderIcon()} - + - + ); diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/constants/RoleTargetConfig.ts b/packages/twenty-front/src/modules/settings/roles/role-assignment/constants/RoleTargetConfig.ts new file mode 100644 index 0000000000..75e3b04b90 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/constants/RoleTargetConfig.ts @@ -0,0 +1,67 @@ +import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; +import { SettingsRoleAssignmentEntityPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown'; +import { SettingsRoleAssignmentWorkspaceMemberPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown'; +import { t } from '@lingui/core/macro'; +import { + type Agent, + type Role, + type WorkspaceMember, +} from '~/generated-metadata/graphql'; +import { type ApiKeyForRole } from '~/generated/graphql'; +import { type RoleMaps } from '../types/role-maps'; + +export const ROLE_TARGET_CONFIG = { + member: { + dropdownId: 'role-member-select', + getRoleMap: (maps: RoleMaps) => maps.member, + getName: (entity: CurrentWorkspaceMember) => + `${entity.name.firstName} ${entity.name.lastName}`, + getAssignedIds: (settingsDraftRole: Role) => + settingsDraftRole.workspaceMembers.map( + (member: WorkspaceMember) => member.id, + ), + getExcludedIds: (assignedIds: string[], currentMemberId?: string) => + currentMemberId ? [...assignedIds, currentMemberId] : assignedIds, + canBeAssigned: (settingsDraftRole: Role) => + settingsDraftRole.canBeAssignedToUsers, + buttonTitle: () => t`Assign to member`, + dropdownComponent: SettingsRoleAssignmentWorkspaceMemberPickerDropdown, + // Tooltip configuration for member type + tooltip: { + anchorId: 'assign-member', + content: () => t`All workspace members already have this role`, + shouldShow: (allWorkspaceMembersHaveThisRole?: boolean) => + allWorkspaceMembersHaveThisRole ?? false, + }, + }, + agent: { + dropdownId: 'role-agent-select', + getRoleMap: (maps: RoleMaps) => maps.agent, + getName: (entity: Agent) => entity.label, + getAssignedIds: (settingsDraftRole: Role) => + (settingsDraftRole.agents || []).map((agent: Agent) => agent.id), + getExcludedIds: (assignedIds: string[]) => assignedIds, + canBeAssigned: (settingsDraftRole: Role) => + settingsDraftRole.canBeAssignedToAgents, + buttonTitle: () => t`Assign to agent`, + dropdownComponent: SettingsRoleAssignmentEntityPickerDropdown, + // No tooltip for agent type + tooltip: null, + }, + apiKey: { + dropdownId: 'role-api-key-select', + getRoleMap: (maps: RoleMaps) => maps.apiKey, + getName: (entity: ApiKeyForRole) => entity.name, + getAssignedIds: (settingsDraftRole: Role) => + (settingsDraftRole.apiKeys || []).map( + (apiKey: ApiKeyForRole) => apiKey.id, + ), + getExcludedIds: (assignedIds: string[]) => assignedIds, + canBeAssigned: (settingsDraftRole: Role) => + settingsDraftRole.canBeAssignedToApiKeys, + buttonTitle: () => t`Assign to API key`, + dropdownComponent: SettingsRoleAssignmentEntityPickerDropdown, + // No tooltip for apiKey type + tooltip: null, + }, +} as const; diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember.ts b/packages/twenty-front/src/modules/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedRoleTarget.ts similarity index 50% rename from packages/twenty-front/src/modules/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember.ts rename to packages/twenty-front/src/modules/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedRoleTarget.ts index 903bd61e51..a235f1c323 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember.ts +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedRoleTarget.ts @@ -1,7 +1,8 @@ -export type SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember = { +export type SettingsRoleAssignmentConfirmationModalSelectedRoleTarget = { id: string; name: string; role?: { id: string; label: string }; colorScheme?: string; userEmail?: string; + entityType: 'member' | 'agent' | 'apiKey'; }; diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/types/role-maps.ts b/packages/twenty-front/src/modules/settings/roles/role-assignment/types/role-maps.ts new file mode 100644 index 0000000000..7e4d1ea954 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/types/role-maps.ts @@ -0,0 +1,5 @@ +export type RoleMaps = { + member: Map; + agent: Map; + apiKey: Map; +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/utils/build-role-maps.ts b/packages/twenty-front/src/modules/settings/roles/role-assignment/utils/build-role-maps.ts new file mode 100644 index 0000000000..9c325d7339 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/utils/build-role-maps.ts @@ -0,0 +1,24 @@ +import { type Role } from '~/generated-metadata/graphql'; +import { type RoleMaps } from '../types/role-maps'; + +export const buildRoleMaps = (roles: Role[]): RoleMaps => { + const maps: RoleMaps = { + member: new Map(), + agent: new Map(), + apiKey: new Map(), + }; + + roles.forEach((role) => { + role.workspaceMembers.forEach((member) => { + maps.member.set(member.id, { id: role.id, label: role.label }); + }); + role.agents?.forEach((agent) => { + maps.agent.set(agent.id, { id: role.id, label: role.label }); + }); + role.apiKeys?.forEach((apiKey) => { + maps.apiKey.set(apiKey.id, { id: role.id, label: role.label }); + }); + }); + + return maps; +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/field-permissions/utils/tests/getRoleWithRemovedFieldPermission.spec.ts b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/field-permissions/utils/tests/getRoleWithRemovedFieldPermission.spec.ts index cefce982b6..35436f9fbc 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/field-permissions/utils/tests/getRoleWithRemovedFieldPermission.spec.ts +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/field-permissions/utils/tests/getRoleWithRemovedFieldPermission.spec.ts @@ -17,9 +17,14 @@ const BASE_ROLE_MOCK: Role = { canSoftDeleteAllObjectRecords: true, canUpdateAllObjectRecords: true, canUpdateAllSettings: true, + canBeAssignedToUsers: true, + canBeAssignedToAgents: true, + canBeAssignedToApiKeys: true, id: '1', isEditable: true, label: 'Role base', + agents: [], + apiKeys: [], workspaceMembers: [], fieldPermissions: [BASE_FIELD_PERMISSION], }; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/field-permissions/utils/tests/getRoleWithUpsertedFieldPermission.spec.ts b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/field-permissions/utils/tests/getRoleWithUpsertedFieldPermission.spec.ts index 6c2b1bf7b6..5f723c9150 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/field-permissions/utils/tests/getRoleWithUpsertedFieldPermission.spec.ts +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/field-permissions/utils/tests/getRoleWithUpsertedFieldPermission.spec.ts @@ -8,9 +8,14 @@ const BASE_ROLE_MOCK: Role = { canSoftDeleteAllObjectRecords: true, canUpdateAllObjectRecords: true, canUpdateAllSettings: true, + canBeAssignedToUsers: true, + canBeAssignedToAgents: true, + canBeAssignedToApiKeys: true, id: '1', isEditable: true, label: 'Role base', + agents: [], + apiKeys: [], workspaceMembers: [], }; diff --git a/packages/twenty-front/src/modules/settings/roles/role-settings/components/SettingsRoleApplicability.tsx b/packages/twenty-front/src/modules/settings/roles/role-settings/components/SettingsRoleApplicability.tsx new file mode 100644 index 0000000000..f3b857544b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-settings/components/SettingsRoleApplicability.tsx @@ -0,0 +1,110 @@ +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; + +import { H2Title, IconKey, IconRobot, IconUsers } from 'twenty-ui/display'; +import { Checkbox } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { FeatureFlagKey } from '~/generated/graphql'; + +const StyledCheckboxContainer = styled.div<{ disabled: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + padding: ${({ theme }) => theme.spacing(1)}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + transition: background-color + ${({ theme }) => theme.animation.duration.normal}s ease; + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; + + &:hover { + background-color: ${({ theme }) => theme.background.transparent.light}; + } +`; + +const StyledCheckboxLabel = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +type SettingsRoleApplicabilityValues = { + canBeAssignedToUsers: boolean; + canBeAssignedToAgents: boolean; + canBeAssignedToApiKeys: boolean; +}; + +type SettingsRoleApplicabilityProps = { + values: SettingsRoleApplicabilityValues; + onApplicabilityChange: ( + key: keyof SettingsRoleApplicabilityValues, + value: boolean, + ) => void; + isEditable: boolean; +}; + +export const SettingsRoleApplicability = ({ + values, + onApplicabilityChange, + isEditable, +}: SettingsRoleApplicabilityProps) => { + const theme = useTheme(); + + const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); + + const options = [ + { + key: 'canBeAssignedToUsers' as const, + label: t`Assignable to team members`, + Icon: IconUsers, + }, + { + key: 'canBeAssignedToAgents' as const, + label: t`Assignable to Agents`, + Icon: IconRobot, + }, + ...(isAiEnabled + ? [ + { + key: 'canBeAssignedToApiKeys' as const, + label: t`Assignable to API Keys`, + Icon: IconKey, + }, + ] + : []), + ]; + return ( +
+ +
+ {options.map((option) => ( + + isEditable && + onApplicabilityChange(option.key, !values[option.key]) + } + > + + + {option.label} + + { + event.stopPropagation(); + onApplicabilityChange(option.key, event.target.checked); + }} + disabled={!isEditable} + /> + + ))} +
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-settings/components/SettingsRoleSettings.tsx b/packages/twenty-front/src/modules/settings/roles/role-settings/components/SettingsRoleSettings.tsx index b045a13fa8..8f98404636 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-settings/components/SettingsRoleSettings.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-settings/components/SettingsRoleSettings.tsx @@ -2,6 +2,7 @@ import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; import { useRecoilState } from 'recoil'; +import { SettingsRoleApplicability } from '@/settings/roles/role-settings/components/SettingsRoleApplicability'; import { SettingsRoleSettingsDeleteRoleConfirmationModal } from '@/settings/roles/role-settings/components/SettingsRoleSettingsDeleteRoleConfirmationModal'; import { ROLE_SETTINGS_DELETE_ROLE_CONFIRMATION_MODAL_ID } from '@/settings/roles/role-settings/components/constants/RoleSettingsDeleteRoleConfirmationModalId'; import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; @@ -91,6 +92,21 @@ export const SettingsRoleSettings = ({ /> + { + setSettingsDraftRole({ + ...settingsDraftRole, + [key]: value, + }); + }} + isEditable={isEditable} + /> + {!isCreateMode && ( <>
diff --git a/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRoleCreateEffect.tsx b/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRoleCreateEffect.tsx index bb691b225f..1e3fe34aa2 100644 --- a/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRoleCreateEffect.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRoleCreateEffect.tsx @@ -47,8 +47,13 @@ export const SettingsRoleCreateEffect = ({ canUpdateAllObjectRecords: true, canSoftDeleteAllObjectRecords: true, canDestroyAllObjectRecords: true, + canBeAssignedToUsers: true, + canBeAssignedToAgents: true, + canBeAssignedToApiKeys: true, isEditable: true, workspaceMembers: [], + apiKeys: [], + agents: [], }; setSettingsPersistedRole(undefined); 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 78947028b6..c2ca8b9048 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 @@ -1,4 +1,6 @@ import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery'; +import { useUpdateAgentRole } from '@/settings/roles/hooks/useUpdateAgentRole'; +import { useUpdateApiKeyRole } from '@/settings/roles/hooks/useUpdateApiKeyRole'; import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole'; import { useRemoveFieldPermissionInDraftRole } from '@/settings/roles/role-permissions/object-level-permissions/field-permissions/hooks/useRemoveFieldPermissionInDraftRole'; import { newFieldPermissionsFilter } from '@/settings/roles/role/hooks/utils/newFieldPermissionsFilter.util'; @@ -30,6 +32,9 @@ const ROLE_BASIC_KEYS: Array = [ 'canUpdateAllObjectRecords', 'canSoftDeleteAllObjectRecords', 'canDestroyAllObjectRecords', + 'canBeAssignedToUsers', + 'canBeAssignedToAgents', + 'canBeAssignedToApiKeys', ]; export const useSaveDraftRoleToDB = ({ @@ -45,6 +50,8 @@ export const useSaveDraftRoleToDB = ({ const [upsertObjectPermissions] = useUpsertObjectPermissionsMutation(); const [upsertFieldPermissions] = useUpsertFieldPermissionsMutation(); const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId); + const { addAgentsToRole } = useUpdateAgentRole(roleId); + const { addApiKeysToRole } = useUpdateApiKeyRole(roleId); const navigateSettings = useNavigateSettings(); const settingsPersistedRole = useRecoilValue( @@ -125,6 +132,9 @@ export const useSaveDraftRoleToDB = ({ settingsDraftRole.canSoftDeleteAllObjectRecords, canDestroyAllObjectRecords: settingsDraftRole.canDestroyAllObjectRecords, + canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers, + canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents, + canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys, } satisfies Partial, }, refetchQueries: [getOperationName(GET_ROLES) ?? ''], @@ -191,7 +201,10 @@ export const useSaveDraftRoleToDB = ({ }); } - if (isDefined(dirtyFields.workspaceMembers)) { + if ( + isDefined(dirtyFields.workspaceMembers) && + settingsDraftRole.canBeAssignedToUsers + ) { await addWorkspaceMembersToRole({ roleId: data.createOneRole.id, workspaceMemberIds: settingsDraftRole.workspaceMembers.map( @@ -200,6 +213,26 @@ export const useSaveDraftRoleToDB = ({ }); } + 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, }); @@ -238,6 +271,10 @@ export const useSaveDraftRoleToDB = ({ settingsDraftRole.canSoftDeleteAllObjectRecords, canDestroyAllObjectRecords: settingsDraftRole.canDestroyAllObjectRecords, + canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers, + canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents, + canBeAssignedToApiKeys: + settingsDraftRole.canBeAssignedToApiKeys, }, }, }, diff --git a/packages/twenty-front/src/modules/settings/roles/states/settingsDraftRoleFamilyState.ts b/packages/twenty-front/src/modules/settings/roles/states/settingsDraftRoleFamilyState.ts index 4a040f5762..235e23e773 100644 --- a/packages/twenty-front/src/modules/settings/roles/states/settingsDraftRoleFamilyState.ts +++ b/packages/twenty-front/src/modules/settings/roles/states/settingsDraftRoleFamilyState.ts @@ -19,5 +19,10 @@ export const settingsDraftRoleFamilyState = createFamilyState({ permissionFlags: [], objectPermissions: [], fieldPermissions: [], + canBeAssignedToAgents: false, + canBeAssignedToApiKeys: false, + canBeAssignedToUsers: false, + agents: [], + apiKeys: [], }, }); diff --git a/packages/twenty-front/src/pages/settings/ai/SettingsAgentForm.tsx b/packages/twenty-front/src/pages/settings/ai/SettingsAgentForm.tsx index 4dababc20a..f82421283f 100644 --- a/packages/twenty-front/src/pages/settings/ai/SettingsAgentForm.tsx +++ b/packages/twenty-front/src/pages/settings/ai/SettingsAgentForm.tsx @@ -175,7 +175,7 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => { navigate(SettingsPath.AI)} @@ -210,6 +210,7 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => { {isEditMode && agent && formValues.isCustom && (
diff --git a/packages/twenty-front/src/pages/settings/ai/forms/components/SettingsAIAgentForm.tsx b/packages/twenty-front/src/pages/settings/ai/forms/components/SettingsAIAgentForm.tsx index f5482ec0a1..2bfdd443d5 100644 --- a/packages/twenty-front/src/pages/settings/ai/forms/components/SettingsAIAgentForm.tsx +++ b/packages/twenty-front/src/pages/settings/ai/forms/components/SettingsAIAgentForm.tsx @@ -39,11 +39,13 @@ type SettingsAIAgentFormProps = { field: keyof SettingsAIAgentFormValues, value: SettingsAIAgentFormValues[keyof SettingsAIAgentFormValues], ) => void; + disabled: boolean; }; export const SettingsAIAgentForm = ({ formValues, onFieldChange, + disabled, }: SettingsAIAgentFormProps) => { const { t } = useLingui(); @@ -51,10 +53,12 @@ export const SettingsAIAgentForm = ({ const { data: rolesData } = useGetRolesQuery(); const rolesOptions = - rolesData?.getRoles?.map((role) => ({ - label: role.label, - value: role.id, - })) || []; + rolesData?.getRoles + ?.filter((role) => role.canBeAssignedToAgents) + .map((role) => ({ + label: role.label, + value: role.id, + })) || []; const noModelsAvailable = modelOptions.length === 0; @@ -72,6 +76,7 @@ export const SettingsAIAgentForm = ({ onChange={({ iconKey }) => { onFieldChange('icon', iconKey); }} + disabled={disabled} /> @@ -84,6 +89,7 @@ export const SettingsAIAgentForm = ({ fillNameFromLabel(value); }} fullWidth + disabled={disabled} /> @@ -96,6 +102,7 @@ export const SettingsAIAgentForm = ({ minRows={3} value={formValues.description || ''} onChange={(value) => onFieldChange('description', value)} + disabled={disabled} /> @@ -106,7 +113,7 @@ export const SettingsAIAgentForm = ({ value={formValues.modelId} onChange={(value) => onFieldChange('modelId', value)} options={modelOptions} - disabled={noModelsAvailable} + disabled={noModelsAvailable || disabled} /> {noModelsAvailable && ( @@ -126,6 +133,7 @@ export const SettingsAIAgentForm = ({ label: t`Select a role`, value: '', }} + disabled={disabled} /> @@ -137,6 +145,7 @@ export const SettingsAIAgentForm = ({ minRows={6} value={formValues.prompt} onChange={(value) => onFieldChange('prompt', value)} + disabled={disabled} /> diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx index 88d26654fa..71cc7d4528 100644 --- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx +++ b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx @@ -39,7 +39,11 @@ export const SettingsDevelopersApiKeysNew = () => { const defaultRole = data.getRoles.find( (role) => role.id === currentWorkspace?.defaultRole?.id, ); - if (isDefined(defaultRole) && !formValues.roleId) { + if ( + isDefined(defaultRole) && + defaultRole.canBeAssignedToApiKeys && + !formValues.roleId + ) { setFormValues((prev) => ({ ...prev, roleId: defaultRole.id, diff --git a/packages/twenty-front/src/testing/mock-data/roles.ts b/packages/twenty-front/src/testing/mock-data/roles.ts index b5a538fd48..cd4680f1e4 100644 --- a/packages/twenty-front/src/testing/mock-data/roles.ts +++ b/packages/twenty-front/src/testing/mock-data/roles.ts @@ -15,6 +15,11 @@ const rolesMock: Role[] = [ canUpdateAllSettings: true, canAccessAllTools: true, isEditable: false, + canBeAssignedToUsers: true, + canBeAssignedToAgents: true, + canBeAssignedToApiKeys: true, + agents: [], + apiKeys: [], workspaceMembers: [mockWorkspaceMembers[0]], permissionFlags: [ { @@ -48,6 +53,11 @@ const rolesMock: Role[] = [ canUpdateAllSettings: false, canAccessAllTools: false, isEditable: true, + canBeAssignedToUsers: true, + canBeAssignedToAgents: true, + canBeAssignedToApiKeys: true, + agents: [], + apiKeys: [], workspaceMembers: [mockWorkspaceMembers[1]], permissionFlags: [], objectPermissions: [ diff --git a/packages/twenty-front/src/testing/mock-data/roles/roles-mock.ts b/packages/twenty-front/src/testing/mock-data/roles/roles-mock.ts index e7aa30b43c..f79bd5186e 100644 --- a/packages/twenty-front/src/testing/mock-data/roles/roles-mock.ts +++ b/packages/twenty-front/src/testing/mock-data/roles/roles-mock.ts @@ -59,9 +59,14 @@ export const BASE_ROLE_MOCK_GRANTS_ALL: Role = { canSoftDeleteAllObjectRecords: true, canDestroyAllObjectRecords: true, canUpdateAllSettings: true, + canBeAssignedToUsers: true, + canBeAssignedToAgents: true, + canBeAssignedToApiKeys: true, id: MOCK_ROLE_ID_GRANTS_ALL, isEditable: true, label: 'Role grants all', + agents: [], + apiKeys: [], workspaceMembers: [], fieldPermissions: [ { ...BASE_FIELD_PERMISSION, roleId: MOCK_ROLE_ID_GRANTS_ALL }, @@ -84,9 +89,14 @@ export const BASE_ROLE_MOCK_REVOKES_ALL: Role = { canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, canUpdateAllSettings: true, + canBeAssignedToUsers: true, + canBeAssignedToAgents: true, + canBeAssignedToApiKeys: true, id: MOCK_ROLE_ID_REVOKES_ALL, isEditable: true, label: 'Role revokes all', + agents: [], + apiKeys: [], workspaceMembers: [], fieldPermissions: [ { ...BASE_FIELD_PERMISSION, roleId: MOCK_ROLE_ID_REVOKES_ALL }, diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1756434280823-AddRoleApplicabilityFields.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756434280823-AddRoleApplicabilityFields.ts new file mode 100644 index 0000000000..e30763a33f --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756434280823-AddRoleApplicabilityFields.ts @@ -0,0 +1,35 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddRoleApplicabilityFields1756434280823 + implements MigrationInterface +{ + name = 'AddRoleApplicabilityFields1756434280823'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."role" ADD "canBeAssignedToUsers" boolean NOT NULL DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "core"."role" ADD "canBeAssignedToAgents" boolean NOT NULL DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "core"."role" ADD "canBeAssignedToApiKeys" boolean NOT NULL DEFAULT true`, + ); + + await queryRunner.query( + `UPDATE "core"."role" SET "canBeAssignedToUsers" = true, "canBeAssignedToAgents" = true, "canBeAssignedToApiKeys" = true`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."role" DROP COLUMN "canBeAssignedToApiKeys"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."role" DROP COLUMN "canBeAssignedToAgents"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."role" DROP COLUMN "canBeAssignedToUsers"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key-role.service.spec.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key-role.service.spec.ts index 284afe1f45..1dcd2f6b42 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/api-key-role.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key-role.service.spec.ts @@ -55,6 +55,9 @@ describe('ApiKeyRoleService', () => { canUpdateAllObjectRecords: true, canSoftDeleteAllObjectRecords: true, canDestroyAllObjectRecords: true, + canBeAssignedToAgents: false, + canBeAssignedToUsers: true, + canBeAssignedToApiKeys: true, }; const mockNewRole: Partial = { @@ -427,6 +430,10 @@ describe('ApiKeyRoleService', () => { canUpdateAllObjectRecords: true, canSoftDeleteAllObjectRecords: true, canDestroyAllObjectRecords: true, + canBeAssignedToAgents: false, + canBeAssignedToUsers: true, + canBeAssignedToApiKeys: true, + standardId: undefined, }); }); diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key-role.service.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key-role.service.ts index bc074f1061..c06bb73033 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/api-key-role.service.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key-role.service.ts @@ -1,7 +1,14 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { DataSource, type EntityManager, In, Repository } from 'typeorm'; +import { + DataSource, + type EntityManager, + In, + IsNull, + Not, + Repository, +} from 'typeorm'; import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { @@ -145,6 +152,13 @@ export class ApiKeyRoleService { ); } + if (!role.canBeAssignedToApiKeys) { + throw new ApiKeyException( + `Role "${role.label}" cannot be assigned to API keys`, + ApiKeyExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS, + ); + } + const existingRoleTarget = await this.roleTargetsRepository.findOne({ where: { apiKeyId, @@ -190,4 +204,35 @@ export class ApiKeyRoleService { return rolesMap; } + + public async getApiKeysAssignedToRole( + roleId: string, + workspaceId: string, + ): Promise { + const roleTargets = await this.roleTargetsRepository.find({ + where: { + roleId, + workspaceId, + apiKeyId: Not(IsNull()), + }, + }); + + const apiKeyIds = roleTargets + .map((roleTarget) => roleTarget.apiKeyId) + .filter((apiKeyId): apiKeyId is string => apiKeyId !== null); + + if (!apiKeyIds.length) { + return []; + } + + const apiKeys = await this.apiKeyRepository.find({ + where: { + id: In(apiKeyIds), + workspaceId, + revokedAt: IsNull(), + }, + }); + + return apiKeys; + } } diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.exception.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.exception.ts index d9fa1418bb..e381d6d5a4 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/api-key.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.exception.ts @@ -7,4 +7,5 @@ export enum ApiKeyExceptionCode { API_KEY_REVOKED = 'API_KEY_REVOKED', API_KEY_EXPIRED = 'API_KEY_EXPIRED', API_KEY_NO_ROLE_ASSIGNED = 'API_KEY_NO_ROLE_ASSIGNED', + ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS = 'ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS', } diff --git a/packages/twenty-server/src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util.ts index e8a173d88b..6a49f8262b 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util.ts @@ -27,6 +27,10 @@ export const apiKeyGraphqlApiExceptionHandler = (error: Error) => { throw new ForbiddenError(error.message, { userFriendlyMessage: error.userFriendlyMessage, }); + case ApiKeyExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); default: { return assertUnreachable(error.code); } diff --git a/packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.service.spec.ts index c6e9c95eca..0974c2b5f9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.service.spec.ts @@ -87,6 +87,9 @@ describe('AgentRoleService', () => { canUpdateAllObjectRecords: false, canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, + canBeAssignedToAgents: true, + canBeAssignedToUsers: true, + canBeAssignedToApiKeys: true, workspaceId: testWorkspaceId, createdAt: new Date(), updatedAt: new Date(), @@ -102,6 +105,9 @@ describe('AgentRoleService', () => { canUpdateAllObjectRecords: true, canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, + canBeAssignedToAgents: true, + canBeAssignedToUsers: true, + canBeAssignedToApiKeys: true, workspaceId: testWorkspaceId, createdAt: new Date(), updatedAt: new Date(), @@ -288,7 +294,7 @@ describe('AgentRoleService', () => { roleId: nonExistentRoleId, }), ).rejects.toMatchObject({ - code: AgentExceptionCode.AGENT_EXECUTION_FAILED, + code: AgentExceptionCode.ROLE_NOT_FOUND, message: `Role with id ${nonExistentRoleId} not found in workspace`, }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.service.ts index 8546062baf..85ce95fce4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Not, Repository } from 'typeorm'; +import { In, IsNull, Not, Repository } from 'typeorm'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { @@ -54,6 +54,33 @@ export class AgentRoleService { }); } + public async assignStandardRoleToAgent({ + workspaceId, + agentId, + standardRoleId, + }: { + workspaceId: string; + agentId: string; + standardRoleId: string; + }) { + const role = await this.roleRepository.findOne({ + where: { standardId: standardRoleId, workspaceId }, + }); + + if (!role) { + throw new AgentException( + `Standard role with standard ID ${standardRoleId} not found in workspace`, + AgentExceptionCode.ROLE_NOT_FOUND, + ); + } + + await this.assignRoleToAgent({ + workspaceId, + agentId, + roleId: role.id, + }); + } + public async removeRoleFromAgent({ workspaceId, agentId, @@ -67,6 +94,36 @@ export class AgentRoleService { }); } + public async getAgentsAssignedToRole( + roleId: string, + workspaceId: string, + ): Promise { + const roleTargets = await this.roleTargetsRepository.find({ + where: { + roleId, + workspaceId, + agentId: Not(IsNull()), + }, + }); + + const agentIds = roleTargets + .map((roleTarget) => roleTarget.agentId) + .filter((agentId): agentId is string => agentId !== null); + + if (!agentIds.length) { + return []; + } + + const agents = await this.agentRepository.find({ + where: { + id: In(agentIds), + workspaceId, + }, + }); + + return agents; + } + private async validateAssignRoleInput({ agentId, workspaceId, @@ -94,7 +151,14 @@ export class AgentRoleService { if (!role) { throw new AgentException( `Role with id ${roleId} not found in workspace`, - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AgentExceptionCode.ROLE_NOT_FOUND, + ); + } + + if (!role.canBeAssignedToAgents) { + throw new AgentException( + `Role "${role.label}" cannot be assigned to agents`, + AgentExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool-generator.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool-generator.service.ts index 3e8774cbee..7823a0b990 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool-generator.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool-generator.service.ts @@ -49,6 +49,7 @@ export class AgentToolGeneratorService { id: roleId, workspaceId, }, + relations: ['permissionFlags'], }); if (!role) { diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts index 1d8990129b..0ba6ec7972 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts @@ -9,4 +9,5 @@ export enum AgentExceptionCode { USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND', ROLE_NOT_FOUND = 'ROLE_NOT_FOUND', HANDOFF_ALREADY_EXISTS = 'HANDOFF_ALREADY_EXISTS', + ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS', } diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts index 424132190e..ab17f6a406 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts @@ -38,10 +38,7 @@ export class AgentResolver { @Query(() => [AgentDTO]) @RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED) async findManyAgents(@AuthWorkspace() { id: workspaceId }: Workspace) { - return this.agentRepository.find({ - where: { workspaceId }, - order: { createdAt: 'DESC' }, - }); + return this.agentService.findManyAgents(workspaceId); } @Query(() => AgentDTO) diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts index ec915d2a27..89cdac6943 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service'; import { type CreateAgentInput } from 'src/engine/metadata-modules/agent/dtos/create-agent.input'; @@ -23,6 +23,37 @@ export class AgentService { private readonly agentRoleService: AgentRoleService, ) {} + async findManyAgents(workspaceId: string) { + const agents = await this.agentRepository.find({ + where: { workspaceId }, + order: { createdAt: 'DESC' }, + }); + + if (agents.length === 0) { + return []; + } + + const roleTargets = await this.roleTargetsRepository.find({ + where: { + workspaceId, + agentId: In(agents.map((agent) => agent.id)), + }, + }); + + const agentRoleMap = new Map(); + + roleTargets.forEach((roleTarget) => { + if (roleTarget.agentId) { + agentRoleMap.set(roleTarget.agentId, roleTarget.roleId); + } + }); + + return agents.map((agent) => ({ + ...agent, + roleId: agentRoleMap.get(agent.id) || null, + })); + } + async findOneAgent(id: string, workspaceId: string) { const agent = await this.agentRepository.findOne({ where: { id, workspaceId }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util.ts index 2aec0becd3..0224e6f23c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util.ts @@ -15,6 +15,9 @@ export const fromRoleEntityToFlatRole = (role: RoleEntity): FlatRole => { canUpdateAllObjectRecords: role.canUpdateAllObjectRecords, canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords, canDestroyAllObjectRecords: role.canDestroyAllObjectRecords, + canBeAssignedToUsers: role.canBeAssignedToUsers, + canBeAssignedToAgents: role.canBeAssignedToAgents, + canBeAssignedToApiKeys: role.canBeAssignedToApiKeys, workspaceId: role.workspaceId, universalIdentifier: role.standardId || role.id, }; diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts index e77a4d0c43..15d1f4db69 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts @@ -49,6 +49,8 @@ export enum PermissionsExceptionCode { EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'EMPTY_FIELD_PERMISSION_NOT_ALLOWED', JOIN_COLUMN_NAME_REQUIRED = 'JOIN_COLUMN_NAME_REQUIRED', COMPOSITE_TYPE_NOT_FOUND = 'COMPOSITE_TYPE_NOT_FOUND', + ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET = 'ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET', + ROLE_CANNOT_BE_ASSIGNED_TO_USERS = 'ROLE_CANNOT_BE_ASSIGNED_TO_USERS', } export enum PermissionsExceptionMessage { @@ -78,4 +80,6 @@ export enum PermissionsExceptionMessage { FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT = 'Field restriction on update only makes sense on updatable object', OBJECT_PERMISSION_NOT_FOUND = 'Object permission not found', EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'Empty field permission not allowed', + ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET = 'Role must be assignable to at least one target type', + ROLE_CANNOT_BE_ASSIGNED_TO_USERS = 'Role cannot be assigned to users', } diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts index c0c924c9dd..ce9ea4c919 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts @@ -41,6 +41,8 @@ export const permissionGraphqlApiExceptionHandler = ( case PermissionsExceptionCode.FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT: case PermissionsExceptionCode.FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT: case PermissionsExceptionCode.EMPTY_FIELD_PERMISSION_NOT_ALLOWED: + case PermissionsExceptionCode.ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET: + case PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS: throw new UserInputError(error); case PermissionsExceptionCode.ROLE_NOT_FOUND: case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND: diff --git a/packages/twenty-server/src/engine/metadata-modules/role/dtos/create-role-input.dto.ts b/packages/twenty-server/src/engine/metadata-modules/role/dtos/create-role-input.dto.ts index 0426e91415..7d268dc4e7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/dtos/create-role-input.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/dtos/create-role-input.dto.ts @@ -52,4 +52,19 @@ export class CreateRoleInput { @IsOptional() @Field({ nullable: true }) canDestroyAllObjectRecords?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canBeAssignedToUsers?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canBeAssignedToAgents?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canBeAssignedToApiKeys?: boolean; } diff --git a/packages/twenty-server/src/engine/metadata-modules/role/dtos/role.dto.ts b/packages/twenty-server/src/engine/metadata-modules/role/dtos/role.dto.ts index 3a17e108d3..f0be4dad6e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/dtos/role.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/dtos/role.dto.ts @@ -4,11 +4,27 @@ import { Relation } from 'typeorm'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; +import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto'; import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto'; import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto'; import { PermissionFlagDTO } from 'src/engine/metadata-modules/permission-flag/dtos/permission-flag.dto'; import { type RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; +@ObjectType('ApiKeyForRole') +export class ApiKeyForRoleDTO { + @Field(() => UUIDScalarType, { nullable: false }) + id: string; + + @Field({ nullable: false }) + name: string; + + @Field(() => Date, { nullable: false }) + expiresAt: Date; + + @Field(() => Date, { nullable: true }) + revokedAt?: Date | null; +} + @ObjectType('Role') export class RoleDTO { @Field(() => UUIDScalarType, { nullable: false }) @@ -29,12 +45,27 @@ export class RoleDTO { @Field({ nullable: false }) isEditable: boolean; + @Field({ nullable: false }) + canBeAssignedToUsers: boolean; + + @Field({ nullable: false }) + canBeAssignedToAgents: boolean; + + @Field({ nullable: false }) + canBeAssignedToApiKeys: boolean; + @HideField() roleTargets: Relation; @Field(() => [WorkspaceMember], { nullable: true }) workspaceMembers?: WorkspaceMember[]; + @Field(() => [AgentDTO], { nullable: true }) + agents?: AgentDTO[]; + + @Field(() => [ApiKeyForRoleDTO], { nullable: true }) + apiKeys?: ApiKeyForRoleDTO[]; + @Field({ nullable: false }) canUpdateAllSettings: boolean; diff --git a/packages/twenty-server/src/engine/metadata-modules/role/dtos/update-role-input.dto.ts b/packages/twenty-server/src/engine/metadata-modules/role/dtos/update-role-input.dto.ts index b10d9490d5..fec0d2a35d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/dtos/update-role-input.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/dtos/update-role-input.dto.ts @@ -56,6 +56,21 @@ export class UpdateRolePayload { @IsOptional() @Field({ nullable: true }) canDestroyAllObjectRecords?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canBeAssignedToUsers?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canBeAssignedToAgents?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canBeAssignedToApiKeys?: boolean; } @InputType() diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts index 81451c9e35..ed54b190c1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts @@ -62,6 +62,15 @@ export class RoleEntity { @Column({ nullable: false, default: true }) isEditable: boolean; + @Column({ nullable: false, default: true }) + canBeAssignedToUsers: boolean; + + @Column({ nullable: false, default: true }) + canBeAssignedToAgents: boolean; + + @Column({ nullable: false, default: true }) + canBeAssignedToApiKeys: boolean; + @OneToMany( () => RoleTargetsEntity, (roleTargets: RoleTargetsEntity) => roleTargets.role, diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts index 9fde81f25c..d303c7d092 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; @@ -22,6 +23,7 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor TypeOrmModule.forFeature([UserWorkspace, Workspace]), UserRoleModule, AgentRoleModule, + ApiKeyModule, PermissionsModule, UserWorkspaceModule, ObjectPermissionModule, diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts index 3f4450b7b0..ee0505865d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/graphql'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; @@ -22,6 +23,7 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service'; +import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto'; import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto'; import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto'; import { UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input'; @@ -39,7 +41,10 @@ import { } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { CreateRoleInput } from 'src/engine/metadata-modules/role/dtos/create-role-input.dto'; -import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; +import { + ApiKeyForRoleDTO, + RoleDTO, +} from 'src/engine/metadata-modules/role/dtos/role.dto'; import { UpdateRoleInput } from 'src/engine/metadata-modules/role/dtos/update-role-input.dto'; import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @@ -63,6 +68,7 @@ export class RoleResolver { private readonly objectPermissionService: ObjectPermissionService, private readonly settingPermissionService: PermissionFlagService, private readonly agentRoleService: AgentRoleService, + private readonly apiKeyRoleService: ApiKeyRoleService, private readonly fieldPermissionService: FieldPermissionService, ) {} @@ -243,4 +249,35 @@ export class RoleResolver { return workspaceMembers; } + + @ResolveField('agents', () => [AgentDTO]) + async getAgentsAssignedToRole( + @Parent() role: RoleDTO, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const agents = await this.agentRoleService.getAgentsAssignedToRole( + role.id, + workspace.id, + ); + + return agents; + } + + @ResolveField('apiKeys', () => [ApiKeyForRoleDTO]) + async getApiKeysAssignedToRole( + @Parent() role: RoleDTO, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const apiKeys = await this.apiKeyRoleService.getApiKeysAssignedToRole( + role.id, + workspace.id, + ); + + return apiKeys.map((apiKey) => ({ + id: apiKey.id, + name: apiKey.name, + expiresAt: apiKey.expiresAt, + revokedAt: apiKey.revokedAt, + })); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts index 5236907392..6e930f28a2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts @@ -83,6 +83,9 @@ export class RoleService { canUpdateAllObjectRecords: input.canUpdateAllObjectRecords, canSoftDeleteAllObjectRecords: input.canSoftDeleteAllObjectRecords, canDestroyAllObjectRecords: input.canDestroyAllObjectRecords, + canBeAssignedToUsers: input.canBeAssignedToUsers, + canBeAssignedToAgents: input.canBeAssignedToAgents, + canBeAssignedToApiKeys: input.canBeAssignedToApiKeys, isEditable: true, workspaceId, }); @@ -231,6 +234,9 @@ export class RoleService { canUpdateAllObjectRecords: false, canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, + canBeAssignedToUsers: true, + canBeAssignedToAgents: false, + canBeAssignedToApiKeys: false, isEditable: false, workspaceId, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util.ts b/packages/twenty-server/src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util.ts index 5769fc046d..95b43dd223 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util.ts @@ -15,6 +15,9 @@ export const fromRoleEntityToRoleDto = (role: RoleEntity): RoleDTO => { canUpdateAllObjectRecords: role.canUpdateAllObjectRecords, canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords, canDestroyAllObjectRecords: role.canDestroyAllObjectRecords, + canBeAssignedToUsers: role.canBeAssignedToUsers, + canBeAssignedToAgents: role.canBeAssignedToAgents, + canBeAssignedToApiKeys: role.canBeAssignedToApiKeys, roleTargets: role.roleTargets, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts index 79b6feafc5..f84aa8eaca 100644 --- a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts @@ -258,6 +258,17 @@ export class UserRoleService { ); } + if (!role.canBeAssignedToUsers) { + throw new PermissionsException( + `Role "${role.label}" cannot be assigned to users`, + PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS, + { + userFriendlyMessage: + 'This role cannot be assigned to users. Please select a different role.', + }, + ); + } + const roles = await this.getRolesByUserWorkspaces({ userWorkspaceIds: [userWorkspace.id], workspaceId, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-agent.comparator.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-agent.comparator.ts index 4c48d43e90..43916d447f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-agent.comparator.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-agent.comparator.ts @@ -49,11 +49,27 @@ export class WorkspaceAgentComparator { switch (difference.type) { case 'CREATE': { + const fromAgent = fromFlatAgents.find( + (agent) => keyFactory(agent) === universalIdentifier, + ); const toAgent = toFlatAgents.find( (agent) => keyFactory(agent) === universalIdentifier, ); - if (toAgent) { + if (!toAgent) { + break; + } + + if (fromAgent) { + fromAgent && + results.push({ + action: ComparatorAction.UPDATE, + object: { + ...toAgent, + id: fromAgent.id, + }, + }); + } else { results.push({ action: ComparatorAction.CREATE, object: toAgent, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-agent.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-agent.service.ts index 4bea693171..9777a15239 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-agent.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-agent.service.ts @@ -1,11 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { removePropertiesFromRecord } from 'twenty-shared/utils'; -import { IsNull, Not, type EntityManager, type Repository } from 'typeorm'; +import { IsNull, Not, type EntityManager } from 'typeorm'; import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface'; import { type WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; +import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { transformAgentEntityToFlatAgent } from 'src/engine/metadata-modules/flat-agent/utils/transform-agent-entity-to-flat-agent.util'; import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; @@ -19,7 +20,6 @@ import { WorkspaceAgentComparator } from 'src/engine/workspace-manager/workspace import { StandardAgentFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-agent.factory'; import { standardAgentDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents'; import { WORKFLOW_CREATION_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent'; -import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role'; @Injectable() export class WorkspaceSyncAgentService { @@ -28,6 +28,7 @@ export class WorkspaceSyncAgentService { constructor( private readonly standardAgentFactory: StandardAgentFactory, private readonly workspaceAgentComparator: WorkspaceAgentComparator, + private readonly agentRoleService: AgentRoleService, ) {} async synchronize( @@ -37,8 +38,6 @@ export class WorkspaceSyncAgentService { this.logger.log('Syncing standard agent.'); const agentRepository = manager.getRepository(AgentEntity); - const roleRepository = manager.getRepository(RoleEntity); - const roleTargetsRepository = manager.getRepository(RoleTargetsEntity); const existingStandardAgentEntities = await agentRepository.find({ where: { @@ -75,13 +74,43 @@ export class WorkspaceSyncAgentService { workspaceId: context.workspaceId, }); - await this.assignAdminRoleToAgent( - createdAgent.id, - context.workspaceId, - roleRepository, - roleTargetsRepository, + const agentDefinition = standardAgentDefinitions.find( + (def) => def.standardId === createdAgent.standardId, ); + if (agentDefinition?.standardRoleId) { + try { + const roleRepository = manager.getRepository(RoleEntity); + const role = await roleRepository.findOne({ + where: { + standardId: agentDefinition.standardRoleId, + workspaceId: context.workspaceId, + }, + }); + + if (!role) { + throw new Error( + `Standard role with standard ID ${agentDefinition.standardRoleId} not found in workspace`, + ); + } + + const roleTargetsRepository = + manager.getRepository(RoleTargetsEntity); + + await roleTargetsRepository.save({ + roleId: role.id, + agentId: createdAgent.id, + workspaceId: context.workspaceId, + }); + } catch (error) { + this.logger.error( + `Failed to assign standard role ${agentDefinition.standardRoleId} to agent ${createdAgent.id}: ${error.message}`, + error.stack, + ); + throw error; + } + } + if (createdAgent.standardId === WORKFLOW_CREATION_AGENT.standardId) { await this.createAgentHandoffToWorkflowCreationAgent( createdAgent.id, @@ -99,9 +128,47 @@ export class WorkspaceSyncAgentService { 'id', 'universalIdentifier', 'workspaceId', + 'standardRoleId' as keyof typeof agentToUpdate, ]); await agentRepository.update({ id: agentToUpdate.id }, flatAgentData); + + const agentDefinition = standardAgentDefinitions.find( + (def) => def.standardId === agentToUpdate.standardId, + ); + + if (agentDefinition?.standardRoleId) { + try { + const roleRepository = manager.getRepository(RoleEntity); + const role = await roleRepository.findOne({ + where: { + standardId: agentDefinition.standardRoleId, + workspaceId: context.workspaceId, + }, + }); + + if (!role) { + throw new Error( + `Standard role with standard ID ${agentDefinition.standardRoleId} not found in workspace`, + ); + } + + const roleTargetsRepository = + manager.getRepository(RoleTargetsEntity); + + await roleTargetsRepository.save({ + roleId: role.id, + agentId: agentToUpdate.id, + workspaceId: context.workspaceId, + }); + } catch (error) { + this.logger.error( + `Failed to assign standard role ${agentDefinition.standardRoleId} to agent ${agentToUpdate.id}: ${error.message}`, + error.stack, + ); + throw error; + } + } break; } @@ -115,60 +182,6 @@ export class WorkspaceSyncAgentService { } } - private async assignAdminRoleToAgent( - agentId: string, - workspaceId: string, - roleRepository: Repository, - roleTargetsRepository: Repository, - ): Promise { - try { - const adminRole = await roleRepository.findOne({ - where: { - workspaceId, - standardId: ADMIN_ROLE.standardId, - }, - }); - - if (!adminRole) { - this.logger.warn( - `Admin role not found for workspace ${workspaceId}, cannot assign to agent ${agentId}.`, - ); - - return; - } - - const existingRoleTarget = await roleTargetsRepository.findOne({ - where: { - agentId, - roleId: adminRole.id, - workspaceId, - }, - }); - - if (existingRoleTarget) { - this.logger.log( - `Workflow creation agent already has admin role assigned`, - ); - - return; - } - - await roleTargetsRepository.save({ - roleId: adminRole.id, - agentId, - workspaceId, - }); - - this.logger.log( - `Successfully assigned admin role to workflow creation agent`, - ); - } catch (error) { - this.logger.error( - `Failed to assign admin role to workflow creation agent: ${error.message}`, - ); - } - } - private async createAgentHandoffToWorkflowCreationAgent( workflowCreationAgentId: string, workspaceId: string, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-role.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-role.service.ts index ae98089551..3252a16bec 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-role.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-role.service.ts @@ -1,12 +1,14 @@ import { Injectable, Logger } from '@nestjs/common'; import { removePropertiesFromRecord } from 'twenty-shared/utils'; -import { IsNull, Not, type EntityManager } from 'typeorm'; +import { IsNull, Not, type EntityManager, type Repository } from 'typeorm'; import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface'; import { type WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; import { fromRoleEntityToFlatRole } from 'src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util'; +import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity'; +import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { WorkspaceRoleComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-role.comparator'; import { StandardRoleFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-role.factory'; @@ -28,12 +30,15 @@ export class WorkspaceSyncRoleService { this.logger.log('Syncing standard role metadata'); const roleRepository = manager.getRepository(RoleEntity); + const permissionFlagRepository = + manager.getRepository(PermissionFlagEntity); const existingStandardRoleEntities = await roleRepository.find({ where: { workspaceId: context.workspaceId, standardId: Not(IsNull()), }, + relations: ['permissionFlags'], }); const targetStandardRoles = this.standardRoleFactory.create( @@ -57,10 +62,23 @@ export class WorkspaceSyncRoleService { 'id', ]); - await roleRepository.save({ + const createdRole = await roleRepository.save({ ...flatRoleData, workspaceId: context.workspaceId, }); + + const roleDefinition = standardRoleDefinitions.find( + (def) => def.standardId === roleToCreate.standardId, + ); + + if (roleDefinition?.permissionFlags?.length) { + await this.syncPermissionFlags( + permissionFlagRepository, + createdRole.id, + context.workspaceId, + roleDefinition.permissionFlags, + ); + } break; } @@ -74,6 +92,19 @@ export class WorkspaceSyncRoleService { ]); await roleRepository.update({ id: roleToUpdate.id }, flatRoleData); + + const roleDefinition = standardRoleDefinitions.find( + (def) => def.standardId === roleToUpdate.standardId, + ); + + if (roleDefinition?.permissionFlags) { + await this.syncPermissionFlags( + permissionFlagRepository, + roleToUpdate.id, + context.workspaceId, + roleDefinition.permissionFlags, + ); + } break; } @@ -86,4 +117,28 @@ export class WorkspaceSyncRoleService { } } } + + private async syncPermissionFlags( + permissionFlagRepository: Repository, + roleId: string, + workspaceId: string, + permissionFlags: PermissionFlagType[], + ): Promise { + await permissionFlagRepository.delete({ + roleId, + workspaceId, + }); + + if (permissionFlags.length > 0) { + const newPermissionFlags = permissionFlags.map((flag) => + permissionFlagRepository.create({ + roleId, + workspaceId, + flag, + }), + ); + + await permissionFlagRepository.save(newPermissionFlags); + } + } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent.ts index 8ddb9bd8a5..ed6d61cbc4 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent.ts @@ -1,4 +1,5 @@ import { type StandardAgentDefinition } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface'; +import { WORKFLOW_MANAGER_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/workflow-manager-role'; export const WORKFLOW_CREATION_AGENT: StandardAgentDefinition = { standardId: '20202020-0002-0001-0001-000000000001', @@ -44,4 +45,5 @@ Be helpful, thorough, and always prioritize user understanding and workflow effe modelId: 'auto', responseFormat: {}, isCustom: false, + standardRoleId: WORKFLOW_MANAGER_ROLE.standardId, }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface.ts index b0581a2dc7..e524e647fb 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface.ts @@ -5,4 +5,5 @@ export type StandardAgentDefinition = Omit< 'id' | 'workspaceId' | 'universalIdentifier' | 'standardId' > & { standardId: string; + standardRoleId?: string; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/index.ts index 24a8d71f78..25934cd5c5 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/index.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/index.ts @@ -1,6 +1,8 @@ import { ADMIN_ROLE } from './roles/admin-role'; +import { WORKFLOW_MANAGER_ROLE } from './roles/workflow-manager-role'; import { type StandardRoleDefinition } from './types/standard-role-definition.interface'; export const standardRoleDefinitions = [ ADMIN_ROLE, + WORKFLOW_MANAGER_ROLE, ] as const satisfies StandardRoleDefinition[]; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role.ts index b791266d17..5a5ccba408 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role.ts @@ -12,4 +12,7 @@ export const ADMIN_ROLE: StandardRoleDefinition = { canUpdateAllObjectRecords: true, canSoftDeleteAllObjectRecords: true, canDestroyAllObjectRecords: true, + canBeAssignedToUsers: true, + canBeAssignedToAgents: false, + canBeAssignedToApiKeys: true, }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/workflow-manager-role.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/workflow-manager-role.ts new file mode 100644 index 0000000000..ec8565b27e --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/workflow-manager-role.ts @@ -0,0 +1,20 @@ +import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; +import { type StandardRoleDefinition } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/types/standard-role-definition.interface'; + +export const WORKFLOW_MANAGER_ROLE: StandardRoleDefinition = { + standardId: '20202020-0001-0001-0001-000000000002', + label: 'Workflow Manager', + description: 'Role for managing workflows', + icon: 'IconSettingsAutomation', + isEditable: false, + canUpdateAllSettings: false, + canAccessAllTools: true, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: true, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canBeAssignedToUsers: false, + canBeAssignedToAgents: true, + canBeAssignedToApiKeys: false, + permissionFlags: [PermissionFlagType.WORKFLOWS], +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/types/standard-role-definition.interface.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/types/standard-role-definition.interface.ts index 7705bd2980..1c1cdb5d54 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/types/standard-role-definition.interface.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-roles/types/standard-role-definition.interface.ts @@ -1,8 +1,10 @@ import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type'; +import { type PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; export type StandardRoleDefinition = Omit< FlatRole, 'id' | 'workspaceId' | 'universalIdentifier' | 'standardId' > & { standardId: string; + permissionFlags?: PermissionFlagType[]; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts index fe37e9f993..7cc2bb1a15 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -38,6 +39,7 @@ import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/works DataSourceModule, TypeOrmModule.forFeature([Workspace, FeatureFlag]), WorkspaceMetadataVersionModule, + AgentRoleModule, ], providers: [ ...workspaceSyncMetadataFactories, diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/roles.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/roles.integration-spec.ts index a3e219544e..96935d668d 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/roles.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/roles.integration-spec.ts @@ -89,7 +89,7 @@ describe('roles permissions', () => { expect(resp.status).toBe(200); expect(resp.body.errors).toBeUndefined(); - expect(resp.body.data.getRoles).toHaveLength(4); + expect(resp.body.data.getRoles).toHaveLength(5); expect(resp.body.data.getRoles).toEqual( expect.arrayContaining([ { diff --git a/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts b/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts index a78ddbde99..4ea9e11388 100644 --- a/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts @@ -222,7 +222,7 @@ export const workflowFilterActionSettingsSchema = export const workflowIteratorActionSettingsSchema = baseWorkflowActionSettingsSchema.extend({ input: z.object({ - items: z.union([z.array(z.any()), z.string()]).optional(), + items: z.union([z.array(z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.any()), z.any()])), z.string()]).optional(), // TODO: should never be a string once fix the UI initialLoopStepIds: z.union([z.array(z.string()), z.string()]).optional(), }),