From c795b8c52a78c3ad6685a6f62ad3c72c0a5130f1 Mon Sep 17 00:00:00 2001 From: Weiko Date: Mon, 12 Jan 2026 18:39:17 +0100 Subject: [PATCH] RLS FE implementation (#17062) ## Summary This PR introduces row-level security (RLS) permissions for roles in the frontend, allowing fine-grained access control at the record level. Users can now define permission rules that determine which specific records a role can access based on dynamic conditions and filters. ## What's Changed Implemented UI for configuring record-level permissions on object permissions screens Added support for defining permission predicates using filter conditions (similar to advanced filters) Introduced variable picker for dynamic permission rules (e.g., "me" context for user-specific access) Built predicate conversion layer to sync UI state with backend permission structure Extended GraphQL schema with mutations for upserting row-level permission predicates Fixed handling of orphaned RLS groups to prevent data inconsistencies Added enterprise key validation for RLS features The implementation enables scenarios like "users can only see their own records" or "users can access records associated with their team." Screenshot 2026-01-09 at 23 07 02 --------- Co-authored-by: Charles Bochet --- .../src/generated-metadata/graphql.ts | 131 ++++++- .../twenty-front/src/generated/graphql.ts | 44 +++ .../AdvancedFilterAddFilterRuleSelect.tsx | 2 +- .../utils/getCompositeSubFieldType.ts | 23 ++ .../components/FormMultiSelectFieldInput.tsx | 6 +- .../record-filter/types/RecordFilter.ts | 8 + .../rowLevelPermissionPredicateFragment.ts | 19 + ...owLevelPermissionPredicateGroupFragment.ts | 14 + ...ertRowLevelPermissionPredicatesMutation.ts | 48 +++ ...ertRowLevelPermissionPredicatesMutation.ts | 22 ++ .../roles/graphql/queries/getRolesQuery.ts | 10 + ...gsRolePermissionsObjectLevelObjectForm.tsx | 13 +- ...tLevelRecordLevelPermissionFieldSelect.tsx | 60 +++ ...ordLevelPermissionFieldSelectFieldMenu.tsx | 137 +++++++ ...LevelPermissionFieldSelectSubFieldMenu.tsx | 185 +++++++++ ...evelRecordLevelPermissionFilterBuilder.tsx | 33 ++ ...ordLevelPermissionFilterBuilderContent.tsx | 161 ++++++++ ...ectLevelRecordLevelPermissionFilterRow.tsx | 68 ++++ ...cordLevelPermissionLogicalOperatorCell.tsx | 69 ++++ ...evelRecordLevelPermissionMeValueSelect.tsx | 302 +++++++++++++++ ...ctLevelRecordLevelPermissionValueInput.tsx | 335 ++++++++++++++++ ...velRecordLevelPermissionVariablePicker.tsx | 84 ++++ ...rmissionsObjectLevelRecordLevelSection.tsx | 30 +- .../useRecordLevelPermissionFilterActions.ts | 131 +++++++ ...cordLevelPermissionFilterInitialization.ts | 144 +++++++ ...useRecordLevelPermissionSyncToDraftRole.ts | 102 +++++ ...ecordLevelPermissionPredicateConversion.ts | 142 +++++++ .../roles/role/components/SettingsRole.tsx | 4 +- .../roles/role/hooks/useSaveDraftRoleToDB.ts | 124 ++++++ .../states/settingsDraftRoleFamilyState.ts | 2 + .../ai/components/SettingsAgentRoleTab.tsx | 2 + .../src/testing/mock-data/roles.ts | 4 + .../src/testing/mock-data/roles/roles-mock.ts | 4 + ...ataIdToRowLevelPermissionPredicateGroup.ts | 37 ++ .../graphql-query-filter-field.parser.ts | 5 + .../utils/compute-where-condition-parts.ts | 44 ++- .../all-metadata-relations.constant.ts | 7 +- ...quired-metadata-for-validation.constant.ts | 1 + ...a-related-metadata-names.util.spec.ts.snap | 1 + ...rmission-predicate-group-to-create.util.ts | 9 +- ...-predicate-editable-properties.constant.ts | 1 + ...w-level-permission-predicate-group.util.ts | 16 +- .../metadata-modules/role/dtos/role.dto.ts | 8 + .../metadata-modules/role/role.module.ts | 2 + .../metadata-modules/role/role.resolver.ts | 54 +++ ...-level-permission-predicate-group.input.ts | 3 + ...te-row-level-permission-predicate.input.ts | 3 + ...t-row-level-permission-predicates.input.ts | 103 +++++ ...ow-level-permission-predicate-group.dto.ts | 3 + ...-level-permission-predicates-result.dto.ts | 15 + ...level-permission-predicate-group.entity.ts | 14 +- ...el-permission-predicate-group.exception.ts | 2 + ...ow-level-permission-predicate.exception.ts | 4 + .../row-level-permission-predicate.service.ts | 361 +++++++++++++++++- ...date-enum-value-compatibility.util.spec.ts | 273 +++++++++++++ ...ly-row-level-permission-predicates.util.ts | 23 +- ...row-level-permission-record-filter.util.ts | 33 +- .../validate-enum-value-compatibility.util.ts | 47 +++ ...ce-migration-build-orchestrator.service.ts | 20 +- ...ssion-predicate-group-validator.service.ts | 11 + ...-permission-predicate-validator.service.ts | 24 ++ .../display/icon/components/TablerIcons.ts | 1 + packages/twenty-ui/src/display/index.ts | 1 + 63 files changed, 3520 insertions(+), 69 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getCompositeSubFieldType.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/graphql/fragments/rowLevelPermissionPredicateFragment.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/graphql/fragments/rowLevelPermissionPredicateGroupFragment.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/graphql/hooks/useUpsertRowLevelPermissionPredicatesMutation.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/graphql/mutations/upsertRowLevelPermissionPredicatesMutation.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelect.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilder.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderContent.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterRow.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionLogicalOperatorCell.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionMeValueSelect.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionValueInput.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionVariablePicker.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/hooks/useRecordLevelPermissionFilterActions.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/hooks/useRecordLevelPermissionFilterInitialization.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/hooks/useRecordLevelPermissionSyncToDraftRole.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/utils/recordLevelPermissionPredicateConversion.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1767998263185-addObjectMetadataIdToRowLevelPermissionPredicateGroup.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/upsert-row-level-permission-predicates.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/dtos/upsert-row-level-permission-predicates-result.dto.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/__tests__/validate-enum-value-compatibility.util.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/validate-enum-value-compatibility.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index a54ef7f196..27be58ba62 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -950,6 +950,7 @@ export type CreateRouteTriggerInput = { export type CreateRowLevelPermissionPredicateGroupInput = { logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator; + objectMetadataId: Scalars['String']; parentRowLevelPermissionPredicateGroupId?: InputMaybe; positionInRowLevelPermissionPredicateGroup?: InputMaybe; roleId: Scalars['String']; @@ -2028,6 +2029,7 @@ export type Mutation = { upsertFieldPermissions: Array; upsertObjectPermissions: Array; upsertPermissionFlags: Array; + upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult; userLookupAdminPanel: UserLookup; validateApprovedAccessDomain: ApprovedAccessDomain; verifyEmailAndGetLoginToken: VerifyEmailAndGetLoginTokenOutput; @@ -2942,6 +2944,11 @@ export type MutationUpsertPermissionFlagsArgs = { }; +export type MutationUpsertRowLevelPermissionPredicatesArgs = { + input: UpsertRowLevelPermissionPredicatesInput; +}; + + export type MutationUserLookupAdminPanelArgs = { userIdentifier: Scalars['String']; }; @@ -3891,6 +3898,8 @@ export type Role = { label: Scalars['String']; objectPermissions?: Maybe>; permissionFlags?: Maybe>; + rowLevelPermissionPredicateGroups?: Maybe>; + rowLevelPermissionPredicates?: Maybe>; standardId?: Maybe; universalIdentifier?: Maybe; workspaceMembers: Array; @@ -3929,16 +3938,37 @@ export type RowLevelPermissionPredicateGroup = { __typename?: 'RowLevelPermissionPredicateGroup'; id: Scalars['String']; logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator; + objectMetadataId: Scalars['String']; parentRowLevelPermissionPredicateGroupId?: Maybe; positionInRowLevelPermissionPredicateGroup?: Maybe; roleId: Scalars['String']; }; +export type RowLevelPermissionPredicateGroupInput = { + id?: InputMaybe; + logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator; + objectMetadataId: Scalars['UUID']; + parentRowLevelPermissionPredicateGroupId?: InputMaybe; + positionInRowLevelPermissionPredicateGroup?: InputMaybe; +}; + export enum RowLevelPermissionPredicateGroupLogicalOperator { AND = 'AND', OR = 'OR' } +export type RowLevelPermissionPredicateInput = { + fieldMetadataId: Scalars['UUID']; + id?: InputMaybe; + operand: RowLevelPermissionPredicateOperand; + positionInRowLevelPermissionPredicateGroup?: InputMaybe; + rowLevelPermissionPredicateGroupId?: InputMaybe; + subFieldName?: InputMaybe; + value?: InputMaybe; + workspaceMemberFieldMetadataId?: InputMaybe; + workspaceMemberSubFieldName?: InputMaybe; +}; + export enum RowLevelPermissionPredicateOperand { CONTAINS = 'CONTAINS', DOES_NOT_CONTAIN = 'DOES_NOT_CONTAIN', @@ -4597,6 +4627,7 @@ export type UpdateRowLevelPermissionPredicateGroupInput = { }; export type UpdateRowLevelPermissionPredicateInput = { + fieldMetadataId?: InputMaybe; id: Scalars['String']; operand?: InputMaybe; positionInRowLevelPermissionPredicateGroup?: InputMaybe; @@ -4778,6 +4809,19 @@ export type UpsertPermissionFlagsInput = { roleId: Scalars['UUID']; }; +export type UpsertRowLevelPermissionPredicatesInput = { + objectMetadataId: Scalars['UUID']; + predicateGroups: Array; + predicates: Array; + roleId: Scalars['UUID']; +}; + +export type UpsertRowLevelPermissionPredicatesResult = { + __typename?: 'UpsertRowLevelPermissionPredicatesResult'; + predicateGroups: Array; + predicates: Array; +}; + export type User = { __typename?: 'User'; availableWorkspaces: AvailableWorkspaces; @@ -6096,6 +6140,10 @@ export type PermissionFlagFragmentFragment = { __typename?: 'PermissionFlag', id 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 RowLevelPermissionPredicateFragmentFragment = { __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }; + +export type RowLevelPermissionPredicateGroupFragmentFragment = { __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }; + export type CreateOneRoleMutationVariables = Exact<{ createRoleInput: CreateRoleInput; }>; @@ -6146,10 +6194,17 @@ export type UpsertPermissionFlagsMutationVariables = Exact<{ export type UpsertPermissionFlagsMutation = { __typename?: 'Mutation', upsertPermissionFlags: Array<{ __typename?: 'PermissionFlag', id: string, flag: PermissionFlagType, roleId: string }> }; +export type UpsertRowLevelPermissionPredicatesMutationVariables = Exact<{ + input: UpsertRowLevelPermissionPredicatesInput; +}>; + + +export type UpsertRowLevelPermissionPredicatesMutation = { __typename?: 'Mutation', upsertRowLevelPermissionPredicates: { __typename?: 'UpsertRowLevelPermissionPredicatesResult', predicates: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }>, predicateGroups: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> } }; + 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, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, 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, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, 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 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, avatarUrl?: string | null, userEmail: string, 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, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, 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, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> }; export type CreateApprovedAccessDomainMutationVariables = Exact<{ input: CreateApprovedAccessDomainInput; @@ -7081,6 +7136,31 @@ export const PermissionFlagFragmentFragmentDoc = gql` roleId } `; +export const RowLevelPermissionPredicateFragmentFragmentDoc = gql` + fragment RowLevelPermissionPredicateFragment on RowLevelPermissionPredicate { + id + fieldMetadataId + objectMetadataId + operand + subFieldName + workspaceMemberFieldMetadataId + workspaceMemberSubFieldName + rowLevelPermissionPredicateGroupId + positionInRowLevelPermissionPredicateGroup + roleId + value +} + `; +export const RowLevelPermissionPredicateGroupFragmentFragmentDoc = gql` + fragment RowLevelPermissionPredicateGroupFragment on RowLevelPermissionPredicateGroup { + id + parentRowLevelPermissionPredicateGroupId + logicalOperator + positionInRowLevelPermissionPredicateGroup + roleId + objectMetadataId +} + `; export const WorkspaceMemberQueryFragmentFragmentDoc = gql` fragment WorkspaceMemberQueryFragment on WorkspaceMember { id @@ -12109,6 +12189,45 @@ export function useUpsertPermissionFlagsMutation(baseOptions?: Apollo.MutationHo export type UpsertPermissionFlagsMutationHookResult = ReturnType; export type UpsertPermissionFlagsMutationResult = Apollo.MutationResult; export type UpsertPermissionFlagsMutationOptions = Apollo.BaseMutationOptions; +export const UpsertRowLevelPermissionPredicatesDocument = gql` + mutation UpsertRowLevelPermissionPredicates($input: UpsertRowLevelPermissionPredicatesInput!) { + upsertRowLevelPermissionPredicates(input: $input) { + predicates { + ...RowLevelPermissionPredicateFragment + } + predicateGroups { + ...RowLevelPermissionPredicateGroupFragment + } + } +} + ${RowLevelPermissionPredicateFragmentFragmentDoc} +${RowLevelPermissionPredicateGroupFragmentFragmentDoc}`; +export type UpsertRowLevelPermissionPredicatesMutationFn = Apollo.MutationFunction; + +/** + * __useUpsertRowLevelPermissionPredicatesMutation__ + * + * To run a mutation, you first call `useUpsertRowLevelPermissionPredicatesMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpsertRowLevelPermissionPredicatesMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [upsertRowLevelPermissionPredicatesMutation, { data, loading, error }] = useUpsertRowLevelPermissionPredicatesMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpsertRowLevelPermissionPredicatesMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpsertRowLevelPermissionPredicatesDocument, options); + } +export type UpsertRowLevelPermissionPredicatesMutationHookResult = ReturnType; +export type UpsertRowLevelPermissionPredicatesMutationResult = Apollo.MutationResult; +export type UpsertRowLevelPermissionPredicatesMutationOptions = Apollo.BaseMutationOptions; export const GetRolesDocument = gql` query GetRoles { getRoles { @@ -12131,6 +12250,12 @@ export const GetRolesDocument = gql` fieldPermissions { ...FieldPermissionFragment } + rowLevelPermissionPredicates { + ...RowLevelPermissionPredicateFragment + } + rowLevelPermissionPredicateGroups { + ...RowLevelPermissionPredicateGroupFragment + } } } ${RoleFragmentFragmentDoc} @@ -12139,7 +12264,9 @@ ${AgentFieldsFragmentDoc} ${ApiKeyForRoleFragmentFragmentDoc} ${PermissionFlagFragmentFragmentDoc} ${ObjectPermissionFragmentFragmentDoc} -${FieldPermissionFragmentFragmentDoc}`; +${FieldPermissionFragmentFragmentDoc} +${RowLevelPermissionPredicateFragmentFragmentDoc} +${RowLevelPermissionPredicateGroupFragmentFragmentDoc}`; /** * __useGetRolesQuery__ diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index dd9dba3174..1658a874b9 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -941,6 +941,7 @@ export type CreateRouteTriggerInput = { export type CreateRowLevelPermissionPredicateGroupInput = { logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator; + objectMetadataId: Scalars['String']; parentRowLevelPermissionPredicateGroupId?: InputMaybe; positionInRowLevelPermissionPredicateGroup?: InputMaybe; roleId: Scalars['String']; @@ -2003,6 +2004,7 @@ export type Mutation = { upsertFieldPermissions: Array; upsertObjectPermissions: Array; upsertPermissionFlags: Array; + upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult; userLookupAdminPanel: UserLookup; validateApprovedAccessDomain: ApprovedAccessDomain; verifyEmailAndGetLoginToken: VerifyEmailAndGetLoginTokenOutput; @@ -2876,6 +2878,11 @@ export type MutationUpsertPermissionFlagsArgs = { }; +export type MutationUpsertRowLevelPermissionPredicatesArgs = { + input: UpsertRowLevelPermissionPredicatesInput; +}; + + export type MutationUserLookupAdminPanelArgs = { userIdentifier: Scalars['String']; }; @@ -3766,6 +3773,8 @@ export type Role = { label: Scalars['String']; objectPermissions?: Maybe>; permissionFlags?: Maybe>; + rowLevelPermissionPredicateGroups?: Maybe>; + rowLevelPermissionPredicates?: Maybe>; standardId?: Maybe; universalIdentifier?: Maybe; workspaceMembers: Array; @@ -3804,16 +3813,37 @@ export type RowLevelPermissionPredicateGroup = { __typename?: 'RowLevelPermissionPredicateGroup'; id: Scalars['String']; logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator; + objectMetadataId: Scalars['String']; parentRowLevelPermissionPredicateGroupId?: Maybe; positionInRowLevelPermissionPredicateGroup?: Maybe; roleId: Scalars['String']; }; +export type RowLevelPermissionPredicateGroupInput = { + id?: InputMaybe; + logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator; + objectMetadataId: Scalars['UUID']; + parentRowLevelPermissionPredicateGroupId?: InputMaybe; + positionInRowLevelPermissionPredicateGroup?: InputMaybe; +}; + export enum RowLevelPermissionPredicateGroupLogicalOperator { AND = 'AND', OR = 'OR' } +export type RowLevelPermissionPredicateInput = { + fieldMetadataId: Scalars['UUID']; + id?: InputMaybe; + operand: RowLevelPermissionPredicateOperand; + positionInRowLevelPermissionPredicateGroup?: InputMaybe; + rowLevelPermissionPredicateGroupId?: InputMaybe; + subFieldName?: InputMaybe; + value?: InputMaybe; + workspaceMemberFieldMetadataId?: InputMaybe; + workspaceMemberSubFieldName?: InputMaybe; +}; + export enum RowLevelPermissionPredicateOperand { CONTAINS = 'CONTAINS', DOES_NOT_CONTAIN = 'DOES_NOT_CONTAIN', @@ -4472,6 +4502,7 @@ export type UpdateRowLevelPermissionPredicateGroupInput = { }; export type UpdateRowLevelPermissionPredicateInput = { + fieldMetadataId?: InputMaybe; id: Scalars['String']; operand?: InputMaybe; positionInRowLevelPermissionPredicateGroup?: InputMaybe; @@ -4643,6 +4674,19 @@ export type UpsertPermissionFlagsInput = { roleId: Scalars['UUID']; }; +export type UpsertRowLevelPermissionPredicatesInput = { + objectMetadataId: Scalars['UUID']; + predicateGroups: Array; + predicates: Array; + roleId: Scalars['UUID']; +}; + +export type UpsertRowLevelPermissionPredicatesResult = { + __typename?: 'UpsertRowLevelPermissionPredicatesResult'; + predicateGroups: Array; + predicates: Array; +}; + export type User = { __typename?: 'User'; availableWorkspaces: AvailableWorkspaces; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect.tsx index a2db7eb158..c854e2caa2 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect.tsx @@ -15,9 +15,9 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { t } from '@lingui/core/macro'; import { useContext } from 'react'; import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types'; -import { t } from '@lingui/core/macro'; import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils'; import { IconLibraryPlus, IconPlus } from 'twenty-ui/display'; import { MenuItem } from 'twenty-ui/navigation'; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getCompositeSubFieldType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getCompositeSubFieldType.ts new file mode 100644 index 0000000000..a83b147b67 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getCompositeSubFieldType.ts @@ -0,0 +1,23 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { + compositeTypeDefinitions, + type FieldMetadataType, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const getCompositeSubFieldType = ( + fieldMetadataItem: FieldMetadataItem, + subFieldName: string, +): FieldMetadataType | null => { + const compositeType = compositeTypeDefinitions.get(fieldMetadataItem.type); + + if (!isDefined(compositeType)) { + return null; + } + + const subFieldProperty = compositeType.properties.find( + (property) => property.name === subFieldName, + ); + + return subFieldProperty?.type ?? null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput.tsx index a1ee2bd032..c876bf1313 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput.tsx @@ -37,6 +37,7 @@ type FormMultiSelectFieldInputProps = { placeholder?: string; testId?: string; hint?: string; + dropdownWidth?: number; }; const StyledDisplayModeReadonlyContainer = styled.div` @@ -86,6 +87,7 @@ export const FormMultiSelectFieldInput = ({ placeholder, testId, hint, + dropdownWidth, }: FormMultiSelectFieldInputProps) => { const instanceId = useId(); const theme = useTheme(); @@ -269,7 +271,9 @@ export const FormMultiSelectFieldInput = ({ onCancel={onCancel} onOptionSelected={onOptionSelected} values={selectedNames} - dropdownWidth={GenericDropdownContentWidth.ExtraLarge} + dropdownWidth={ + dropdownWidth ?? GenericDropdownContentWidth.ExtraLarge + } /> )} diff --git a/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts index ae9da12e4e..74d59b6345 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts @@ -5,6 +5,12 @@ import { type ViewFilterOperand, } from 'twenty-shared/types'; +// RLS-specific: references a workspace member field for dynamic "Me" comparisons +export type RLSDynamicValue = { + workspaceMemberFieldMetadataId: string; + workspaceMemberSubFieldName?: string | null; +}; + export type RecordFilter = { id: string; fieldMetadataId: string; @@ -18,6 +24,8 @@ export type RecordFilter = { positionInRecordFilterGroup?: number | null; label: string; subFieldName?: CompositeFieldSubFieldName | null | undefined; + // RLS-specific: when set, filter compares against current user's field value + rlsDynamicValue?: RLSDynamicValue | null; }; export type RecordFilterToRecordInputOperand< diff --git a/packages/twenty-front/src/modules/settings/roles/graphql/fragments/rowLevelPermissionPredicateFragment.ts b/packages/twenty-front/src/modules/settings/roles/graphql/fragments/rowLevelPermissionPredicateFragment.ts new file mode 100644 index 0000000000..f2e3de444d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/graphql/fragments/rowLevelPermissionPredicateFragment.ts @@ -0,0 +1,19 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const ROW_LEVEL_PERMISSION_PREDICATE_FRAGMENT = gql` + fragment RowLevelPermissionPredicateFragment on RowLevelPermissionPredicate { + id + fieldMetadataId + objectMetadataId + operand + subFieldName + workspaceMemberFieldMetadataId + workspaceMemberSubFieldName + rowLevelPermissionPredicateGroupId + positionInRowLevelPermissionPredicateGroup + roleId + value + } +`; diff --git a/packages/twenty-front/src/modules/settings/roles/graphql/fragments/rowLevelPermissionPredicateGroupFragment.ts b/packages/twenty-front/src/modules/settings/roles/graphql/fragments/rowLevelPermissionPredicateGroupFragment.ts new file mode 100644 index 0000000000..f338b88031 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/graphql/fragments/rowLevelPermissionPredicateGroupFragment.ts @@ -0,0 +1,14 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const ROW_LEVEL_PERMISSION_PREDICATE_GROUP_FRAGMENT = gql` + fragment RowLevelPermissionPredicateGroupFragment on RowLevelPermissionPredicateGroup { + id + parentRowLevelPermissionPredicateGroupId + logicalOperator + positionInRowLevelPermissionPredicateGroup + roleId + objectMetadataId + } +`; diff --git a/packages/twenty-front/src/modules/settings/roles/graphql/hooks/useUpsertRowLevelPermissionPredicatesMutation.ts b/packages/twenty-front/src/modules/settings/roles/graphql/hooks/useUpsertRowLevelPermissionPredicatesMutation.ts new file mode 100644 index 0000000000..f6330a2136 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/graphql/hooks/useUpsertRowLevelPermissionPredicatesMutation.ts @@ -0,0 +1,48 @@ +/* @license Enterprise */ + +import { useMutation } from '@apollo/client'; + +import { UPSERT_ROW_LEVEL_PERMISSION_PREDICATES } from '@/settings/roles/graphql/mutations/upsertRowLevelPermissionPredicatesMutation'; +import { + type RowLevelPermissionPredicate, + type RowLevelPermissionPredicateGroup, + type RowLevelPermissionPredicateGroupLogicalOperator, + type RowLevelPermissionPredicateOperand, +} from '~/generated-metadata/graphql'; + +export type UpsertRowLevelPermissionPredicatesInput = { + roleId: string; + objectMetadataId: string; + predicates: Array<{ + id?: string; + fieldMetadataId: string; + operand: RowLevelPermissionPredicateOperand; + value?: unknown; + subFieldName?: string | null; + workspaceMemberFieldMetadataId?: string | null; + workspaceMemberSubFieldName?: string | null; + rowLevelPermissionPredicateGroupId?: string | null; + positionInRowLevelPermissionPredicateGroup?: number | null; + }>; + predicateGroups: Array<{ + id?: string; + objectMetadataId: string; + parentRowLevelPermissionPredicateGroupId?: string | null; + logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator; + positionInRowLevelPermissionPredicateGroup?: number | null; + }>; +}; + +type UpsertRowLevelPermissionPredicatesResult = { + upsertRowLevelPermissionPredicates: { + predicates: RowLevelPermissionPredicate[]; + predicateGroups: RowLevelPermissionPredicateGroup[]; + }; +}; + +export const useUpsertRowLevelPermissionPredicatesMutation = () => { + return useMutation< + UpsertRowLevelPermissionPredicatesResult, + { input: UpsertRowLevelPermissionPredicatesInput } + >(UPSERT_ROW_LEVEL_PERMISSION_PREDICATES); +}; diff --git a/packages/twenty-front/src/modules/settings/roles/graphql/mutations/upsertRowLevelPermissionPredicatesMutation.ts b/packages/twenty-front/src/modules/settings/roles/graphql/mutations/upsertRowLevelPermissionPredicatesMutation.ts new file mode 100644 index 0000000000..6ef2ab375f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/graphql/mutations/upsertRowLevelPermissionPredicatesMutation.ts @@ -0,0 +1,22 @@ +/* @license Enterprise */ + +import { ROW_LEVEL_PERMISSION_PREDICATE_FRAGMENT } from '@/settings/roles/graphql/fragments/rowLevelPermissionPredicateFragment'; +import { ROW_LEVEL_PERMISSION_PREDICATE_GROUP_FRAGMENT } from '@/settings/roles/graphql/fragments/rowLevelPermissionPredicateGroupFragment'; +import { gql } from '@apollo/client'; + +export const UPSERT_ROW_LEVEL_PERMISSION_PREDICATES = gql` + ${ROW_LEVEL_PERMISSION_PREDICATE_FRAGMENT} + ${ROW_LEVEL_PERMISSION_PREDICATE_GROUP_FRAGMENT} + mutation UpsertRowLevelPermissionPredicates( + $input: UpsertRowLevelPermissionPredicatesInput! + ) { + upsertRowLevelPermissionPredicates(input: $input) { + predicates { + ...RowLevelPermissionPredicateFragment + } + predicateGroups { + ...RowLevelPermissionPredicateGroupFragment + } + } + } +`; 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 d285fe2ab8..4fe85d8604 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 @@ -4,6 +4,8 @@ import { FIELD_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/fi import { OBJECT_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/objectPermissionFragment'; import { PERMISSION_FLAG_FRAGMENT } from '@/settings/roles/graphql/fragments/permissionFlagFragment'; import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment'; +import { ROW_LEVEL_PERMISSION_PREDICATE_FRAGMENT } from '@/settings/roles/graphql/fragments/rowLevelPermissionPredicateFragment'; +import { ROW_LEVEL_PERMISSION_PREDICATE_GROUP_FRAGMENT } from '@/settings/roles/graphql/fragments/rowLevelPermissionPredicateGroupFragment'; import { PARTIAL_WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/partialWorkspaceMemberQueryFragment'; import { gql } from '@apollo/client'; @@ -15,6 +17,8 @@ export const GET_ROLES = gql` ${PERMISSION_FLAG_FRAGMENT} ${OBJECT_PERMISSION_FRAGMENT} ${FIELD_PERMISSION_FRAGMENT} + ${ROW_LEVEL_PERMISSION_PREDICATE_FRAGMENT} + ${ROW_LEVEL_PERMISSION_PREDICATE_GROUP_FRAGMENT} query GetRoles { getRoles { ...RoleFragment @@ -36,6 +40,12 @@ export const GET_ROLES = gql` fieldPermissions { ...FieldPermissionFragment } + rowLevelPermissionPredicates { + ...RowLevelPermissionPredicateFragment + } + rowLevelPermissionPredicateGroups { + ...RowLevelPermissionPredicateGroupFragment + } } } `; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx index d9172853be..c0cd3ffe31 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx @@ -1,3 +1,4 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsRolePermissionsObjectLevelObjectFieldPermissionTable } from '@/settings/roles/role-permissions/object-level-permissions/field-permissions/components/SettingsRolePermissionsObjectLevelObjectFieldPermissionTable'; @@ -29,6 +30,9 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({ const [searchParams] = useSearchParams(); const fromAgentId = searchParams.get('fromAgent'); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const hasValidEnterpriseKey = + currentWorkspace?.hasValidEnterpriseKey === true; const settingsDraftRole = useRecoilValue( settingsDraftRoleFamilyState(roleId), ); @@ -44,7 +48,9 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({ const featureFlagsMap = useFeatureFlagsMap(); const isRowLevelPermissionPredicatesEnabled = - featureFlagsMap[FeatureFlagKey.IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED]; + featureFlagsMap[ + FeatureFlagKey.IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED + ] && hasValidEnterpriseKey; const objectMetadataItem = objectMetadata.objectMetadataItem; @@ -123,7 +129,10 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({ roleId={roleId} /> {isRowLevelPermissionPredicatesEnabled && ( - + )} diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelect.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelect.tsx new file mode 100644 index 0000000000..221473162d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelect.tsx @@ -0,0 +1,60 @@ +/* @license Enterprise */ + +import styled from '@emotion/styled'; + +import { AdvancedFilterFieldSelectDropdownButtonClickableSelect } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectDropdownButtonClickableSelect'; +import { DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET } from '@/object-record/advanced-filter/constants/DefaultAdvancedFilterDropdownOffset'; +import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown'; +import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; +import { SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu'; +import { SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; + +const StyledContainer = styled.div` + flex: 2; + min-width: 0; +`; + +type SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectProps = { + recordFilterId: string; +}; + +export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelect = + ({ + recordFilterId, + }: SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectProps) => { + const { advancedFilterFieldSelectDropdownId } = + useAdvancedFilterFieldSelectDropdown(recordFilterId); + + const [objectFilterDropdownIsSelectingCompositeField] = + useRecoilComponentState( + objectFilterDropdownIsSelectingCompositeFieldComponentState, + ); + + return ( + + + } + dropdownComponents={ + objectFilterDropdownIsSelectingCompositeField ? ( + + ) : ( + + ) + } + dropdownOffset={DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET} + dropdownPlacement="bottom-start" + /> + + ); + }; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu.tsx new file mode 100644 index 0000000000..1bc73c1b7c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu.tsx @@ -0,0 +1,137 @@ +/* @license Enterprise */ + +import { useLingui } from '@lingui/react/macro'; +import { getFilterTypeFromFieldType } from 'twenty-shared/utils'; + +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { AdvancedFilterFieldSelectSearchInput } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectSearchInput'; +import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown'; +import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown'; +import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext'; +import { ObjectFilterDropdownFilterSelectMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem'; +import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState'; +import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; +import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState'; +import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; +import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; +import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { useContext } from 'react'; + +type SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenuProps = + { + recordFilterId: string; + }; + +export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu = + ({ + recordFilterId, + }: SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenuProps) => { + const { t } = useLingui(); + + const { + closeAdvancedFilterFieldSelectDropdown, + advancedFilterFieldSelectDropdownId, + } = useAdvancedFilterFieldSelectDropdown(recordFilterId); + + const [objectFilterDropdownSearchInput] = useRecoilComponentState( + objectFilterDropdownSearchInputComponentState, + ); + + const { objectMetadataItem } = useContext(AdvancedFilterContext); + + const { filterableFieldMetadataItems } = useFilterableFieldMetadataItems( + objectMetadataItem.id, + ); + + const filteredFieldMetadataItems = filterableFieldMetadataItems + .filter((fieldMetadataItem) => + fieldMetadataItem.label + .toLocaleLowerCase() + .includes(objectFilterDropdownSearchInput.toLocaleLowerCase()), + ) + .sort((a, b) => a.label.localeCompare(b.label)); + + const { resetSelectedItem } = useSelectableList( + advancedFilterFieldSelectDropdownId, + ); + + const { selectFieldUsedInAdvancedFilterDropdown } = + useSelectFieldUsedInAdvancedFilterDropdown(); + + const [, setObjectFilterDropdownSubMenuFieldType] = useRecoilComponentState( + objectFilterDropdownSubMenuFieldTypeComponentState, + ); + + const [, setObjectFilterDropdownIsSelectingCompositeField] = + useRecoilComponentState( + objectFilterDropdownIsSelectingCompositeFieldComponentState, + ); + + const setFieldMetadataItemIdUsedInDropdown = useSetRecoilComponentState( + fieldMetadataItemIdUsedInDropdownComponentState, + ); + + const handleFieldSelect = ( + selectedFieldMetadataItem: FieldMetadataItem, + ) => { + resetSelectedItem(); + + const filterType = getFilterTypeFromFieldType( + selectedFieldMetadataItem.type, + ); + + if (isCompositeFieldType(filterType)) { + setObjectFilterDropdownSubMenuFieldType(filterType); + setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id); + setObjectFilterDropdownIsSelectingCompositeField(true); + } else { + selectFieldUsedInAdvancedFilterDropdown({ + fieldMetadataItemId: selectedFieldMetadataItem.id, + recordFilterId, + }); + closeAdvancedFilterFieldSelectDropdown(); + } + }; + + const selectableItemIdArray = filteredFieldMetadataItems.map( + (fieldMetadataItem) => fieldMetadataItem.id, + ); + + return ( + + + + + + {filteredFieldMetadataItems.map((fieldMetadataItem) => ( + { + handleFieldSelect(fieldMetadataItem); + }} + > + + + ))} + + + + ); + }; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu.tsx new file mode 100644 index 0000000000..5818bd0fc4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu.tsx @@ -0,0 +1,185 @@ +/* @license Enterprise */ + +import { + compositeTypeDefinitions, + FieldMetadataType, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { IconChevronLeft, useIcons } from 'twenty-ui/display'; +import { MenuItem } from 'twenty-ui/navigation'; + +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown'; +import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown'; +import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; +import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; +import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; +import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel'; +import { ICON_NAME_BY_SUB_FIELD } from '@/object-record/record-filter/constants/IconNameBySubField'; +import { areCompositeTypeSubFieldsFilterable } from '@/object-record/record-filter/utils/areCompositeTypeSubFieldsFilterable'; +import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; +import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; +import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +type SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenuProps = + { + recordFilterId: string; + }; + +export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu = + ({ + recordFilterId, + }: SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenuProps) => { + const { getIcon } = useIcons(); + + const fieldMetadataItemUsedInDropdown = useRecoilComponentValue( + fieldMetadataItemUsedInDropdownComponentSelector, + ); + + const [, setObjectFilterDropdownIsSelectingCompositeField] = + useRecoilComponentState( + objectFilterDropdownIsSelectingCompositeFieldComponentState, + ); + + const [ + objectFilterDropdownSubMenuFieldType, + setObjectFilterDropdownSubMenuFieldType, + ] = useRecoilComponentState( + objectFilterDropdownSubMenuFieldTypeComponentState, + ); + + const { closeAdvancedFilterFieldSelectDropdown } = + useAdvancedFilterFieldSelectDropdown(recordFilterId); + + const { selectFieldUsedInAdvancedFilterDropdown } = + useSelectFieldUsedInAdvancedFilterDropdown(); + + const handleSelectFilter = ( + selectedFieldMetadataItem: FieldMetadataItem | null | undefined, + subFieldName?: CompositeFieldSubFieldName | null | undefined, + ) => { + if (!isDefined(selectedFieldMetadataItem)) { + return; + } + + selectFieldUsedInAdvancedFilterDropdown({ + fieldMetadataItemId: selectedFieldMetadataItem.id, + recordFilterId, + subFieldName, + }); + + closeAdvancedFilterFieldSelectDropdown(); + }; + + const handleSubMenuBack = () => { + setObjectFilterDropdownSubMenuFieldType(null); + setObjectFilterDropdownIsSelectingCompositeField(false); + }; + + const { advancedFilterFieldSelectDropdownId } = + useAdvancedFilterFieldSelectDropdown(recordFilterId); + + const selectedItemId = useRecoilComponentValue( + selectedItemIdComponentState, + advancedFilterFieldSelectDropdownId, + ); + + if (!isDefined(objectFilterDropdownSubMenuFieldType)) { + return null; + } + + const compositeType = compositeTypeDefinitions.get( + objectFilterDropdownSubMenuFieldType as FieldMetadataType, + ); + + const subFieldNames = SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[ + objectFilterDropdownSubMenuFieldType + ].subFields + .filter((subField) => { + if (!subField.isFilterable) { + return false; + } + + const subFieldProperty = compositeType?.properties.find( + (property) => property.name === subField.subFieldName, + ); + + return subFieldProperty?.type !== FieldMetadataType.RAW_JSON; + }) + .map((subField) => subField.subFieldName); + + const subFieldsAreFilterable = + isDefined(fieldMetadataItemUsedInDropdown) && + areCompositeTypeSubFieldsFilterable(fieldMetadataItemUsedInDropdown.type); + + const selectableItemIdArray = subFieldNames.map( + (subFieldName) => subFieldName, + ); + + return ( + + + } + > + {fieldMetadataItemUsedInDropdown?.label} + + + + {subFieldsAreFilterable && + subFieldNames.map((subFieldName, index) => ( + { + handleSelectFilter( + fieldMetadataItemUsedInDropdown, + subFieldName, + ); + }} + > + { + if (isDefined(fieldMetadataItemUsedInDropdown)) { + handleSelectFilter( + fieldMetadataItemUsedInDropdown, + subFieldName, + ); + } + }} + text={getCompositeSubFieldLabel( + objectFilterDropdownSubMenuFieldType, + subFieldName, + )} + LeftIcon={getIcon( + ICON_NAME_BY_SUB_FIELD[subFieldName] ?? + fieldMetadataItemUsedInDropdown?.icon, + )} + /> + + ))} + + + + ); + }; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilder.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilder.tsx new file mode 100644 index 0000000000..e21daa6b69 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilder.tsx @@ -0,0 +1,33 @@ +/* @license Enterprise */ + +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/record-filter-group/states/context/RecordFilterGroupsComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; +import { SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderContent } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderContent'; + +type SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderProps = + { + roleId: string; + objectMetadataItem: ObjectMetadataItem; + }; + +export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilder = + ({ + roleId, + objectMetadataItem, + }: SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderProps) => { + const instanceId = `record-level-permission-filter-${roleId}-${objectMetadataItem.id}`; + + return ( + + + + + + ); + }; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderContent.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderContent.tsx new file mode 100644 index 0000000000..7f91d53930 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderContent.tsx @@ -0,0 +1,161 @@ +/* @license Enterprise */ + +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { IconFilter, IconPlus } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; + +import { ActionButton } from '@/action-menu/actions/display/components/ActionButton'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useChildRecordFiltersAndRecordFilterGroups } from '@/object-record/advanced-filter/hooks/useChildRecordFiltersAndRecordFilterGroups'; +import { useSetRecordFilterUsedInAdvancedFilterDropdownRow } from '@/object-record/advanced-filter/hooks/useSetRecordFilterUsedInAdvancedFilterDropdownRow'; +import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext'; +import { rootLevelRecordFilterGroupComponentSelector } from '@/object-record/advanced-filter/states/rootLevelRecordFilterGroupComponentSelector'; +import { isRecordFilterGroupChildARecordFilterGroup } from '@/object-record/advanced-filter/utils/isRecordFilterGroupChildARecordFilterGroup'; +import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; +import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems'; +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterRow } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterRow'; +import { useRecordLevelPermissionFilterActions } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/hooks/useRecordLevelPermissionFilterActions'; +import { useRecordLevelPermissionFilterInitialization } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/hooks/useRecordLevelPermissionFilterInitialization'; +import { useRecordLevelPermissionSyncToDraftRole } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/hooks/useRecordLevelPermissionSyncToDraftRole'; +import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; + +const StyledContainer = styled.div` + align-items: start; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledFiltersContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + width: 100%; +`; + +const StyledActionButtonWrapper = styled.div` + margin-top: ${({ theme }) => theme.spacing(2)}; +`; + +type SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderContentProps = + { + roleId: string; + objectMetadataItem: ObjectMetadataItem; + }; + +export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderContent = + ({ + roleId, + objectMetadataItem, + }: SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterBuilderContentProps) => { + const settingsDraftRole = useRecoilValue( + settingsDraftRoleFamilyState(roleId), + ); + + const { filterableFieldMetadataItems } = useFilterableFieldMetadataItems( + objectMetadataItem.id, + ); + + const setCurrentRecordFilters = useSetRecoilComponentState( + currentRecordFiltersComponentState, + ); + + const setCurrentRecordFilterGroups = useSetRecoilComponentState( + currentRecordFilterGroupsComponentState, + ); + + const currentRecordFilters = useRecoilComponentValue( + currentRecordFiltersComponentState, + ); + + const currentRecordFilterGroups = useRecoilComponentValue( + currentRecordFilterGroupsComponentState, + ); + + const { setRecordFilterUsedInAdvancedFilterDropdownRow } = + useSetRecordFilterUsedInAdvancedFilterDropdownRow(); + + const rootRecordFilterGroup = useRecoilComponentValue( + rootLevelRecordFilterGroupComponentSelector, + ); + + const { childRecordFiltersAndRecordFilterGroups } = + useChildRecordFiltersAndRecordFilterGroups({ + recordFilterGroupId: rootRecordFilterGroup?.id, + }); + + const { hasInitialized } = useRecordLevelPermissionFilterInitialization({ + roleId, + objectMetadataItem, + settingsDraftRole, + filterableFieldMetadataItems, + setCurrentRecordFilters, + setCurrentRecordFilterGroups, + setRecordFilterUsedInAdvancedFilterDropdownRow, + }); + + useRecordLevelPermissionSyncToDraftRole({ + roleId, + objectMetadataItem, + currentRecordFilters, + currentRecordFilterGroups, + hasInitialized, + }); + + const { handleCreateFirstFilter, handleAddFilter } = + useRecordLevelPermissionFilterActions({ + objectMetadataItem, + }); + + return ( + + {isDefined(rootRecordFilterGroup) ? ( + + + {childRecordFiltersAndRecordFilterGroups + .filter( + (child): child is RecordFilter => + !isRecordFilterGroupChildARecordFilterGroup(child), + ) + .map((child, index) => ( + + ))} + + + handleAddFilter(rootRecordFilterGroup)} + /> + + + ) : ( +