Add delete role action (#12691)

## Context
Add delete role action, the backend takes care of most of the operations
(can't delete a default role, can't delete the admin role, re-assign
existing members to default role...)

<img width="592" alt="Screenshot 2025-06-17 at 20 24 21"
src="https://github.com/user-attachments/assets/3f01f12c-d8a4-466c-b4c7-9674f597a7a8"
/>

<img width="567" alt="Screenshot 2025-06-17 at 20 24 24"
src="https://github.com/user-attachments/assets/8aceaf6c-3082-4ca6-a4dd-9767fc186923"
/>
This commit is contained in:
Weiko
2025-06-18 00:43:23 +02:00
committed by GitHub
parent 338f08b3a4
commit 6650d4b059
12 changed files with 230 additions and 48 deletions

View File

@ -2862,6 +2862,13 @@ export type CreateOneRoleMutationVariables = Exact<{
export type CreateOneRoleMutation = { __typename?: 'Mutation', createOneRole: { __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 DeleteOneRoleMutationVariables = Exact<{
roleId: Scalars['String'];
}>;
export type DeleteOneRoleMutation = { __typename?: 'Mutation', deleteOneRole: string };
export type UpdateOneRoleMutationVariables = Exact<{
updateRoleInput: UpdateRoleInput;
}>;
@ -5389,6 +5396,37 @@ export function useCreateOneRoleMutation(baseOptions?: Apollo.MutationHookOption
export type CreateOneRoleMutationHookResult = ReturnType<typeof useCreateOneRoleMutation>;
export type CreateOneRoleMutationResult = Apollo.MutationResult<CreateOneRoleMutation>;
export type CreateOneRoleMutationOptions = Apollo.BaseMutationOptions<CreateOneRoleMutation, CreateOneRoleMutationVariables>;
export const DeleteOneRoleDocument = gql`
mutation DeleteOneRole($roleId: String!) {
deleteOneRole(roleId: $roleId)
}
`;
export type DeleteOneRoleMutationFn = Apollo.MutationFunction<DeleteOneRoleMutation, DeleteOneRoleMutationVariables>;
/**
* __useDeleteOneRoleMutation__
*
* To run a mutation, you first call `useDeleteOneRoleMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteOneRoleMutation` 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 [deleteOneRoleMutation, { data, loading, error }] = useDeleteOneRoleMutation({
* variables: {
* roleId: // value for 'roleId'
* },
* });
*/
export function useDeleteOneRoleMutation(baseOptions?: Apollo.MutationHookOptions<DeleteOneRoleMutation, DeleteOneRoleMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteOneRoleMutation, DeleteOneRoleMutationVariables>(DeleteOneRoleDocument, options);
}
export type DeleteOneRoleMutationHookResult = ReturnType<typeof useDeleteOneRoleMutation>;
export type DeleteOneRoleMutationResult = Apollo.MutationResult<DeleteOneRoleMutation>;
export type DeleteOneRoleMutationOptions = Apollo.BaseMutationOptions<DeleteOneRoleMutation, DeleteOneRoleMutationVariables>;
export const UpdateOneRoleDocument = gql`
mutation UpdateOneRole($updateRoleInput: UpdateRoleInput!) {
updateOneRole(updateRoleInput: $updateRoleInput) {

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_ROLE = gql`
mutation DeleteOneRole($roleId: String!) {
deleteOneRole(roleId: $roleId)
}
`;

View File

@ -14,6 +14,7 @@ import {
IconKey,
IconLockOpen,
IconSettings,
IconSettingsAutomation,
IconUsers,
} from 'twenty-ui/display';
import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout';
@ -90,6 +91,12 @@ export const SettingsRolePermissionsSettingsSection = ({
description: t`Manage security policies`,
Icon: IconKey,
},
{
key: SettingPermissionType.WORKFLOWS,
name: t`Workflows`,
description: t`Manage workflows`,
Icon: IconSettingsAutomation,
},
];
return (

View File

@ -1,11 +1,16 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilState } from 'recoil';
import { ROLE_SETTINGS_DELETE_ROLE_CONFIRMATION_MODAL_ID } from '@/settings/roles/role-settings/components/constants/RoleSettingsDeleteRoleConfirmationModalId';
import { SettingsRoleSettingsDeleteRoleConfirmationModal } from '@/settings/roles/role-settings/components/SettingsRoleSettingsDeleteRoleConfirmationModal';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { useRecoilState } from 'recoil';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
const StyledInputsContainer = styled.div`
@ -23,57 +28,85 @@ const StyledInputContainer = styled.div`
type SettingsRoleSettingsProps = {
roleId: string;
isEditable: boolean;
isCreateMode: boolean;
};
export const SettingsRoleSettings = ({
roleId,
isEditable,
isCreateMode,
}: SettingsRoleSettingsProps) => {
const [settingsDraftRole, setSettingsDraftRole] = useRecoilState(
settingsDraftRoleFamilyState(roleId),
);
const { openModal } = useModal();
return (
<Section>
<StyledInputsContainer>
<StyledInputContainer>
<IconPicker
selectedIconKey={settingsDraftRole.icon ?? 'IconUser'}
dropdownId="role-settings-icon-picker"
onChange={({ iconKey }: { iconKey: string }) => {
<>
<Section>
<StyledInputsContainer>
<StyledInputContainer>
<IconPicker
selectedIconKey={settingsDraftRole.icon ?? 'IconUser'}
dropdownId="role-settings-icon-picker"
onChange={({ iconKey }: { iconKey: string }) => {
setSettingsDraftRole({
...settingsDraftRole,
icon: iconKey,
});
}}
disabled={!isEditable}
/>
</StyledInputContainer>
<TextInput
value={settingsDraftRole.label}
fullWidth
onChange={(value: string) => {
setSettingsDraftRole({
...settingsDraftRole,
icon: iconKey,
label: value,
});
}}
placeholder={t`Role name`}
disabled={!isEditable}
/>
</StyledInputContainer>
<TextInput
value={settingsDraftRole.label}
fullWidth
</StyledInputsContainer>
<TextArea
minRows={4}
placeholder={t`Write a description`}
value={settingsDraftRole.description || ''}
onChange={(value: string) => {
setSettingsDraftRole({
...settingsDraftRole,
label: value,
description: value,
});
}}
placeholder={t`Role name`}
disabled={!isEditable}
/>
</StyledInputsContainer>
<TextArea
minRows={4}
placeholder={t`Write a description`}
value={settingsDraftRole.description || ''}
onChange={(value: string) => {
setSettingsDraftRole({
...settingsDraftRole,
description: value,
});
}}
disabled={!isEditable}
/>
</Section>
</Section>
{!isCreateMode && (
<>
<Section>
<H2Title
title={t`Danger zone`}
description={t`Delete this role and assign a new role to its members`}
/>
<Button
title={t`Delete role`}
size="small"
variant="secondary"
accent="danger"
onClick={() => {
openModal(ROLE_SETTINGS_DELETE_ROLE_CONFIRMATION_MODAL_ID);
}}
disabled={!isEditable}
/>
</Section>
<SettingsRoleSettingsDeleteRoleConfirmationModal roleId={roleId} />
</>
)}
</>
);
};

View File

@ -0,0 +1,41 @@
import { ROLE_SETTINGS_DELETE_ROLE_CONFIRMATION_MODAL_ID } from '@/settings/roles/role-settings/components/constants/RoleSettingsDeleteRoleConfirmationModalId';
import { SettingsRoleSettingsDeleteRoleConfirmationModalSubtitle } from '@/settings/roles/role-settings/components/SettingsRoleSettingsDeleteRoleConfirmationModalSubtitle';
import { SettingsPath } from '@/types/SettingsPath';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { t } from '@lingui/core/macro';
import { useDeleteOneRoleMutation } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type SettingsRoleSettingsDeleteRoleConfirmationModalProps = {
roleId: string;
};
export const SettingsRoleSettingsDeleteRoleConfirmationModal = ({
roleId,
}: SettingsRoleSettingsDeleteRoleConfirmationModalProps) => {
const [deleteRole] = useDeleteOneRoleMutation();
const navigateSettings = useNavigateSettings();
const handleConfirmClick = async () => {
await deleteRole({
variables: { roleId },
});
navigateSettings(SettingsPath.Roles);
};
return (
<ConfirmationModal
modalId={ROLE_SETTINGS_DELETE_ROLE_CONFIRMATION_MODAL_ID}
title={t`Delete Role Permanently`}
subtitle={
<SettingsRoleSettingsDeleteRoleConfirmationModalSubtitle
roleId={roleId}
/>
}
onConfirmClick={handleConfirmClick}
confirmButtonText={t`Confirm`}
confirmButtonAccent="danger"
/>
);
};

View File

@ -0,0 +1,20 @@
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
type SettingsRoleSettingsDeleteRoleConfirmationModalSubtitleProps = {
roleId: string;
};
export const SettingsRoleSettingsDeleteRoleConfirmationModalSubtitle = ({
roleId,
}: SettingsRoleSettingsDeleteRoleConfirmationModalSubtitleProps) => {
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId),
);
const roleName = settingsDraftRole.label;
return (
<>{t`Confirm deletion of ${roleName} role? This cannot be undone. All members will be reassigned to the default role.`}</>
);
};

View File

@ -0,0 +1,2 @@
export const ROLE_SETTINGS_DELETE_ROLE_CONFIRMATION_MODAL_ID =
'role-settings-delete-role-confirmation-modal';

View File

@ -3,10 +3,10 @@ import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDr
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { PENDING_ROLE_ID } from '~/pages/settings/roles/SettingsRoleCreate';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
const SettingsRoleSettingsWrapper = (
args: React.ComponentProps<typeof SettingsRoleSettings>,
@ -22,7 +22,11 @@ const SettingsRoleSettingsWrapper = (
}
return (
<SettingsRoleSettings roleId={args.roleId} isEditable={args.isEditable} />
<SettingsRoleSettings
roleId={args.roleId}
isEditable={args.isEditable}
isCreateMode={args.isCreateMode}
/>
);
};
@ -39,6 +43,7 @@ export const Default: Story = {
args: {
roleId: '1',
isEditable: true,
isCreateMode: false,
},
};
@ -46,11 +51,22 @@ export const ReadOnly: Story = {
args: {
roleId: '1',
isEditable: false,
isCreateMode: false,
},
};
export const PendingRole: Story = {
args: {
roleId: PENDING_ROLE_ID,
isEditable: true,
isCreateMode: false,
},
};
export const CreateMode: Story = {
args: {
roleId: '1',
isEditable: true,
isCreateMode: true,
},
};

View File

@ -205,6 +205,21 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
},
});
} else {
if (isDefined(dirtyFields.settingPermissions)) {
await upsertSettingPermissions({
variables: {
upsertSettingPermissionsInput: {
roleId: roleId,
settingPermissionKeys:
settingsDraftRole.settingPermissions?.map(
(settingPermission) => settingPermission.setting,
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (ROLE_BASIC_KEYS.some((key) => key in dirtyFields)) {
await updateRole({
variables: {
@ -229,21 +244,6 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
});
}
if (isDefined(dirtyFields.settingPermissions)) {
await upsertSettingPermissions({
variables: {
upsertSettingPermissionsInput: {
roleId: roleId,
settingPermissionKeys:
settingsDraftRole.settingPermissions?.map(
(settingPermission) => settingPermission.setting,
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isDefined(dirtyFields.objectPermissions)) {
await upsertObjectPermissions({
variables: {
@ -310,7 +310,11 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
/>
)}
{activeTabId === SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.SETTINGS && (
<SettingsRoleSettings roleId={roleId} isEditable={isRoleEditable} />
<SettingsRoleSettings
roleId={roleId}
isEditable={isRoleEditable}
isCreateMode={isCreateMode}
/>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>

View File

@ -2,6 +2,7 @@ import { SETTINGS_ROLE_DETAIL_TABS } from '@/settings/roles/role/constants/Setti
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { t } from '@lingui/core/macro';
import { useEffect, useState } from 'react';
import { useSetRecoilState } from 'recoil';
@ -31,7 +32,7 @@ export const SettingsRoleCreateEffect = ({
const newRole = {
id: roleId,
label: '',
label: t`Role name`,
description: '',
icon: 'IconUser',
canUpdateAllSettings: true,

View File

@ -4,11 +4,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { SettingPermissionEntity } from 'src/engine/metadata-modules/setting-permission/setting-permission.entity';
import { SettingPermissionService } from 'src/engine/metadata-modules/setting-permission/setting-permission.service';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
@Module({
imports: [
TypeOrmModule.forFeature([SettingPermissionEntity, RoleEntity], 'core'),
WorkspacePermissionsCacheModule,
],
providers: [SettingPermissionService],
exports: [SettingPermissionService],
})

View File

@ -12,6 +12,7 @@ import {
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
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 { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
export class SettingPermissionService {
constructor(
@ -21,6 +22,7 @@ export class SettingPermissionService {
private readonly roleRepository: Repository<RoleEntity>,
@InjectDataSource('core')
private readonly coreDataSource: DataSource,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
) {}
public async upsertSettingPermissions({
@ -115,6 +117,14 @@ export class SettingPermissionService {
throw error;
} finally {
await queryRunner.release();
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache(
{
workspaceId,
roleIds: [input.roleId],
ignoreLock: true,
},
);
}
}