From e1f6c616517dd128bc0fd72f375d1f73d9d062ec Mon Sep 17 00:00:00 2001 From: Weiko Date: Fri, 4 Apr 2025 17:40:14 +0200 Subject: [PATCH] add settings permissions update (#11377) Fixes https://github.com/twentyhq/core-team-issues/issues/710 --- .../src/generated-metadata/graphql.ts | 13 +- .../twenty-front/src/generated/graphql.tsx | 72 +++++++++-- .../components/SettingsRolesQueryEffect.tsx | 2 + .../fragments/settingPermissionFragment.ts | 9 ++ .../upsertSettingPermissionsMutation.ts | 15 +++ .../roles/graphql/queries/getRolesQuery.ts | 5 + .../components/SettingsRolePermissions.tsx | 47 +++++-- ...ingsRolePermissionsSettingsTableHeader.tsx | 39 +----- ...ettingsRolePermissionsSettingsTableRow.tsx | 54 +++++++- .../roles/role/components/SettingsRole.tsx | 104 ++++++++++++---- .../states/settingsDraftRoleFamilyState.ts | 1 + ...ettingsRolePermissionsSettingPermission.ts | 5 +- .../src/utils/getDirtyFields.spec.ts | 115 ++++++++++++++++++ .../twenty-front/src/utils/getDirtyFields.ts | 26 ++++ ...veCanUpdateSettingFromSettingPermission.ts | 19 +++ .../metadata-modules/role/dtos/role.dto.ts | 4 + .../metadata-modules/role/role.resolver.ts | 14 +-- .../metadata-modules/role/role.service.ts | 4 +- .../dtos/setting-permission.dto.ts | 3 - .../dtos/upsert-setting-permission-input.ts | 23 +--- .../setting-permission.entity.ts | 3 - .../setting-permission.service.ts | 86 +++++++++---- .../roles.integration-spec.ts | 30 +++-- 23 files changed, 528 insertions(+), 165 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/roles/graphql/fragments/settingPermissionFragment.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/graphql/mutations/upsertSettingPermissionsMutation.ts create mode 100644 packages/twenty-front/src/utils/getDirtyFields.spec.ts create mode 100644 packages/twenty-front/src/utils/getDirtyFields.ts create mode 100644 packages/twenty-server/src/database/typeorm/metadata/migrations/1743605310126-removeCanUpdateSettingFromSettingPermission.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 9673293d3..dedafd5f4 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -948,7 +948,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']['output']; uploadWorkspaceLogo: Scalars['String']['output']; upsertOneObjectPermission: ObjectPermission; - upsertOneSettingPermission: SettingPermission; + upsertSettingPermissions: Array; userLookupAdminPanel: UserLookup; validateApprovedAccessDomain: ApprovedAccessDomain; }; @@ -1308,8 +1308,8 @@ export type MutationUpsertOneObjectPermissionArgs = { }; -export type MutationUpsertOneSettingPermissionArgs = { - upsertSettingPermissionInput: UpsertSettingPermissionInput; +export type MutationUpsertSettingPermissionsArgs = { + upsertSettingPermissionsInput: UpsertSettingPermissionsInput; }; @@ -1849,6 +1849,7 @@ export type Role = { id: Scalars['String']['output']; isEditable: Scalars['Boolean']['output']; label: Scalars['String']['output']; + settingPermissions?: Maybe>; workspaceMembers: Array; }; @@ -1958,7 +1959,6 @@ export enum ServerlessFunctionSyncStatus { export type SettingPermission = { __typename?: 'SettingPermission'; - canUpdateSetting?: Maybe; id: Scalars['String']['output']; roleId: Scalars['String']['output']; setting: SettingPermissionType; @@ -2262,10 +2262,9 @@ export type UpsertObjectPermissionInput = { roleId: Scalars['String']['input']; }; -export type UpsertSettingPermissionInput = { - canUpdateSetting?: InputMaybe; +export type UpsertSettingPermissionsInput = { roleId: Scalars['String']['input']; - setting: SettingPermissionType; + settingPermissionKeys: Array; }; export type User = { diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 45a450c77..04ccfde9c 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -864,7 +864,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']; uploadWorkspaceLogo: Scalars['String']; upsertOneObjectPermission: ObjectPermission; - upsertOneSettingPermission: SettingPermission; + upsertSettingPermissions: Array; userLookupAdminPanel: UserLookup; validateApprovedAccessDomain: ApprovedAccessDomain; }; @@ -1174,8 +1174,8 @@ export type MutationUpsertOneObjectPermissionArgs = { }; -export type MutationUpsertOneSettingPermissionArgs = { - upsertSettingPermissionInput: UpsertSettingPermissionInput; +export type MutationUpsertSettingPermissionsArgs = { + upsertSettingPermissionsInput: UpsertSettingPermissionsInput; }; @@ -1644,6 +1644,7 @@ export type Role = { id: Scalars['String']; isEditable: Scalars['Boolean']; label: Scalars['String']; + settingPermissions?: Maybe>; workspaceMembers: Array; }; @@ -1753,7 +1754,6 @@ export enum ServerlessFunctionSyncStatus { export type SettingPermission = { __typename?: 'SettingPermission'; - canUpdateSetting?: Maybe; id: Scalars['String']; roleId: Scalars['String']; setting: SettingPermissionType; @@ -2049,10 +2049,9 @@ export type UpsertObjectPermissionInput = { roleId: Scalars['String']; }; -export type UpsertSettingPermissionInput = { - canUpdateSetting?: InputMaybe; +export type UpsertSettingPermissionsInput = { roleId: Scalars['String']; - setting: SettingPermissionType; + settingPermissionKeys: Array; }; export type User = { @@ -2601,6 +2600,8 @@ export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', upda export type RoleFragmentFragment = { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean }; +export type SettingPermissionFragmentFragment = { __typename?: 'SettingPermission', id: string, setting: SettingPermissionType, roleId: string }; + export type CreateOneRoleMutationVariables = Exact<{ createRoleInput: CreateRoleInput; }>; @@ -2623,10 +2624,17 @@ export type UpdateWorkspaceMemberRoleMutationVariables = Exact<{ export type UpdateWorkspaceMemberRoleMutation = { __typename?: 'Mutation', updateWorkspaceMemberRole: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, roles?: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean }> | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } }; +export type UpsertSettingPermissionsMutationVariables = Exact<{ + upsertSettingPermissionsInput: UpsertSettingPermissionsInput; +}>; + + +export type UpsertSettingPermissionsMutation = { __typename?: 'Mutation', upsertSettingPermissions: Array<{ __typename?: 'SettingPermission', id: string, setting: SettingPermissionType, roleId: 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, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> }> }; +export type GetRolesQuery = { __typename?: 'Query', getRoles: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }>, settingPermissions?: Array<{ __typename?: 'SettingPermission', id: string, setting: SettingPermissionType, roleId: string }> | null }> }; export type CreateApprovedAccessDomainMutationVariables = Exact<{ input: CreateApprovedAccessDomainInput; @@ -2941,6 +2949,13 @@ export const AvailableSsoIdentityProvidersFragmentFragmentDoc = gql` } } `; +export const SettingPermissionFragmentFragmentDoc = gql` + fragment SettingPermissionFragment on SettingPermission { + id + setting + roleId +} + `; export const WorkspaceMemberQueryFragmentFragmentDoc = gql` fragment WorkspaceMemberQueryFragment on WorkspaceMember { id @@ -4737,6 +4752,41 @@ export function useUpdateWorkspaceMemberRoleMutation(baseOptions?: Apollo.Mutati export type UpdateWorkspaceMemberRoleMutationHookResult = ReturnType; export type UpdateWorkspaceMemberRoleMutationResult = Apollo.MutationResult; export type UpdateWorkspaceMemberRoleMutationOptions = Apollo.BaseMutationOptions; +export const UpsertSettingPermissionsDocument = gql` + mutation UpsertSettingPermissions($upsertSettingPermissionsInput: UpsertSettingPermissionsInput!) { + upsertSettingPermissions( + upsertSettingPermissionsInput: $upsertSettingPermissionsInput + ) { + ...SettingPermissionFragment + } +} + ${SettingPermissionFragmentFragmentDoc}`; +export type UpsertSettingPermissionsMutationFn = Apollo.MutationFunction; + +/** + * __useUpsertSettingPermissionsMutation__ + * + * To run a mutation, you first call `useUpsertSettingPermissionsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpsertSettingPermissionsMutation` 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 [upsertSettingPermissionsMutation, { data, loading, error }] = useUpsertSettingPermissionsMutation({ + * variables: { + * upsertSettingPermissionsInput: // value for 'upsertSettingPermissionsInput' + * }, + * }); + */ +export function useUpsertSettingPermissionsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpsertSettingPermissionsDocument, options); + } +export type UpsertSettingPermissionsMutationHookResult = ReturnType; +export type UpsertSettingPermissionsMutationResult = Apollo.MutationResult; +export type UpsertSettingPermissionsMutationOptions = Apollo.BaseMutationOptions; export const GetRolesDocument = gql` query GetRoles { getRoles { @@ -4744,10 +4794,14 @@ export const GetRolesDocument = gql` workspaceMembers { ...WorkspaceMemberQueryFragment } + settingPermissions { + ...SettingPermissionFragment + } } } ${RoleFragmentFragmentDoc} -${WorkspaceMemberQueryFragmentFragmentDoc}`; +${WorkspaceMemberQueryFragmentFragmentDoc} +${SettingPermissionFragmentFragmentDoc}`; /** * __useGetRolesQuery__ diff --git a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesQueryEffect.tsx b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesQueryEffect.tsx index 9d3a1fb16..fea085883 100644 --- a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesQueryEffect.tsx +++ b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesQueryEffect.tsx @@ -1,3 +1,4 @@ +import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState'; import { settingsRoleIdsState } from '@/settings/roles/states/settingsRoleIdsState'; import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState'; @@ -21,6 +22,7 @@ export const SettingsRolesQueryEffect = () => { const roleIds = roles.map((role) => role.id); set(settingsRoleIdsState, roleIds); roles.forEach((role) => { + set(settingsDraftRoleFamilyState(role.id), role); set(settingsPersistedRoleFamilyState(role.id), role); }); }, diff --git a/packages/twenty-front/src/modules/settings/roles/graphql/fragments/settingPermissionFragment.ts b/packages/twenty-front/src/modules/settings/roles/graphql/fragments/settingPermissionFragment.ts new file mode 100644 index 000000000..c61f457b2 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/graphql/fragments/settingPermissionFragment.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const SETTING_PERMISSION_FRAGMENT = gql` + fragment SettingPermissionFragment on SettingPermission { + id + setting + roleId + } +`; diff --git a/packages/twenty-front/src/modules/settings/roles/graphql/mutations/upsertSettingPermissionsMutation.ts b/packages/twenty-front/src/modules/settings/roles/graphql/mutations/upsertSettingPermissionsMutation.ts new file mode 100644 index 000000000..8923e8f01 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/graphql/mutations/upsertSettingPermissionsMutation.ts @@ -0,0 +1,15 @@ +import { SETTING_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/settingPermissionFragment'; +import { gql } from '@apollo/client'; + +export const UPSERT_SETTING_PERMISSIONS = gql` + ${SETTING_PERMISSION_FRAGMENT} + mutation UpsertSettingPermissions( + $upsertSettingPermissionsInput: UpsertSettingPermissionsInput! + ) { + upsertSettingPermissions( + upsertSettingPermissionsInput: $upsertSettingPermissionsInput + ) { + ...SettingPermissionFragment + } + } +`; 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 6434b48ed..7223ef524 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,16 +1,21 @@ import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment'; +import { SETTING_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/settingPermissionFragment'; import { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment'; import { gql } from '@apollo/client'; export const GET_ROLES = gql` ${WORKSPACE_MEMBER_QUERY_FRAGMENT} ${ROLE_FRAGMENT} + ${SETTING_PERMISSION_FRAGMENT} query GetRoles { getRoles { ...RoleFragment workspaceMembers { ...WorkspaceMemberQueryFragment } + settingPermissions { + ...SettingPermissionFragment + } } } `; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissions.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissions.tsx index 232927ebe..3c9708612 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissions.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissions.tsx @@ -1,3 +1,4 @@ +import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; import { SettingsRolePermissionsObjectsTableHeader } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsObjectsTableHeader'; import { SettingsRolePermissionsObjectsTableRow } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsObjectsTableRow'; import { SettingsRolePermissionsSettingsTableHeader } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableHeader'; @@ -5,10 +6,10 @@ import { SettingsRolePermissionsSettingsTableRow } from '@/settings/roles/role-p import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; import { SettingsRolePermissionsObjectPermission } from '@/settings/roles/types/SettingsRolePermissionsObjectPermission'; import { SettingsRolePermissionsSettingPermission } from '@/settings/roles/types/SettingsRolePermissionsSettingPermission'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; import { useRecoilState } from 'recoil'; -import { SettingPermissionType } from '~/generated-metadata/graphql'; import { H2Title, IconCode, @@ -23,7 +24,11 @@ import { IconTrashX, IconUsers, } from 'twenty-ui/display'; -import { Section } from 'twenty-ui/layout'; +import { Card, Section } from 'twenty-ui/layout'; +import { + FeatureFlagKey, + SettingPermissionType, +} from '~/generated-metadata/graphql'; const StyledRolePermissionsContainer = styled.div` display: flex; @@ -40,6 +45,10 @@ const StyledTableRows = styled.div` padding-top: ${({ theme }) => theme.spacing(2)}; `; +const StyledCard = styled(Card)` + margin-bottom: ${({ theme }) => theme.spacing(4)}; +`; + type SettingsRolePermissionsProps = { roleId: string; isEditable: boolean; @@ -110,53 +119,50 @@ export const SettingsRolePermissions = ({ key: SettingPermissionType.API_KEYS_AND_WEBHOOKS, name: t`API Keys & Webhooks`, description: t`Manage API keys and webhooks`, - value: settingsDraftRole.canUpdateAllSettings, Icon: IconCode, }, { key: SettingPermissionType.WORKSPACE, name: t`Workspace`, description: t`Set global workspace preferences`, - value: settingsDraftRole.canUpdateAllSettings, Icon: IconSettings, }, { key: SettingPermissionType.WORKSPACE_MEMBERS, name: t`Users`, description: t`Add or remove users`, - value: settingsDraftRole.canUpdateAllSettings, Icon: IconUsers, }, { key: SettingPermissionType.ROLES, name: t`Roles`, description: t`Define user roles and access levels`, - value: settingsDraftRole.canUpdateAllSettings, Icon: IconLockOpen, }, { key: SettingPermissionType.DATA_MODEL, name: t`Data Model`, description: t`Edit CRM data structure and fields`, - value: settingsDraftRole.canUpdateAllSettings, Icon: IconHierarchy, }, { key: SettingPermissionType.ADMIN_PANEL, name: t`Admin Panel`, description: t`Admin settings and system tools`, - value: settingsDraftRole.canUpdateAllSettings, Icon: IconServer, }, { key: SettingPermissionType.SECURITY, name: t`Security`, description: t`Manage security policies`, - value: settingsDraftRole.canUpdateAllSettings, Icon: IconKey, }, ]; + const isPermissionsV2Enabled = useIsFeatureEnabled( + FeatureFlagKey.IsPermissionsV2Enabled, + ); + return (
@@ -183,15 +189,32 @@ export const SettingsRolePermissions = ({
+ {isPermissionsV2Enabled && ( + + { + setSettingsDraftRole({ + ...settingsDraftRole, + canUpdateAllSettings: !settingsDraftRole.canUpdateAllSettings, + }); + }} + /> + + )} - + {settingsPermissionsConfig.map((permission) => ( ))} diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableHeader.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableHeader.tsx index d37de11ec..983c21fd1 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableHeader.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableHeader.tsx @@ -1,42 +1,11 @@ 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 { Checkbox } from 'twenty-ui/input'; -const StyledNameHeader = styled(TableHeader)` - flex: 1; -`; - -const StyledTypeHeader = styled(TableHeader)` - flex: 1; -`; - -const StyledActionsHeader = styled(TableHeader)` - align-items: center; - display: flex; - justify-content: flex-end; - padding-right: ${({ theme }) => theme.spacing(4)}; -`; - -type SettingsRolePermissionsSettingsTableHeaderProps = { - allPermissions: boolean; - onToggleAll?: () => void; -}; - -export const SettingsRolePermissionsSettingsTableHeader = ({ - allPermissions, - onToggleAll, -}: SettingsRolePermissionsSettingsTableHeaderProps) => ( +export const SettingsRolePermissionsSettingsTableHeader = () => ( - {t`Name`} - {t`Description`} - - - + {t`Name`} + {t`Description`} + ); diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableRow.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableRow.tsx index 536b2350b..38978d4af 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableRow.tsx @@ -1,9 +1,14 @@ +import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState'; import { SettingsRolePermissionsSettingPermission } from '@/settings/roles/types/SettingsRolePermissionsSettingPermission'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; import { Checkbox } from 'twenty-ui/input'; +import { v4 } from 'uuid'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; const StyledName = styled.span` color: ${({ theme }) => theme.font.color.primary}; @@ -34,13 +39,54 @@ const StyledIconContainer = styled.div` `; type SettingsRolePermissionsSettingsTableRowProps = { + roleId: string; permission: SettingsRolePermissionsSettingPermission; + isEditable: boolean; }; export const SettingsRolePermissionsSettingsTableRow = ({ + roleId, permission, + isEditable, }: SettingsRolePermissionsSettingsTableRowProps) => { const theme = useTheme(); + const [settingsDraftRole, setSettingsDraftRole] = useRecoilState( + settingsDraftRoleFamilyState(roleId), + ); + const isPermissionsV2Enabled = useIsFeatureEnabled( + FeatureFlagKey.IsPermissionsV2Enabled, + ); + const canUpdateAllSettings = settingsDraftRole.canUpdateAllSettings; + + const isSettingPermissionEnabled = + settingsDraftRole.settingPermissions?.some( + (settingPermission) => settingPermission.setting === permission.key, + ) ?? false; + + const handleChange = (value: boolean) => { + const currentPermissions = settingsDraftRole.settingPermissions ?? []; + + if (value === true) { + setSettingsDraftRole({ + ...settingsDraftRole, + settingPermissions: [ + ...currentPermissions, + { + id: v4(), + setting: permission.key, + roleId, + }, + ], + }); + } else { + setSettingsDraftRole({ + ...settingsDraftRole, + settingPermissions: currentPermissions.filter( + (p) => p.setting !== permission.key, + ), + }); + } + }; return ( @@ -58,7 +104,13 @@ export const SettingsRolePermissionsSettingsTableRow = ({ {permission.description} - + handleChange(event.target.checked)} + /> ); diff --git a/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx b/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx index 7859f7af6..38696d3ea 100644 --- a/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx @@ -1,4 +1,5 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery'; import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole'; import { SettingsRoleAssignment } from '@/settings/roles/role-assignment/components/SettingsRoleAssignment'; import { SettingsRolePermissions } from '@/settings/roles/role-permissions/components/SettingsRolePermissions'; @@ -14,26 +15,41 @@ import { TabList } from '@/ui/layout/tab/components/TabList'; import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { getOperationName } from '@apollo/client/utilities'; import { t } from '@lingui/core/macro'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; +import { IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; import { v4 } from 'uuid'; import { FeatureFlagKey, + Role, useCreateOneRoleMutation, useUpdateOneRoleMutation, + useUpsertSettingPermissionsMutation, } from '~/generated/graphql'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { getDirtyFields } from '~/utils/getDirtyFields'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; -import { Button } from 'twenty-ui/input'; -import { IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui/display'; type SettingsRoleProps = { roleId: string; isCreateMode: boolean; }; +const ROLE_BASIC_KEYS: Array = [ + 'label', + 'description', + 'icon', + 'canUpdateAllSettings', + 'canReadAllObjectRecords', + 'canUpdateAllObjectRecords', + 'canSoftDeleteAllObjectRecords', + 'canDestroyAllObjectRecords', +]; + export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => { const activeTabId = useRecoilComponentValueV2( activeTabIdComponentState, @@ -48,6 +64,11 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => { const [createRole] = useCreateOneRoleMutation(); const [updateRole] = useUpdateOneRoleMutation(); + const [upsertSettingPermissions] = useUpsertSettingPermissionsMutation(); + + const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId); + + const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState); const settingsDraftRole = useRecoilValue( settingsDraftRoleFamilyState(roleId), @@ -57,10 +78,6 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => { settingsPersistedRoleFamilyState(roleId), ); - const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId); - - const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState); - if (!isDefined(settingsRolesIsLoading)) { return <>; } @@ -87,7 +104,12 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => { const isDirty = !isDeeplyEqual(settingsDraftRole, settingsPersistedRole); - const handleSave = () => { + const handleSave = async () => { + const dirtyFields = getDirtyFields( + settingsDraftRole, + settingsPersistedRole, + ); + if (isCreateMode) { const roleId = v4(); @@ -116,33 +138,63 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => { ), }); + await upsertSettingPermissions({ + variables: { + upsertSettingPermissionsInput: { + roleId: data.createOneRole.id, + settingPermissionKeys: + settingsDraftRole.settingPermissions?.map( + (settingPermission) => settingPermission.setting, + ) ?? [], + }, + }, + refetchQueries: [getOperationName(GET_ROLES) ?? ''], + }); + navigateSettings(SettingsPath.RoleDetail, { roleId: data.createOneRole.id, }); }, }); } else { - updateRole({ - variables: { - updateRoleInput: { - id: roleId, - update: { - label: settingsDraftRole.label, - description: settingsDraftRole.description, - icon: settingsDraftRole.icon, - canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings, - canReadAllObjectRecords: - settingsDraftRole.canReadAllObjectRecords, - canUpdateAllObjectRecords: - settingsDraftRole.canUpdateAllObjectRecords, - canSoftDeleteAllObjectRecords: - settingsDraftRole.canSoftDeleteAllObjectRecords, - canDestroyAllObjectRecords: - settingsDraftRole.canDestroyAllObjectRecords, + if (ROLE_BASIC_KEYS.some((key) => key in dirtyFields)) { + await updateRole({ + variables: { + updateRoleInput: { + id: roleId, + update: { + label: settingsDraftRole.label, + description: settingsDraftRole.description, + icon: settingsDraftRole.icon, + canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings, + canReadAllObjectRecords: + settingsDraftRole.canReadAllObjectRecords, + canUpdateAllObjectRecords: + settingsDraftRole.canUpdateAllObjectRecords, + canSoftDeleteAllObjectRecords: + settingsDraftRole.canSoftDeleteAllObjectRecords, + canDestroyAllObjectRecords: + settingsDraftRole.canDestroyAllObjectRecords, + }, }, }, - }, - }); + }); + } + + if (isDefined(dirtyFields.settingPermissions)) { + await upsertSettingPermissions({ + variables: { + upsertSettingPermissionsInput: { + roleId: roleId, + settingPermissionKeys: + settingsDraftRole.settingPermissions?.map( + (settingPermission) => settingPermission.setting, + ) ?? [], + }, + }, + refetchQueries: [getOperationName(GET_ROLES) ?? ''], + }); + } } }; 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 41d6fc16f..22c2a50f7 100644 --- a/packages/twenty-front/src/modules/settings/roles/states/settingsDraftRoleFamilyState.ts +++ b/packages/twenty-front/src/modules/settings/roles/states/settingsDraftRoleFamilyState.ts @@ -15,5 +15,6 @@ export const settingsDraftRoleFamilyState = createFamilyState({ canUpdateAllSettings: false, isEditable: false, workspaceMembers: [], + settingPermissions: [], }, }); diff --git a/packages/twenty-front/src/modules/settings/roles/types/SettingsRolePermissionsSettingPermission.ts b/packages/twenty-front/src/modules/settings/roles/types/SettingsRolePermissionsSettingPermission.ts index 9f5518071..8ccb79ca4 100644 --- a/packages/twenty-front/src/modules/settings/roles/types/SettingsRolePermissionsSettingPermission.ts +++ b/packages/twenty-front/src/modules/settings/roles/types/SettingsRolePermissionsSettingPermission.ts @@ -1,8 +1,9 @@ import { IconComponent } from 'twenty-ui/display'; +import { SettingPermissionType } from '~/generated-metadata/graphql'; + export type SettingsRolePermissionsSettingPermission = { - key: string; + key: SettingPermissionType; name: string; description: string; - value: boolean; Icon: IconComponent; }; diff --git a/packages/twenty-front/src/utils/getDirtyFields.spec.ts b/packages/twenty-front/src/utils/getDirtyFields.spec.ts new file mode 100644 index 000000000..e30cc3fa9 --- /dev/null +++ b/packages/twenty-front/src/utils/getDirtyFields.spec.ts @@ -0,0 +1,115 @@ +import { getDirtyFields } from './getDirtyFields'; + +describe('getDirtyFields', () => { + it('should return all defined fields from draft when persisted is null', () => { + const draft = { a: 1, b: 'hello', c: undefined, d: null }; + const persisted = null; + expect(getDirtyFields(draft, persisted)).toEqual({ + a: 1, + b: 'hello', + d: null, + }); + }); + + it('should return all defined fields from draft when persisted is undefined', () => { + const draft = { a: 1, b: 'hello', c: undefined, d: false }; + const persisted = undefined; + expect(getDirtyFields(draft, persisted)).toEqual({ + a: 1, + b: 'hello', + d: false, + }); + }); + + it('should return an empty object when draft and persisted are identical', () => { + const draft = { a: 1, b: { c: 2 }, d: [1, 2] }; + const persisted = { a: 1, b: { c: 2 }, d: [1, 2] }; + expect(getDirtyFields(draft, persisted)).toEqual({}); + }); + + it('should detect simple value changes', () => { + const draft = { a: 1, b: 'world', c: true }; + const persisted = { a: 1, b: 'hello', c: false }; + expect(getDirtyFields(draft, persisted)).toEqual({ b: 'world', c: true }); + }); + + it('should detect nested object changes', () => { + const draft = { a: { b: { c: 3 } } }; + const persisted = { a: { b: { c: 2 } } }; + expect(getDirtyFields(draft, persisted)).toEqual({ a: { b: { c: 3 } } }); + }); + + it('should detect array changes', () => { + const draft = { a: [1, 3] }; + const persisted = { a: [1, 2] }; + expect(getDirtyFields(draft, persisted)).toEqual({ a: [1, 3] }); + }); + + it('should detect added fields', () => { + const draft = { a: 1, b: 2 }; + const persisted = { a: 1 }; + expect(getDirtyFields(draft, persisted)).toEqual({ b: 2 }); + }); + + it('should detect removed fields (value becomes undefined)', () => { + const draft = { a: 1 }; + const persisted = { a: 1, b: 2 }; + // When a field is removed, its value in draft effectively becomes undefined + // Cast persisted to any to satisfy TS in this test scenario + expect(getDirtyFields(draft, persisted as any)).toEqual({ b: undefined }); + }); + + it('should detect fields set to undefined', () => { + const draft = { a: 1, b: undefined }; + const persisted = { a: 1, b: 2 }; + // Cast persisted to any to satisfy TS in this test scenario + expect(getDirtyFields(draft, persisted as any)).toEqual({ b: undefined }); + }); + + it('should detect fields set to null', () => { + const draft = { a: 1, b: null }; + const persisted = { a: 1, b: 2 }; + // Cast persisted to any to satisfy TS in this test scenario + expect(getDirtyFields(draft, persisted as any)).toEqual({ b: null }); + }); + + it('should handle complex nested structures with mixed changes', () => { + const draft = { + id: 1, + name: 'new name', // changed + details: { + status: 'active', // same + tags: ['tag1', 'tag3'], // changed + metadata: { key: 'newValue' }, // changed + }, + settings: { enabled: true }, // new + }; + const persisted = { + id: 1, + name: 'old name', + details: { + status: 'active', + tags: ['tag1', 'tag2'], + metadata: { key: 'oldValue' }, + }, + archived: true, // removed + }; + // Cast persisted to any to satisfy TS in this test scenario + expect(getDirtyFields(draft, persisted as any)).toEqual({ + name: 'new name', + details: { + status: 'active', + tags: ['tag1', 'tag3'], + metadata: { key: 'newValue' }, + }, + settings: { enabled: true }, + archived: undefined, + }); + }); + + it('should return empty object for deeply equal but different reference objects', () => { + const draft = { a: { b: 1 } }; + const persisted = JSON.parse(JSON.stringify(draft)); // Deep clone, different reference + expect(getDirtyFields(draft, persisted)).toEqual({}); + }); +}); diff --git a/packages/twenty-front/src/utils/getDirtyFields.ts b/packages/twenty-front/src/utils/getDirtyFields.ts new file mode 100644 index 000000000..ba49dc5f7 --- /dev/null +++ b/packages/twenty-front/src/utils/getDirtyFields.ts @@ -0,0 +1,26 @@ +import { isDeeplyEqual } from './isDeeplyEqual'; + +export const getDirtyFields = >( + draft: T, + persisted: T | null | undefined, +): Partial => { + if (!persisted) { + return Object.fromEntries( + Object.entries(draft).filter(([, value]) => value !== undefined), + ) as Partial; + } + + const dirty: Partial = {}; + const allKeys = new Set([...Object.keys(draft), ...Object.keys(persisted)]); + + for (const key of allKeys) { + const draftValue = draft[key as keyof T]; + const persistedValue = persisted[key as keyof T]; + + if (!isDeeplyEqual(draftValue, persistedValue)) { + dirty[key as keyof T] = draftValue; + } + } + + return dirty; +}; diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1743605310126-removeCanUpdateSettingFromSettingPermission.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1743605310126-removeCanUpdateSettingFromSettingPermission.ts new file mode 100644 index 000000000..69c560b56 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1743605310126-removeCanUpdateSettingFromSettingPermission.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveCanUpdateSettingFromSettingPermission1743605310126 + implements MigrationInterface +{ + name = 'RemoveCanUpdateSettingFromSettingPermission1743605310126'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."settingPermission" DROP COLUMN "canUpdateSetting"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."settingPermission" ADD "canUpdateSetting" 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 1ff1ab28a..1f7cacad9 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,6 +4,7 @@ import { Relation } from 'typeorm'; import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; +import { SettingPermissionDTO } from 'src/engine/metadata-modules/setting-permission/dtos/setting-permission.dto'; @ObjectType('Role') export class RoleDTO { @@ -42,4 +43,7 @@ export class RoleDTO { @Field({ nullable: false }) canDestroyAllObjectRecords: boolean; + + @Field(() => [SettingPermissionDTO], { nullable: true }) + settingPermissions?: SettingPermissionDTO[]; } 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 db2be37dc..e60e08f22 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 @@ -31,7 +31,7 @@ import { 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 { SettingPermissionDTO } from 'src/engine/metadata-modules/setting-permission/dtos/setting-permission.dto'; -import { UpsertSettingPermissionInput } from 'src/engine/metadata-modules/setting-permission/dtos/upsert-setting-permission-input'; +import { UpsertSettingPermissionsInput } from 'src/engine/metadata-modules/setting-permission/dtos/upsert-setting-permission-input'; import { SettingPermissionService } from 'src/engine/metadata-modules/setting-permission/setting-permission.service'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; @@ -154,17 +154,17 @@ export class RoleResolver { }); } - @Mutation(() => SettingPermissionDTO) - async upsertOneSettingPermission( + @Mutation(() => [SettingPermissionDTO]) + async upsertSettingPermissions( @AuthWorkspace() workspace: Workspace, - @Args('upsertSettingPermissionInput') - upsertSettingPermissionInput: UpsertSettingPermissionInput, + @Args('upsertSettingPermissionsInput') + upsertSettingPermissionsInput: UpsertSettingPermissionsInput, ) { await this.validatePermissionsV2EnabledOrThrow(workspace); - return this.settingPermissionService.upsertSettingPermission({ + return this.settingPermissionService.upsertSettingPermissions({ workspaceId: workspace.id, - input: upsertSettingPermissionInput, + input: upsertSettingPermissionsInput, }); } 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 6f4ccee83..c6d10ed32 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 @@ -37,7 +37,7 @@ export class RoleService { where: { workspaceId, }, - relations: ['userWorkspaceRoles'], + relations: ['userWorkspaceRoles', 'settingPermissions'], }); } @@ -50,7 +50,7 @@ export class RoleService { id, workspaceId, }, - relations: ['userWorkspaceRoles'], + relations: ['userWorkspaceRoles', 'settingPermissions'], }); } diff --git a/packages/twenty-server/src/engine/metadata-modules/setting-permission/dtos/setting-permission.dto.ts b/packages/twenty-server/src/engine/metadata-modules/setting-permission/dtos/setting-permission.dto.ts index ce401f5de..ed2cf2894 100644 --- a/packages/twenty-server/src/engine/metadata-modules/setting-permission/dtos/setting-permission.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/setting-permission/dtos/setting-permission.dto.ts @@ -12,7 +12,4 @@ export class SettingPermissionDTO { @Field({ nullable: false }) setting: SettingPermissionType; - - @Field({ nullable: true }) - canUpdateSetting?: boolean; } diff --git a/packages/twenty-server/src/engine/metadata-modules/setting-permission/dtos/upsert-setting-permission-input.ts b/packages/twenty-server/src/engine/metadata-modules/setting-permission/dtos/upsert-setting-permission-input.ts index 845831196..c7f679e4f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/setting-permission/dtos/upsert-setting-permission-input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/setting-permission/dtos/upsert-setting-permission-input.ts @@ -1,29 +1,18 @@ import { Field, InputType } from '@nestjs/graphql'; -import { - IsBoolean, - IsNotEmpty, - IsOptional, - IsString, - IsUUID, -} from 'class-validator'; +import { IsArray, IsEnum, IsNotEmpty, IsUUID } from 'class-validator'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; @InputType() -export class UpsertSettingPermissionInput { +export class UpsertSettingPermissionsInput { @IsUUID() @IsNotEmpty() @Field() roleId: string; - @IsString() - @IsNotEmpty() - @Field({ nullable: false }) - setting: SettingPermissionType; - - @IsBoolean() - @IsOptional() - @Field({ nullable: true }) - canUpdateSetting?: boolean; + @IsArray() + @IsEnum(SettingPermissionType, { each: true }) + @Field(() => [SettingPermissionType]) + settingPermissionKeys: SettingPermissionType[]; } diff --git a/packages/twenty-server/src/engine/metadata-modules/setting-permission/setting-permission.entity.ts b/packages/twenty-server/src/engine/metadata-modules/setting-permission/setting-permission.entity.ts index e463baec2..264d2f4ec 100644 --- a/packages/twenty-server/src/engine/metadata-modules/setting-permission/setting-permission.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/setting-permission/setting-permission.entity.ts @@ -31,9 +31,6 @@ export class SettingPermissionEntity { @Column({ nullable: false, type: 'varchar' }) setting: SettingPermissionType; - @Column({ nullable: true, type: 'boolean' }) - canUpdateSetting?: boolean; - @Column({ nullable: false, type: 'uuid' }) workspaceId: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/setting-permission/setting-permission.service.ts b/packages/twenty-server/src/engine/metadata-modules/setting-permission/setting-permission.service.ts index 2ee3444d0..e9cba219b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/setting-permission/setting-permission.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/setting-permission/setting-permission.service.ts @@ -1,7 +1,7 @@ -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { DataSource, In, Repository } from 'typeorm'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { @@ -10,7 +10,7 @@ import { PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; -import { UpsertSettingPermissionInput } from 'src/engine/metadata-modules/setting-permission/dtos/upsert-setting-permission-input'; +import { UpsertSettingPermissionsInput } from 'src/engine/metadata-modules/setting-permission/dtos/upsert-setting-permission-input'; import { SettingPermissionEntity } from 'src/engine/metadata-modules/setting-permission/setting-permission.entity'; export class SettingPermissionService { @@ -19,55 +19,90 @@ export class SettingPermissionService { private readonly settingPermissionRepository: Repository, @InjectRepository(RoleEntity, 'metadata') private readonly roleRepository: Repository, + @InjectDataSource('metadata') + private readonly metadataDataSource: DataSource, ) {} - public async upsertSettingPermission({ + public async upsertSettingPermissions({ workspaceId, input, }: { workspaceId: string; - input: UpsertSettingPermissionInput; - }): Promise { + input: UpsertSettingPermissionsInput; + }): Promise { await this.validateRoleIsEditableOrThrow({ roleId: input.roleId, workspaceId, }); - if (!Object.values(SettingPermissionType).includes(input.setting)) { + const invalidSettings = input.settingPermissionKeys.filter( + (setting) => !Object.values(SettingPermissionType).includes(setting), + ); + + if (invalidSettings.length > 0) { throw new PermissionsException( - PermissionsExceptionMessage.INVALID_SETTING, + `${PermissionsExceptionMessage.INVALID_SETTING}: ${invalidSettings.join(', ')}`, PermissionsExceptionCode.INVALID_SETTING, ); } + const queryRunner = this.metadataDataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { - const result = await this.settingPermissionRepository.upsert( + const existingPermissions = await queryRunner.manager.find( + SettingPermissionEntity, { - workspaceId, - ...input, - }, - { - conflictPaths: ['setting', 'roleId'], + where: { + roleId: input.roleId, + workspaceId, + }, }, ); + const existingSettings = new Set( + existingPermissions.map((p) => p.setting), + ); + const inputSettings = new Set(input.settingPermissionKeys); - const settingPermissionId = result.generatedMaps?.[0]?.id; + const settingsToAdd = input.settingPermissionKeys.filter( + (setting) => !existingSettings.has(setting), + ); + const permissionsToRemove = existingPermissions.filter( + (permission) => !inputSettings.has(permission.setting), + ); - if (!isDefined(settingPermissionId)) { - throw new Error('Failed to upsert setting permission'); + if (permissionsToRemove.length > 0) { + await queryRunner.manager.delete(SettingPermissionEntity, { + id: In(permissionsToRemove.map((p) => p.id)), + }); } - return this.settingPermissionRepository.findOne({ - where: { - id: settingPermissionId, - }, + if (settingsToAdd.length > 0) { + const newPermissions = settingsToAdd.map((setting) => + queryRunner.manager.create(SettingPermissionEntity, { + workspaceId, + roleId: input.roleId, + setting, + }), + ); + + await queryRunner.manager.save(SettingPermissionEntity, newPermissions); + } + + await queryRunner.commitTransaction(); + + return queryRunner.manager.find(SettingPermissionEntity, { + where: { roleId: input.roleId, workspaceId }, + order: { setting: 'ASC' }, }); } catch (error) { + await queryRunner.rollbackTransaction(); + if (error.message.includes('violates foreign key constraint')) { const role = await this.roleRepository.findOne({ - where: { - id: input.roleId, - }, + where: { id: input.roleId }, }); if (!isDefined(role)) { @@ -77,8 +112,9 @@ export class SettingPermissionService { ); } } - throw error; + } finally { + await queryRunner.release(); } } 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 4b655562f..c3f09c1dd 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 @@ -550,25 +550,24 @@ describe('roles permissions', () => { }); }); - describe('upsertSettingPermission', () => { - const upsertSettingPermissionMutation = ({ + describe('upsertSettingPermissions', () => { + const upsertSettingPermissionsMutation = ({ roleId, }: { roleId: string; }) => ` mutation UpsertSettingPermissions { - upsertOneSettingPermission(upsertSettingPermissionInput: {roleId: "${roleId}", setting: ${SettingPermissionType.DATA_MODEL}, canUpdateSetting: true}) { + upsertSettingPermissions(upsertSettingPermissionsInput: {roleId: "${roleId}", settingPermissionKeys: [${SettingPermissionType.DATA_MODEL}]}) { id roleId setting - canUpdateSetting } } `; - it('should throw a permission error when user does not have permission to upsert object permission (member role)', async () => { + it('should throw a permission error when user does not have permission to upsert setting permission (member role)', async () => { const query = { - query: upsertSettingPermissionMutation({ + query: upsertSettingPermissionsMutation({ roleId: guestRoleId, }), }; @@ -578,7 +577,7 @@ describe('roles permissions', () => { it('should throw an error when role is not editable', async () => { const query = { - query: upsertSettingPermissionMutation({ + query: upsertSettingPermissionsMutation({ roleId: adminRoleId, }), }; @@ -602,7 +601,7 @@ describe('roles permissions', () => { it('should upsert a setting permission when user has permission', async () => { const query = { - query: upsertSettingPermissionMutation({ + query: upsertSettingPermissionsMutation({ roleId: createdEditableRoleId, }), }; @@ -615,14 +614,13 @@ describe('roles permissions', () => { .expect((res) => { expect(res.body.data).toBeDefined(); expect(res.body.errors).toBeUndefined(); - expect(res.body.data.upsertOneSettingPermission.roleId).toBe( - createdEditableRoleId, - ); - expect( - res.body.data.upsertOneSettingPermission.canUpdateSetting, - ).toBe(true); - expect(res.body.data.upsertOneSettingPermission.setting).toBe( - SettingPermissionType.DATA_MODEL, + expect(res.body.data.upsertSettingPermissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + roleId: createdEditableRoleId, + setting: SettingPermissionType.DATA_MODEL, + }), + ]), ); }); });