add settings permissions update (#11377)

Fixes https://github.com/twentyhq/core-team-issues/issues/710
This commit is contained in:
Weiko
2025-04-04 17:40:14 +02:00
committed by GitHub
parent 6142e193ce
commit e1f6c61651
23 changed files with 528 additions and 165 deletions

View File

@ -948,7 +948,7 @@ export type Mutation = {
uploadProfilePicture: Scalars['String']['output']; uploadProfilePicture: Scalars['String']['output'];
uploadWorkspaceLogo: Scalars['String']['output']; uploadWorkspaceLogo: Scalars['String']['output'];
upsertOneObjectPermission: ObjectPermission; upsertOneObjectPermission: ObjectPermission;
upsertOneSettingPermission: SettingPermission; upsertSettingPermissions: Array<SettingPermission>;
userLookupAdminPanel: UserLookup; userLookupAdminPanel: UserLookup;
validateApprovedAccessDomain: ApprovedAccessDomain; validateApprovedAccessDomain: ApprovedAccessDomain;
}; };
@ -1308,8 +1308,8 @@ export type MutationUpsertOneObjectPermissionArgs = {
}; };
export type MutationUpsertOneSettingPermissionArgs = { export type MutationUpsertSettingPermissionsArgs = {
upsertSettingPermissionInput: UpsertSettingPermissionInput; upsertSettingPermissionsInput: UpsertSettingPermissionsInput;
}; };
@ -1849,6 +1849,7 @@ export type Role = {
id: Scalars['String']['output']; id: Scalars['String']['output'];
isEditable: Scalars['Boolean']['output']; isEditable: Scalars['Boolean']['output'];
label: Scalars['String']['output']; label: Scalars['String']['output'];
settingPermissions?: Maybe<Array<SettingPermission>>;
workspaceMembers: Array<WorkspaceMember>; workspaceMembers: Array<WorkspaceMember>;
}; };
@ -1958,7 +1959,6 @@ export enum ServerlessFunctionSyncStatus {
export type SettingPermission = { export type SettingPermission = {
__typename?: 'SettingPermission'; __typename?: 'SettingPermission';
canUpdateSetting?: Maybe<Scalars['Boolean']['output']>;
id: Scalars['String']['output']; id: Scalars['String']['output'];
roleId: Scalars['String']['output']; roleId: Scalars['String']['output'];
setting: SettingPermissionType; setting: SettingPermissionType;
@ -2262,10 +2262,9 @@ export type UpsertObjectPermissionInput = {
roleId: Scalars['String']['input']; roleId: Scalars['String']['input'];
}; };
export type UpsertSettingPermissionInput = { export type UpsertSettingPermissionsInput = {
canUpdateSetting?: InputMaybe<Scalars['Boolean']['input']>;
roleId: Scalars['String']['input']; roleId: Scalars['String']['input'];
setting: SettingPermissionType; settingPermissionKeys: Array<SettingPermissionType>;
}; };
export type User = { export type User = {

View File

@ -864,7 +864,7 @@ export type Mutation = {
uploadProfilePicture: Scalars['String']; uploadProfilePicture: Scalars['String'];
uploadWorkspaceLogo: Scalars['String']; uploadWorkspaceLogo: Scalars['String'];
upsertOneObjectPermission: ObjectPermission; upsertOneObjectPermission: ObjectPermission;
upsertOneSettingPermission: SettingPermission; upsertSettingPermissions: Array<SettingPermission>;
userLookupAdminPanel: UserLookup; userLookupAdminPanel: UserLookup;
validateApprovedAccessDomain: ApprovedAccessDomain; validateApprovedAccessDomain: ApprovedAccessDomain;
}; };
@ -1174,8 +1174,8 @@ export type MutationUpsertOneObjectPermissionArgs = {
}; };
export type MutationUpsertOneSettingPermissionArgs = { export type MutationUpsertSettingPermissionsArgs = {
upsertSettingPermissionInput: UpsertSettingPermissionInput; upsertSettingPermissionsInput: UpsertSettingPermissionsInput;
}; };
@ -1644,6 +1644,7 @@ export type Role = {
id: Scalars['String']; id: Scalars['String'];
isEditable: Scalars['Boolean']; isEditable: Scalars['Boolean'];
label: Scalars['String']; label: Scalars['String'];
settingPermissions?: Maybe<Array<SettingPermission>>;
workspaceMembers: Array<WorkspaceMember>; workspaceMembers: Array<WorkspaceMember>;
}; };
@ -1753,7 +1754,6 @@ export enum ServerlessFunctionSyncStatus {
export type SettingPermission = { export type SettingPermission = {
__typename?: 'SettingPermission'; __typename?: 'SettingPermission';
canUpdateSetting?: Maybe<Scalars['Boolean']>;
id: Scalars['String']; id: Scalars['String'];
roleId: Scalars['String']; roleId: Scalars['String'];
setting: SettingPermissionType; setting: SettingPermissionType;
@ -2049,10 +2049,9 @@ export type UpsertObjectPermissionInput = {
roleId: Scalars['String']; roleId: Scalars['String'];
}; };
export type UpsertSettingPermissionInput = { export type UpsertSettingPermissionsInput = {
canUpdateSetting?: InputMaybe<Scalars['Boolean']>;
roleId: Scalars['String']; roleId: Scalars['String'];
setting: SettingPermissionType; settingPermissionKeys: Array<SettingPermissionType>;
}; };
export type User = { 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 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<{ export type CreateOneRoleMutationVariables = Exact<{
createRoleInput: CreateRoleInput; 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 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 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<{ export type CreateApprovedAccessDomainMutationVariables = Exact<{
input: CreateApprovedAccessDomainInput; 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` export const WorkspaceMemberQueryFragmentFragmentDoc = gql`
fragment WorkspaceMemberQueryFragment on WorkspaceMember { fragment WorkspaceMemberQueryFragment on WorkspaceMember {
id id
@ -4737,6 +4752,41 @@ export function useUpdateWorkspaceMemberRoleMutation(baseOptions?: Apollo.Mutati
export type UpdateWorkspaceMemberRoleMutationHookResult = ReturnType<typeof useUpdateWorkspaceMemberRoleMutation>; export type UpdateWorkspaceMemberRoleMutationHookResult = ReturnType<typeof useUpdateWorkspaceMemberRoleMutation>;
export type UpdateWorkspaceMemberRoleMutationResult = Apollo.MutationResult<UpdateWorkspaceMemberRoleMutation>; export type UpdateWorkspaceMemberRoleMutationResult = Apollo.MutationResult<UpdateWorkspaceMemberRoleMutation>;
export type UpdateWorkspaceMemberRoleMutationOptions = Apollo.BaseMutationOptions<UpdateWorkspaceMemberRoleMutation, UpdateWorkspaceMemberRoleMutationVariables>; export type UpdateWorkspaceMemberRoleMutationOptions = Apollo.BaseMutationOptions<UpdateWorkspaceMemberRoleMutation, UpdateWorkspaceMemberRoleMutationVariables>;
export const UpsertSettingPermissionsDocument = gql`
mutation UpsertSettingPermissions($upsertSettingPermissionsInput: UpsertSettingPermissionsInput!) {
upsertSettingPermissions(
upsertSettingPermissionsInput: $upsertSettingPermissionsInput
) {
...SettingPermissionFragment
}
}
${SettingPermissionFragmentFragmentDoc}`;
export type UpsertSettingPermissionsMutationFn = Apollo.MutationFunction<UpsertSettingPermissionsMutation, UpsertSettingPermissionsMutationVariables>;
/**
* __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<UpsertSettingPermissionsMutation, UpsertSettingPermissionsMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpsertSettingPermissionsMutation, UpsertSettingPermissionsMutationVariables>(UpsertSettingPermissionsDocument, options);
}
export type UpsertSettingPermissionsMutationHookResult = ReturnType<typeof useUpsertSettingPermissionsMutation>;
export type UpsertSettingPermissionsMutationResult = Apollo.MutationResult<UpsertSettingPermissionsMutation>;
export type UpsertSettingPermissionsMutationOptions = Apollo.BaseMutationOptions<UpsertSettingPermissionsMutation, UpsertSettingPermissionsMutationVariables>;
export const GetRolesDocument = gql` export const GetRolesDocument = gql`
query GetRoles { query GetRoles {
getRoles { getRoles {
@ -4744,10 +4794,14 @@ export const GetRolesDocument = gql`
workspaceMembers { workspaceMembers {
...WorkspaceMemberQueryFragment ...WorkspaceMemberQueryFragment
} }
settingPermissions {
...SettingPermissionFragment
}
} }
} }
${RoleFragmentFragmentDoc} ${RoleFragmentFragmentDoc}
${WorkspaceMemberQueryFragmentFragmentDoc}`; ${WorkspaceMemberQueryFragmentFragmentDoc}
${SettingPermissionFragmentFragmentDoc}`;
/** /**
* __useGetRolesQuery__ * __useGetRolesQuery__

View File

@ -1,3 +1,4 @@
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState'; import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { settingsRoleIdsState } from '@/settings/roles/states/settingsRoleIdsState'; import { settingsRoleIdsState } from '@/settings/roles/states/settingsRoleIdsState';
import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState'; import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState';
@ -21,6 +22,7 @@ export const SettingsRolesQueryEffect = () => {
const roleIds = roles.map((role) => role.id); const roleIds = roles.map((role) => role.id);
set(settingsRoleIdsState, roleIds); set(settingsRoleIdsState, roleIds);
roles.forEach((role) => { roles.forEach((role) => {
set(settingsDraftRoleFamilyState(role.id), role);
set(settingsPersistedRoleFamilyState(role.id), role); set(settingsPersistedRoleFamilyState(role.id), role);
}); });
}, },

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const SETTING_PERMISSION_FRAGMENT = gql`
fragment SettingPermissionFragment on SettingPermission {
id
setting
roleId
}
`;

View File

@ -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
}
}
`;

View File

@ -1,16 +1,21 @@
import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment'; 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 { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment';
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
export const GET_ROLES = gql` export const GET_ROLES = gql`
${WORKSPACE_MEMBER_QUERY_FRAGMENT} ${WORKSPACE_MEMBER_QUERY_FRAGMENT}
${ROLE_FRAGMENT} ${ROLE_FRAGMENT}
${SETTING_PERMISSION_FRAGMENT}
query GetRoles { query GetRoles {
getRoles { getRoles {
...RoleFragment ...RoleFragment
workspaceMembers { workspaceMembers {
...WorkspaceMemberQueryFragment ...WorkspaceMemberQueryFragment
} }
settingPermissions {
...SettingPermissionFragment
}
} }
} }
`; `;

View File

@ -1,3 +1,4 @@
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { SettingsRolePermissionsObjectsTableHeader } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsObjectsTableHeader'; import { SettingsRolePermissionsObjectsTableHeader } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsObjectsTableHeader';
import { SettingsRolePermissionsObjectsTableRow } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsObjectsTableRow'; import { SettingsRolePermissionsObjectsTableRow } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsObjectsTableRow';
import { SettingsRolePermissionsSettingsTableHeader } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableHeader'; 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 { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SettingsRolePermissionsObjectPermission } from '@/settings/roles/types/SettingsRolePermissionsObjectPermission'; import { SettingsRolePermissionsObjectPermission } from '@/settings/roles/types/SettingsRolePermissionsObjectPermission';
import { SettingsRolePermissionsSettingPermission } from '@/settings/roles/types/SettingsRolePermissionsSettingPermission'; import { SettingsRolePermissionsSettingPermission } from '@/settings/roles/types/SettingsRolePermissionsSettingPermission';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { SettingPermissionType } from '~/generated-metadata/graphql';
import { import {
H2Title, H2Title,
IconCode, IconCode,
@ -23,7 +24,11 @@ import {
IconTrashX, IconTrashX,
IconUsers, IconUsers,
} from 'twenty-ui/display'; } 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` const StyledRolePermissionsContainer = styled.div`
display: flex; display: flex;
@ -40,6 +45,10 @@ const StyledTableRows = styled.div`
padding-top: ${({ theme }) => theme.spacing(2)}; padding-top: ${({ theme }) => theme.spacing(2)};
`; `;
const StyledCard = styled(Card)`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
type SettingsRolePermissionsProps = { type SettingsRolePermissionsProps = {
roleId: string; roleId: string;
isEditable: boolean; isEditable: boolean;
@ -110,53 +119,50 @@ export const SettingsRolePermissions = ({
key: SettingPermissionType.API_KEYS_AND_WEBHOOKS, key: SettingPermissionType.API_KEYS_AND_WEBHOOKS,
name: t`API Keys & Webhooks`, name: t`API Keys & Webhooks`,
description: t`Manage API keys and webhooks`, description: t`Manage API keys and webhooks`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconCode, Icon: IconCode,
}, },
{ {
key: SettingPermissionType.WORKSPACE, key: SettingPermissionType.WORKSPACE,
name: t`Workspace`, name: t`Workspace`,
description: t`Set global workspace preferences`, description: t`Set global workspace preferences`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconSettings, Icon: IconSettings,
}, },
{ {
key: SettingPermissionType.WORKSPACE_MEMBERS, key: SettingPermissionType.WORKSPACE_MEMBERS,
name: t`Users`, name: t`Users`,
description: t`Add or remove users`, description: t`Add or remove users`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconUsers, Icon: IconUsers,
}, },
{ {
key: SettingPermissionType.ROLES, key: SettingPermissionType.ROLES,
name: t`Roles`, name: t`Roles`,
description: t`Define user roles and access levels`, description: t`Define user roles and access levels`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconLockOpen, Icon: IconLockOpen,
}, },
{ {
key: SettingPermissionType.DATA_MODEL, key: SettingPermissionType.DATA_MODEL,
name: t`Data Model`, name: t`Data Model`,
description: t`Edit CRM data structure and fields`, description: t`Edit CRM data structure and fields`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconHierarchy, Icon: IconHierarchy,
}, },
{ {
key: SettingPermissionType.ADMIN_PANEL, key: SettingPermissionType.ADMIN_PANEL,
name: t`Admin Panel`, name: t`Admin Panel`,
description: t`Admin settings and system tools`, description: t`Admin settings and system tools`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconServer, Icon: IconServer,
}, },
{ {
key: SettingPermissionType.SECURITY, key: SettingPermissionType.SECURITY,
name: t`Security`, name: t`Security`,
description: t`Manage security policies`, description: t`Manage security policies`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconKey, Icon: IconKey,
}, },
]; ];
const isPermissionsV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsPermissionsV2Enabled,
);
return ( return (
<StyledRolePermissionsContainer> <StyledRolePermissionsContainer>
<Section> <Section>
@ -183,15 +189,32 @@ export const SettingsRolePermissions = ({
</Section> </Section>
<Section> <Section>
<H2Title title={t`Settings`} description={t`Settings permissions`} /> <H2Title title={t`Settings`} description={t`Settings permissions`} />
{isPermissionsV2Enabled && (
<StyledCard rounded>
<SettingsOptionCardContentToggle
Icon={IconSettings}
title={t`Settings All Access`}
description={t`Ability to edit all settings`}
checked={settingsDraftRole.canUpdateAllSettings}
disabled={!isEditable}
onChange={() => {
setSettingsDraftRole({
...settingsDraftRole,
canUpdateAllSettings: !settingsDraftRole.canUpdateAllSettings,
});
}}
/>
</StyledCard>
)}
<StyledTable> <StyledTable>
<SettingsRolePermissionsSettingsTableHeader <SettingsRolePermissionsSettingsTableHeader />
allPermissions={settingsDraftRole.canUpdateAllSettings}
/>
<StyledTableRows> <StyledTableRows>
{settingsPermissionsConfig.map((permission) => ( {settingsPermissionsConfig.map((permission) => (
<SettingsRolePermissionsSettingsTableRow <SettingsRolePermissionsSettingsTableRow
key={permission.key} key={permission.key}
roleId={roleId}
permission={permission} permission={permission}
isEditable={isEditable}
/> />
))} ))}
</StyledTableRows> </StyledTableRows>

View File

@ -1,42 +1,11 @@
import { TableHeader } from '@/ui/layout/table/components/TableHeader'; import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Checkbox } from 'twenty-ui/input';
const StyledNameHeader = styled(TableHeader)` export const SettingsRolePermissionsSettingsTableHeader = () => (
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) => (
<TableRow gridAutoColumns="3fr 4fr 24px"> <TableRow gridAutoColumns="3fr 4fr 24px">
<StyledNameHeader>{t`Name`}</StyledNameHeader> <TableHeader>{t`Name`}</TableHeader>
<StyledTypeHeader>{t`Description`}</StyledTypeHeader> <TableHeader>{t`Description`}</TableHeader>
<StyledActionsHeader aria-label={t`Actions`}> <TableHeader></TableHeader>
<Checkbox
checked={allPermissions}
disabled={!onToggleAll}
onChange={onToggleAll}
/>
</StyledActionsHeader>
</TableRow> </TableRow>
); );

View File

@ -1,9 +1,14 @@
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SettingsRolePermissionsSettingPermission } from '@/settings/roles/types/SettingsRolePermissionsSettingPermission'; import { SettingsRolePermissionsSettingPermission } from '@/settings/roles/types/SettingsRolePermissionsSettingPermission';
import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { Checkbox } from 'twenty-ui/input'; import { Checkbox } from 'twenty-ui/input';
import { v4 } from 'uuid';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const StyledName = styled.span` const StyledName = styled.span`
color: ${({ theme }) => theme.font.color.primary}; color: ${({ theme }) => theme.font.color.primary};
@ -34,13 +39,54 @@ const StyledIconContainer = styled.div`
`; `;
type SettingsRolePermissionsSettingsTableRowProps = { type SettingsRolePermissionsSettingsTableRowProps = {
roleId: string;
permission: SettingsRolePermissionsSettingPermission; permission: SettingsRolePermissionsSettingPermission;
isEditable: boolean;
}; };
export const SettingsRolePermissionsSettingsTableRow = ({ export const SettingsRolePermissionsSettingsTableRow = ({
roleId,
permission, permission,
isEditable,
}: SettingsRolePermissionsSettingsTableRowProps) => { }: SettingsRolePermissionsSettingsTableRowProps) => {
const theme = useTheme(); 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 ( return (
<TableRow key={permission.key} gridAutoColumns="3fr 4fr 24px"> <TableRow key={permission.key} gridAutoColumns="3fr 4fr 24px">
@ -58,7 +104,13 @@ export const SettingsRolePermissionsSettingsTableRow = ({
<StyledDescription>{permission.description}</StyledDescription> <StyledDescription>{permission.description}</StyledDescription>
</StyledPermissionCell> </StyledPermissionCell>
<StyledCheckboxCell> <StyledCheckboxCell>
<Checkbox checked={permission.value} disabled /> <Checkbox
checked={isSettingPermissionEnabled || canUpdateAllSettings}
disabled={
!isEditable || canUpdateAllSettings || !isPermissionsV2Enabled
}
onChange={(event) => handleChange(event.target.checked)}
/>
</StyledCheckboxCell> </StyledCheckboxCell>
</TableRow> </TableRow>
); );

View File

@ -1,4 +1,5 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery';
import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole'; import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole';
import { SettingsRoleAssignment } from '@/settings/roles/role-assignment/components/SettingsRoleAssignment'; import { SettingsRoleAssignment } from '@/settings/roles/role-assignment/components/SettingsRoleAssignment';
import { SettingsRolePermissions } from '@/settings/roles/role-permissions/components/SettingsRolePermissions'; 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 { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { getOperationName } from '@apollo/client/utilities';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; 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 { v4 } from 'uuid';
import { import {
FeatureFlagKey, FeatureFlagKey,
Role,
useCreateOneRoleMutation, useCreateOneRoleMutation,
useUpdateOneRoleMutation, useUpdateOneRoleMutation,
useUpsertSettingPermissionsMutation,
} from '~/generated/graphql'; } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getDirtyFields } from '~/utils/getDirtyFields';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { Button } from 'twenty-ui/input';
import { IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui/display';
type SettingsRoleProps = { type SettingsRoleProps = {
roleId: string; roleId: string;
isCreateMode: boolean; isCreateMode: boolean;
}; };
const ROLE_BASIC_KEYS: Array<keyof Role> = [
'label',
'description',
'icon',
'canUpdateAllSettings',
'canReadAllObjectRecords',
'canUpdateAllObjectRecords',
'canSoftDeleteAllObjectRecords',
'canDestroyAllObjectRecords',
];
export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => { export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
const activeTabId = useRecoilComponentValueV2( const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState, activeTabIdComponentState,
@ -48,6 +64,11 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
const [createRole] = useCreateOneRoleMutation(); const [createRole] = useCreateOneRoleMutation();
const [updateRole] = useUpdateOneRoleMutation(); const [updateRole] = useUpdateOneRoleMutation();
const [upsertSettingPermissions] = useUpsertSettingPermissionsMutation();
const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId);
const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState);
const settingsDraftRole = useRecoilValue( const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId), settingsDraftRoleFamilyState(roleId),
@ -57,10 +78,6 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
settingsPersistedRoleFamilyState(roleId), settingsPersistedRoleFamilyState(roleId),
); );
const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId);
const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState);
if (!isDefined(settingsRolesIsLoading)) { if (!isDefined(settingsRolesIsLoading)) {
return <></>; return <></>;
} }
@ -87,7 +104,12 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
const isDirty = !isDeeplyEqual(settingsDraftRole, settingsPersistedRole); const isDirty = !isDeeplyEqual(settingsDraftRole, settingsPersistedRole);
const handleSave = () => { const handleSave = async () => {
const dirtyFields = getDirtyFields(
settingsDraftRole,
settingsPersistedRole,
);
if (isCreateMode) { if (isCreateMode) {
const roleId = v4(); 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, { navigateSettings(SettingsPath.RoleDetail, {
roleId: data.createOneRole.id, roleId: data.createOneRole.id,
}); });
}, },
}); });
} else { } else {
updateRole({ if (ROLE_BASIC_KEYS.some((key) => key in dirtyFields)) {
variables: { await updateRole({
updateRoleInput: { variables: {
id: roleId, updateRoleInput: {
update: { id: roleId,
label: settingsDraftRole.label, update: {
description: settingsDraftRole.description, label: settingsDraftRole.label,
icon: settingsDraftRole.icon, description: settingsDraftRole.description,
canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings, icon: settingsDraftRole.icon,
canReadAllObjectRecords: canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings,
settingsDraftRole.canReadAllObjectRecords, canReadAllObjectRecords:
canUpdateAllObjectRecords: settingsDraftRole.canReadAllObjectRecords,
settingsDraftRole.canUpdateAllObjectRecords, canUpdateAllObjectRecords:
canSoftDeleteAllObjectRecords: settingsDraftRole.canUpdateAllObjectRecords,
settingsDraftRole.canSoftDeleteAllObjectRecords, canSoftDeleteAllObjectRecords:
canDestroyAllObjectRecords: settingsDraftRole.canSoftDeleteAllObjectRecords,
settingsDraftRole.canDestroyAllObjectRecords, canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
},
}, },
}, },
}, });
}); }
if (isDefined(dirtyFields.settingPermissions)) {
await upsertSettingPermissions({
variables: {
upsertSettingPermissionsInput: {
roleId: roleId,
settingPermissionKeys:
settingsDraftRole.settingPermissions?.map(
(settingPermission) => settingPermission.setting,
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
} }
}; };

View File

@ -15,5 +15,6 @@ export const settingsDraftRoleFamilyState = createFamilyState<Role, string>({
canUpdateAllSettings: false, canUpdateAllSettings: false,
isEditable: false, isEditable: false,
workspaceMembers: [], workspaceMembers: [],
settingPermissions: [],
}, },
}); });

View File

@ -1,8 +1,9 @@
import { IconComponent } from 'twenty-ui/display'; import { IconComponent } from 'twenty-ui/display';
import { SettingPermissionType } from '~/generated-metadata/graphql';
export type SettingsRolePermissionsSettingPermission = { export type SettingsRolePermissionsSettingPermission = {
key: string; key: SettingPermissionType;
name: string; name: string;
description: string; description: string;
value: boolean;
Icon: IconComponent; Icon: IconComponent;
}; };

View File

@ -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({});
});
});

View File

@ -0,0 +1,26 @@
import { isDeeplyEqual } from './isDeeplyEqual';
export const getDirtyFields = <T extends Record<string, any>>(
draft: T,
persisted: T | null | undefined,
): Partial<T> => {
if (!persisted) {
return Object.fromEntries(
Object.entries(draft).filter(([, value]) => value !== undefined),
) as Partial<T>;
}
const dirty: Partial<T> = {};
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;
};

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveCanUpdateSettingFromSettingPermission1743605310126
implements MigrationInterface
{
name = 'RemoveCanUpdateSettingFromSettingPermission1743605310126';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."settingPermission" DROP COLUMN "canUpdateSetting"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."settingPermission" ADD "canUpdateSetting" boolean`,
);
}
}

View File

@ -4,6 +4,7 @@ import { Relation } from 'typeorm';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; 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 { 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') @ObjectType('Role')
export class RoleDTO { export class RoleDTO {
@ -42,4 +43,7 @@ export class RoleDTO {
@Field({ nullable: false }) @Field({ nullable: false })
canDestroyAllObjectRecords: boolean; canDestroyAllObjectRecords: boolean;
@Field(() => [SettingPermissionDTO], { nullable: true })
settingPermissions?: SettingPermissionDTO[];
} }

View File

@ -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 { UpdateRoleInput } from 'src/engine/metadata-modules/role/dtos/update-role-input.dto';
import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { SettingPermissionDTO } from 'src/engine/metadata-modules/setting-permission/dtos/setting-permission.dto'; 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 { SettingPermissionService } from 'src/engine/metadata-modules/setting-permission/setting-permission.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.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'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@ -154,17 +154,17 @@ export class RoleResolver {
}); });
} }
@Mutation(() => SettingPermissionDTO) @Mutation(() => [SettingPermissionDTO])
async upsertOneSettingPermission( async upsertSettingPermissions(
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Args('upsertSettingPermissionInput') @Args('upsertSettingPermissionsInput')
upsertSettingPermissionInput: UpsertSettingPermissionInput, upsertSettingPermissionsInput: UpsertSettingPermissionsInput,
) { ) {
await this.validatePermissionsV2EnabledOrThrow(workspace); await this.validatePermissionsV2EnabledOrThrow(workspace);
return this.settingPermissionService.upsertSettingPermission({ return this.settingPermissionService.upsertSettingPermissions({
workspaceId: workspace.id, workspaceId: workspace.id,
input: upsertSettingPermissionInput, input: upsertSettingPermissionsInput,
}); });
} }

View File

@ -37,7 +37,7 @@ export class RoleService {
where: { where: {
workspaceId, workspaceId,
}, },
relations: ['userWorkspaceRoles'], relations: ['userWorkspaceRoles', 'settingPermissions'],
}); });
} }
@ -50,7 +50,7 @@ export class RoleService {
id, id,
workspaceId, workspaceId,
}, },
relations: ['userWorkspaceRoles'], relations: ['userWorkspaceRoles', 'settingPermissions'],
}); });
} }

View File

@ -12,7 +12,4 @@ export class SettingPermissionDTO {
@Field({ nullable: false }) @Field({ nullable: false })
setting: SettingPermissionType; setting: SettingPermissionType;
@Field({ nullable: true })
canUpdateSetting?: boolean;
} }

View File

@ -1,29 +1,18 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql';
import { import { IsArray, IsEnum, IsNotEmpty, IsUUID } from 'class-validator';
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
@InputType() @InputType()
export class UpsertSettingPermissionInput { export class UpsertSettingPermissionsInput {
@IsUUID() @IsUUID()
@IsNotEmpty() @IsNotEmpty()
@Field() @Field()
roleId: string; roleId: string;
@IsString() @IsArray()
@IsNotEmpty() @IsEnum(SettingPermissionType, { each: true })
@Field({ nullable: false }) @Field(() => [SettingPermissionType])
setting: SettingPermissionType; settingPermissionKeys: SettingPermissionType[];
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canUpdateSetting?: boolean;
} }

View File

@ -31,9 +31,6 @@ export class SettingPermissionEntity {
@Column({ nullable: false, type: 'varchar' }) @Column({ nullable: false, type: 'varchar' })
setting: SettingPermissionType; setting: SettingPermissionType;
@Column({ nullable: true, type: 'boolean' })
canUpdateSetting?: boolean;
@Column({ nullable: false, type: 'uuid' }) @Column({ nullable: false, type: 'uuid' })
workspaceId: string; workspaceId: string;

View File

@ -1,7 +1,7 @@
import { InjectRepository } from '@nestjs/typeorm'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils'; 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 { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { import {
@ -10,7 +10,7 @@ import {
PermissionsExceptionMessage, PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception'; } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; 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'; import { SettingPermissionEntity } from 'src/engine/metadata-modules/setting-permission/setting-permission.entity';
export class SettingPermissionService { export class SettingPermissionService {
@ -19,55 +19,90 @@ export class SettingPermissionService {
private readonly settingPermissionRepository: Repository<SettingPermissionEntity>, private readonly settingPermissionRepository: Repository<SettingPermissionEntity>,
@InjectRepository(RoleEntity, 'metadata') @InjectRepository(RoleEntity, 'metadata')
private readonly roleRepository: Repository<RoleEntity>, private readonly roleRepository: Repository<RoleEntity>,
@InjectDataSource('metadata')
private readonly metadataDataSource: DataSource,
) {} ) {}
public async upsertSettingPermission({ public async upsertSettingPermissions({
workspaceId, workspaceId,
input, input,
}: { }: {
workspaceId: string; workspaceId: string;
input: UpsertSettingPermissionInput; input: UpsertSettingPermissionsInput;
}): Promise<SettingPermissionEntity | null | undefined> { }): Promise<SettingPermissionEntity[]> {
await this.validateRoleIsEditableOrThrow({ await this.validateRoleIsEditableOrThrow({
roleId: input.roleId, roleId: input.roleId,
workspaceId, 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( throw new PermissionsException(
PermissionsExceptionMessage.INVALID_SETTING, `${PermissionsExceptionMessage.INVALID_SETTING}: ${invalidSettings.join(', ')}`,
PermissionsExceptionCode.INVALID_SETTING, PermissionsExceptionCode.INVALID_SETTING,
); );
} }
const queryRunner = this.metadataDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try { try {
const result = await this.settingPermissionRepository.upsert( const existingPermissions = await queryRunner.manager.find(
SettingPermissionEntity,
{ {
workspaceId, where: {
...input, roleId: input.roleId,
}, workspaceId,
{ },
conflictPaths: ['setting', 'roleId'],
}, },
); );
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)) { if (permissionsToRemove.length > 0) {
throw new Error('Failed to upsert setting permission'); await queryRunner.manager.delete(SettingPermissionEntity, {
id: In(permissionsToRemove.map((p) => p.id)),
});
} }
return this.settingPermissionRepository.findOne({ if (settingsToAdd.length > 0) {
where: { const newPermissions = settingsToAdd.map((setting) =>
id: settingPermissionId, 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) { } catch (error) {
await queryRunner.rollbackTransaction();
if (error.message.includes('violates foreign key constraint')) { if (error.message.includes('violates foreign key constraint')) {
const role = await this.roleRepository.findOne({ const role = await this.roleRepository.findOne({
where: { where: { id: input.roleId },
id: input.roleId,
},
}); });
if (!isDefined(role)) { if (!isDefined(role)) {
@ -77,8 +112,9 @@ export class SettingPermissionService {
); );
} }
} }
throw error; throw error;
} finally {
await queryRunner.release();
} }
} }

View File

@ -550,25 +550,24 @@ describe('roles permissions', () => {
}); });
}); });
describe('upsertSettingPermission', () => { describe('upsertSettingPermissions', () => {
const upsertSettingPermissionMutation = ({ const upsertSettingPermissionsMutation = ({
roleId, roleId,
}: { }: {
roleId: string; roleId: string;
}) => ` }) => `
mutation UpsertSettingPermissions { mutation UpsertSettingPermissions {
upsertOneSettingPermission(upsertSettingPermissionInput: {roleId: "${roleId}", setting: ${SettingPermissionType.DATA_MODEL}, canUpdateSetting: true}) { upsertSettingPermissions(upsertSettingPermissionsInput: {roleId: "${roleId}", settingPermissionKeys: [${SettingPermissionType.DATA_MODEL}]}) {
id id
roleId roleId
setting 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 = { const query = {
query: upsertSettingPermissionMutation({ query: upsertSettingPermissionsMutation({
roleId: guestRoleId, roleId: guestRoleId,
}), }),
}; };
@ -578,7 +577,7 @@ describe('roles permissions', () => {
it('should throw an error when role is not editable', async () => { it('should throw an error when role is not editable', async () => {
const query = { const query = {
query: upsertSettingPermissionMutation({ query: upsertSettingPermissionsMutation({
roleId: adminRoleId, roleId: adminRoleId,
}), }),
}; };
@ -602,7 +601,7 @@ describe('roles permissions', () => {
it('should upsert a setting permission when user has permission', async () => { it('should upsert a setting permission when user has permission', async () => {
const query = { const query = {
query: upsertSettingPermissionMutation({ query: upsertSettingPermissionsMutation({
roleId: createdEditableRoleId, roleId: createdEditableRoleId,
}), }),
}; };
@ -615,14 +614,13 @@ describe('roles permissions', () => {
.expect((res) => { .expect((res) => {
expect(res.body.data).toBeDefined(); expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined(); expect(res.body.errors).toBeUndefined();
expect(res.body.data.upsertOneSettingPermission.roleId).toBe( expect(res.body.data.upsertSettingPermissions).toEqual(
createdEditableRoleId, expect.arrayContaining([
); expect.objectContaining({
expect( roleId: createdEditableRoleId,
res.body.data.upsertOneSettingPermission.canUpdateSetting, setting: SettingPermissionType.DATA_MODEL,
).toBe(true); }),
expect(res.body.data.upsertOneSettingPermission.setting).toBe( ]),
SettingPermissionType.DATA_MODEL,
); );
}); });
}); });