Fix user deletion flows (#15614)
**Before** - any user with workpace_members permission was able to remove a user from their workspace. This triggered the deletion of workspaceMember + of userWorkspace, but did not delete the user (even if they had no workspace left) nor the roleTarget (acts as junction between role and userWorkspace) which was left with a userWorkspaceId pointing to nothing. This is because roleTarget points to userWorkspaceId but the foreign key constraint was not implemented - any user could delete their own account. This triggered the deletion of all their workspaceMembers, but not of their userWorkspace nor their user nor the roleTarget --> we have orphaned userWorkspace, not technically but product wise - a userWorkspace without a workspaceMember does not make sense So the problems are - we have some roleTargets pointing to non-existing userWorkspaceId (which caused https://github.com/twentyhq/twenty/issues/14608 ) - we have userWorkspaces that should not exist and that have no workspaceMember counterpart - it is not possible for a user to leave a workspace by themselves, they can only leave all workspaces at once, except if they are being removed from the workspace by another user **Now** - if a user has multiple workspaces, they are given the possibility to leave one workspace while remaining in the others (we show two buttons: Leave workspace and Delete account buttons). if a user has just one workspace, they only see Delete account - when a user leaves a workspace, we delete their workspaceMember, userWorkspace and roleTarget. If they don't belong to any other workspace we also soft-delete their user - soft-deleted users get hard deleted after 30 days thanks to a cron - we have two commands to clean the orphans roleTarget and userWorkspace (TODO: query db to see how many must be run) **Next** - once the commands have been run, we can implement and introduce the foreign key constraint on roleTarget Fixes https://github.com/twentyhq/twenty/issues/14608
This commit is contained in:
@@ -1813,6 +1813,7 @@ export type Mutation = {
|
||||
deleteSSOIdentityProvider: DeleteSsoOutput;
|
||||
deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput;
|
||||
deleteUser: User;
|
||||
deleteUserFromWorkspace: UserWorkspace;
|
||||
deleteWebhook: Scalars['Boolean'];
|
||||
deleteWorkflowVersionEdge: WorkflowVersionStepChanges;
|
||||
deleteWorkflowVersionStep: WorkflowVersionStepChanges;
|
||||
@@ -2295,6 +2296,11 @@ export type MutationDeleteTwoFactorAuthenticationMethodArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteUserFromWorkspaceArgs = {
|
||||
workspaceMemberIdToDelete: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteWebhookArgs = {
|
||||
input: DeleteWebhookInput;
|
||||
};
|
||||
@@ -5917,6 +5923,13 @@ export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }
|
||||
|
||||
export type DeleteUserAccountMutation = { __typename?: 'Mutation', deleteUser: { __typename?: 'User', id: string } };
|
||||
|
||||
export type DeleteUserWorkspaceMutationVariables = Exact<{
|
||||
workspaceMemberIdToDelete: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteUserWorkspaceMutation = { __typename?: 'Mutation', deleteUserFromWorkspace: { __typename?: 'UserWorkspace', id: string } };
|
||||
|
||||
export type UploadProfilePictureMutationVariables = Exact<{
|
||||
file: Scalars['Upload'];
|
||||
}>;
|
||||
@@ -12389,6 +12402,39 @@ export function useDeleteUserAccountMutation(baseOptions?: Apollo.MutationHookOp
|
||||
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
|
||||
export type DeleteUserAccountMutationResult = Apollo.MutationResult<DeleteUserAccountMutation>;
|
||||
export type DeleteUserAccountMutationOptions = Apollo.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
|
||||
export const DeleteUserWorkspaceDocument = gql`
|
||||
mutation DeleteUserWorkspace($workspaceMemberIdToDelete: String!) {
|
||||
deleteUserFromWorkspace(workspaceMemberIdToDelete: $workspaceMemberIdToDelete) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type DeleteUserWorkspaceMutationFn = Apollo.MutationFunction<DeleteUserWorkspaceMutation, DeleteUserWorkspaceMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useDeleteUserWorkspaceMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useDeleteUserWorkspaceMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useDeleteUserWorkspaceMutation` 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 [deleteUserWorkspaceMutation, { data, loading, error }] = useDeleteUserWorkspaceMutation({
|
||||
* variables: {
|
||||
* workspaceMemberIdToDelete: // value for 'workspaceMemberIdToDelete'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useDeleteUserWorkspaceMutation(baseOptions?: Apollo.MutationHookOptions<DeleteUserWorkspaceMutation, DeleteUserWorkspaceMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<DeleteUserWorkspaceMutation, DeleteUserWorkspaceMutationVariables>(DeleteUserWorkspaceDocument, options);
|
||||
}
|
||||
export type DeleteUserWorkspaceMutationHookResult = ReturnType<typeof useDeleteUserWorkspaceMutation>;
|
||||
export type DeleteUserWorkspaceMutationResult = Apollo.MutationResult<DeleteUserWorkspaceMutation>;
|
||||
export type DeleteUserWorkspaceMutationOptions = Apollo.BaseMutationOptions<DeleteUserWorkspaceMutation, DeleteUserWorkspaceMutationVariables>;
|
||||
export const UploadProfilePictureDocument = gql`
|
||||
mutation UploadProfilePicture($file: Upload!) {
|
||||
uploadProfilePicture(file: $file) {
|
||||
|
||||
@@ -1768,6 +1768,7 @@ export type Mutation = {
|
||||
deleteSSOIdentityProvider: DeleteSsoOutput;
|
||||
deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput;
|
||||
deleteUser: User;
|
||||
deleteUserFromWorkspace: UserWorkspace;
|
||||
deleteWebhook: Scalars['Boolean'];
|
||||
deleteWorkflowVersionEdge: WorkflowVersionStepChanges;
|
||||
deleteWorkflowVersionStep: WorkflowVersionStepChanges;
|
||||
@@ -2226,6 +2227,11 @@ export type MutationDeleteTwoFactorAuthenticationMethodArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteUserFromWorkspaceArgs = {
|
||||
workspaceMemberIdToDelete: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteWebhookArgs = {
|
||||
input: DeleteWebhookInput;
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
|
||||
|
||||
const hasReadPermission = objectPermissions.canReadObjectRecords;
|
||||
|
||||
const { data, loading, error, fetchMore } =
|
||||
const { data, loading, error, fetchMore, refetch } =
|
||||
useQuery<RecordGqlOperationFindManyResult>(findManyRecordsQuery, {
|
||||
skip: skip || !objectMetadataItem || !hasReadPermission,
|
||||
variables: {
|
||||
@@ -126,5 +126,6 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
|
||||
queryIdentifier,
|
||||
hasNextPage,
|
||||
pageInfo,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,43 +1,111 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { useDeleteUserAccountMutation } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
useDeleteUserAccountMutation,
|
||||
useDeleteUserWorkspaceMutation,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const DELETE_ACCOUNT_MODAL_ID = 'delete-account-modal';
|
||||
const LEAVE_WORKSPACE_MODAL_ID = 'leave-workspace-modal';
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export const DeleteAccount = () => {
|
||||
const { t } = useLingui();
|
||||
const { openModal } = useModal();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const [deleteUserAccount] = useDeleteUserAccountMutation();
|
||||
const [deleteUserFromWorkspace] = useDeleteUserWorkspaceMutation();
|
||||
const currentUser = useRecoilValue(currentUserState);
|
||||
const userEmail = currentUser?.email;
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const currentWorkspaceMemberId = currentWorkspaceMember?.id;
|
||||
const { signOut } = useAuth();
|
||||
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
|
||||
const availableWorkspacesCount =
|
||||
countAvailableWorkspaces(availableWorkspaces);
|
||||
|
||||
const userHasMultipleWorkspaces = availableWorkspacesCount > 1;
|
||||
|
||||
const deleteAccount = async () => {
|
||||
await deleteUserAccount();
|
||||
await signOut();
|
||||
};
|
||||
|
||||
const leaveWorkspace = async () => {
|
||||
if (!isDefined(currentWorkspaceMemberId)) {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Current workspace member not found.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteUserFromWorkspace?.({
|
||||
variables: {
|
||||
workspaceMemberIdToDelete: currentWorkspaceMemberId,
|
||||
},
|
||||
});
|
||||
await signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<H2Title
|
||||
title={t`Danger zone`}
|
||||
description={t`Delete account and all the associated data`}
|
||||
description={
|
||||
userHasMultipleWorkspaces
|
||||
? t`Delete account and all the associated data or leave workspace`
|
||||
: t`Delete account and all the associated data`
|
||||
}
|
||||
/>
|
||||
{userHasMultipleWorkspaces && (
|
||||
<StyledDiv>
|
||||
<Button
|
||||
accent="danger"
|
||||
onClick={() => openModal(LEAVE_WORKSPACE_MODAL_ID)}
|
||||
variant="secondary"
|
||||
title={t`Leave workspace`}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
confirmationValue={userEmail}
|
||||
confirmationPlaceholder={userEmail ?? ''}
|
||||
modalId={LEAVE_WORKSPACE_MODAL_ID}
|
||||
title={t`Leave workspace`}
|
||||
subtitle={
|
||||
<>
|
||||
{t`This action cannot be undone. This will permanently remove your membership from this workspace.`}
|
||||
<br />
|
||||
{t`Please type in your email to confirm.`}
|
||||
</>
|
||||
}
|
||||
onConfirmClick={leaveWorkspace}
|
||||
confirmButtonText={t`Leave workspace`}
|
||||
/>
|
||||
</StyledDiv>
|
||||
)}
|
||||
<Button
|
||||
accent="danger"
|
||||
onClick={() => openModal(DELETE_ACCOUNT_MODAL_ID)}
|
||||
variant="secondary"
|
||||
title={t`Delete account`}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
confirmationValue={userEmail}
|
||||
confirmationPlaceholder={userEmail ?? ''}
|
||||
@@ -45,8 +113,10 @@ export const DeleteAccount = () => {
|
||||
title={t`Account Deletion`}
|
||||
subtitle={
|
||||
<>
|
||||
This action cannot be undone. This will permanently delete your
|
||||
entire account. <br /> Please type in your email to confirm.
|
||||
{t`This action cannot be undone. This will permanently delete your
|
||||
entire account.`}
|
||||
<br />
|
||||
{t`Please type in your email to confirm.`}
|
||||
</>
|
||||
}
|
||||
onConfirmClick={deleteAccount}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_USER_FROM_WORKSPACE = gql`
|
||||
mutation DeleteUserWorkspace($workspaceMemberIdToDelete: String!) {
|
||||
deleteUserFromWorkspace(
|
||||
workspaceMemberIdToDelete: $workspaceMemberIdToDelete
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -10,7 +10,6 @@ import { useDebounce } from 'use-debounce';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import {
|
||||
useDeleteUserWorkspaceMutation,
|
||||
useGetWorkspaceInvitationsQuery,
|
||||
useImpersonateMutation,
|
||||
} from '~/generated-metadata/graphql';
|
||||
@@ -142,23 +142,27 @@ export const SettingsWorkspaceMembers = () => {
|
||||
fetchMoreRecords,
|
||||
hasNextPage,
|
||||
loading,
|
||||
refetch: refetchWorkspaceMembers,
|
||||
} = useFindManyRecords<WorkspaceMember>({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
|
||||
filter: searchServerFilter,
|
||||
});
|
||||
const { deleteOneRecord: deleteOneWorkspaceMember } = useDeleteOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
|
||||
});
|
||||
|
||||
const { resendInvitation } = useResendWorkspaceInvitation();
|
||||
const { deleteWorkspaceInvitation } = useDeleteWorkspaceInvitation();
|
||||
const [deleteUserFromWorkspace] = useDeleteUserWorkspaceMutation();
|
||||
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
|
||||
const handleRemoveWorkspaceMember = async (workspaceMemberId: string) => {
|
||||
await deleteOneWorkspaceMember?.(workspaceMemberId);
|
||||
await deleteUserFromWorkspace?.({
|
||||
variables: {
|
||||
workspaceMemberIdToDelete: workspaceMemberId,
|
||||
},
|
||||
});
|
||||
setWorkspaceMemberToDelete(undefined);
|
||||
refetchWorkspaceMembers();
|
||||
};
|
||||
|
||||
const handleImpersonate = async (targetWorkspaceMember: WorkspaceMember) => {
|
||||
@@ -485,18 +489,19 @@ export const SettingsWorkspaceMembers = () => {
|
||||
</SettingsPageContainer>
|
||||
<ConfirmationModal
|
||||
modalId={WORKSPACE_MEMBER_DELETION_MODAL_ID}
|
||||
title={t`Account Deletion`}
|
||||
title={t`Remove member from workspace`}
|
||||
subtitle={
|
||||
<Trans>
|
||||
This action cannot be undone. This will permanently delete this user
|
||||
and remove them from all their assignments.
|
||||
This action cannot be undone. This will permanently remove this
|
||||
member from this workspace and remove them from all their
|
||||
assignments.
|
||||
</Trans>
|
||||
}
|
||||
onConfirmClick={() =>
|
||||
workspaceMemberToDelete &&
|
||||
handleRemoveWorkspaceMember(workspaceMemberToDelete)
|
||||
}
|
||||
confirmButtonText={t`Delete account`}
|
||||
confirmButtonText={t`Remove member`}
|
||||
/>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
|
||||
@@ -17,10 +17,10 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
|
||||
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
|
||||
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-seeder.module';
|
||||
import { WorkspaceCleanerModule } from 'src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module';
|
||||
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
|
||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||
import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module';
|
||||
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { In, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
|
||||
RunOnWorkspaceArgs,
|
||||
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:1-11:clean-orphaned-role-targets',
|
||||
description:
|
||||
'Clean up roleTargets records that reference non-existent userWorkspaces',
|
||||
})
|
||||
export class CleanOrphanedRoleTargetsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
private hasRunOnce = false;
|
||||
constructor(
|
||||
@InjectRepository(RoleTargetsEntity)
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {
|
||||
super(workspaceRepository, twentyORMGlobalManager);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
if (this.hasRunOnce) {
|
||||
this.logger.log('This command has already been run');
|
||||
|
||||
return;
|
||||
}
|
||||
const isDryRun = options.dryRun || false;
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log('Dry run mode: No changes will be applied');
|
||||
}
|
||||
|
||||
const orphanedRoleTargets = await this.roleTargetsRepository
|
||||
.createQueryBuilder('roleTarget')
|
||||
.leftJoin(
|
||||
UserWorkspaceEntity,
|
||||
'userWorkspace',
|
||||
'userWorkspace.id = roleTarget.userWorkspaceId',
|
||||
)
|
||||
.where('roleTarget.userWorkspaceId IS NOT NULL')
|
||||
.andWhere('userWorkspace.id IS NULL')
|
||||
.select([
|
||||
'roleTarget.id',
|
||||
'roleTarget.userWorkspaceId',
|
||||
'roleTarget.workspaceId',
|
||||
])
|
||||
.getMany();
|
||||
|
||||
if (orphanedRoleTargets.length === 0) {
|
||||
this.logger.log('No orphaned roleTargets found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log(
|
||||
`DRY RUN: Would delete ${orphanedRoleTargets.length} orphaned roleTarget(s):`,
|
||||
);
|
||||
orphanedRoleTargets.forEach((roleTarget) => {
|
||||
this.logger.log(
|
||||
` - roleTargetId: ${roleTarget.id}, userWorkspaceId: ${roleTarget.userWorkspaceId}, workspaceId: ${roleTarget.workspaceId}`,
|
||||
);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const orphanedIds = orphanedRoleTargets.map((roleTarget) => roleTarget.id);
|
||||
|
||||
await this.roleTargetsRepository.delete({ id: In(orphanedIds) });
|
||||
|
||||
this.logger.log(
|
||||
`Deleted ${orphanedRoleTargets.length} orphaned roleTarget(s)`,
|
||||
);
|
||||
|
||||
this.hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { In, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
|
||||
type RunOnWorkspaceArgs,
|
||||
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:1-11:clean-orphaned-user-workspaces',
|
||||
description:
|
||||
'Clean up userWorkspace records that do not have a corresponding workspaceMember in the workspace schema',
|
||||
})
|
||||
export class CleanOrphanedUserWorkspacesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {
|
||||
super(workspaceRepository, twentyORMGlobalManager);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun || false;
|
||||
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (userWorkspaces.length === 0) {
|
||||
this.logger.log(
|
||||
`No userWorkspaces found for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceMemberRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workspaceMember',
|
||||
);
|
||||
|
||||
const workspaceMembers = await workspaceMemberRepository.find({
|
||||
select: ['userId'],
|
||||
});
|
||||
|
||||
const workspaceMemberUserIds = new Set(
|
||||
workspaceMembers.map((member) => member.userId),
|
||||
);
|
||||
|
||||
const orphanedUserWorkspaces = userWorkspaces.filter(
|
||||
(userWorkspace) => !workspaceMemberUserIds.has(userWorkspace.userId),
|
||||
);
|
||||
|
||||
if (orphanedUserWorkspaces.length === 0) {
|
||||
this.logger.log(
|
||||
`No orphaned userWorkspaces found for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log(
|
||||
`DRY RUN: Would delete ${orphanedUserWorkspaces.length} orphaned userWorkspace(s) for workspace ${workspaceId}:`,
|
||||
);
|
||||
orphanedUserWorkspaces.forEach((userWorkspace) => {
|
||||
this.logger.log(
|
||||
` - userWorkspaceId: ${userWorkspace.id}, userId: ${userWorkspace.userId}`,
|
||||
);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete orphaned userWorkspaces
|
||||
const orphanedIds = orphanedUserWorkspaces.map((uw) => uw.id);
|
||||
|
||||
await this.userWorkspaceRepository.delete({ id: In(orphanedIds) });
|
||||
|
||||
this.logger.log(
|
||||
`Deleted ${orphanedUserWorkspaces.length} orphaned userWorkspace(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { CleanOrphanedRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-role-targets.command';
|
||||
import { CleanOrphanedUserWorkspacesCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-user-workspaces.command';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
WorkspaceEntity,
|
||||
ObjectMetadataEntity,
|
||||
FieldMetadataEntity,
|
||||
IndexMetadataEntity,
|
||||
ViewEntity,
|
||||
UserWorkspaceEntity,
|
||||
RoleTargetsEntity,
|
||||
]),
|
||||
WorkspaceSchemaManagerModule,
|
||||
],
|
||||
providers: [
|
||||
CleanOrphanedUserWorkspacesCommand,
|
||||
CleanOrphanedRoleTargetsCommand,
|
||||
],
|
||||
exports: [
|
||||
CleanOrphanedUserWorkspacesCommand,
|
||||
CleanOrphanedRoleTargetsCommand,
|
||||
],
|
||||
})
|
||||
export class V1_11_UpgradeVersionCommandModule {}
|
||||
+2
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { V1_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-10/1-10-upgrade-version-command.module';
|
||||
import { V1_11_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-11/1-11-upgrade-version-command.module';
|
||||
import { V1_6_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-6/1-6-upgrade-version-command.module';
|
||||
import { V1_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-7/1-7-upgrade-version-command.module';
|
||||
import { V1_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-8/1-8-upgrade-version-command.module';
|
||||
@@ -16,6 +17,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
|
||||
V1_7_UpgradeVersionCommandModule,
|
||||
V1_8_UpgradeVersionCommandModule,
|
||||
V1_10_UpgradeVersionCommandModule,
|
||||
V1_11_UpgradeVersionCommandModule,
|
||||
WorkspaceSyncMetadataModule,
|
||||
],
|
||||
providers: [UpgradeCommand],
|
||||
|
||||
+15
@@ -19,6 +19,8 @@ import { MigrateAttachmentTypeToFileCategoryCommand } from 'src/database/command
|
||||
import { MigrateChannelPartialFullSyncStagesCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-channel-partial-full-sync-stages.command';
|
||||
import { RegenerateSearchVectorsCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-regenerate-search-vectors.command';
|
||||
import { SeedDashboardViewCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-seed-dashboard-view.command';
|
||||
import { CleanOrphanedRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-role-targets.command';
|
||||
import { CleanOrphanedUserWorkspacesCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-user-workspaces.command';
|
||||
import { FixLabelIdentifierPositionAndVisibilityCommand } from 'src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command';
|
||||
import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command';
|
||||
import { DeduplicateUniqueFieldsCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-deduplicate-unique-fields.command';
|
||||
@@ -69,6 +71,10 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
protected readonly seedDashboardViewCommand: SeedDashboardViewCommand,
|
||||
protected readonly createViewKanbanFieldMetadataIdForeignKeyMigrationCommand: CreateViewKanbanFieldMetadataIdForeignKeyMigrationCommand,
|
||||
protected readonly flushWorkspaceCacheCommand: FlushCacheCommand,
|
||||
|
||||
// 1.11 Commands
|
||||
protected readonly cleanOrphanedUserWorkspacesCommand: CleanOrphanedUserWorkspacesCommand,
|
||||
protected readonly cleanOrphanedRoleTargetsCommand: CleanOrphanedRoleTargetsCommand,
|
||||
) {
|
||||
super(
|
||||
workspaceRepository,
|
||||
@@ -117,11 +123,20 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
],
|
||||
};
|
||||
|
||||
const commands_1110: VersionCommands = {
|
||||
beforeSyncMetadata: [],
|
||||
afterSyncMetadata: [
|
||||
this.cleanOrphanedUserWorkspacesCommand,
|
||||
this.cleanOrphanedRoleTargetsCommand,
|
||||
],
|
||||
};
|
||||
|
||||
this.allCommands = {
|
||||
'1.6.0': commands_160,
|
||||
'1.7.0': commands_170,
|
||||
'1.8.0': commands_180,
|
||||
'1.10.0': commands_1100,
|
||||
'1.11.0': commands_1110,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+7
-2
@@ -1,8 +1,12 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { getAllSelectableFields } from 'src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util';
|
||||
import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant';
|
||||
@@ -86,8 +90,9 @@ export class CommonSelectedFieldsHandler {
|
||||
objectMetadataMaps.byId[field.relationTargetObjectMetadataId];
|
||||
|
||||
if (!isDefined(relationTargetObjectMetadata)) {
|
||||
throw new BadRequestException(
|
||||
throw new CommonQueryRunnerException(
|
||||
`Object metadata relation target not found for relation creation payload`,
|
||||
CommonQueryRunnerExceptionCode.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
const relationFieldSelectFields = getAllSelectableFields({
|
||||
|
||||
+1
@@ -13,4 +13,5 @@ export enum CommonQueryRunnerExceptionCode {
|
||||
MISSING_SYSTEM_FIELD = 'MISSING_SYSTEM_FIELD',
|
||||
INVALID_CURSOR = 'INVALID_CURSOR',
|
||||
UPSERT_MAX_RECORDS_EXCEEDED = 'UPSERT_MAX_RECORDS_EXCEEDED',
|
||||
BAD_REQUEST = 'BAD_REQUEST',
|
||||
}
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
|
||||
case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT:
|
||||
case CommonQueryRunnerExceptionCode.INVALID_CURSOR:
|
||||
case CommonQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED:
|
||||
case CommonQueryRunnerExceptionCode.BAD_REQUEST:
|
||||
throw new UserInputError(error);
|
||||
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
|
||||
throw new AuthenticationError(error);
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
|
||||
case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT:
|
||||
case CommonQueryRunnerExceptionCode.INVALID_CURSOR:
|
||||
case CommonQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED:
|
||||
case CommonQueryRunnerExceptionCode.BAD_REQUEST:
|
||||
throw new BadRequestException(error.message);
|
||||
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
|
||||
throw new NotFoundException('Record not found');
|
||||
|
||||
@@ -129,8 +129,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
throw new AuthException(
|
||||
"You're not member of this workspace.",
|
||||
'User is not a member of the workspace.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
{
|
||||
userFriendlyMessage: msg`User is not a member of the workspace.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import { type Request } from 'express';
|
||||
import ms from 'ms';
|
||||
@@ -96,6 +97,9 @@ export class AccessTokenService {
|
||||
new AuthException(
|
||||
'User is not a member of the workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
{
|
||||
userFriendlyMessage: msg`User is not a member of the workspace.`,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
@@ -28,6 +29,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
|
||||
UserEntity,
|
||||
UserWorkspaceEntity,
|
||||
WorkspaceEntity,
|
||||
RoleTargetsEntity,
|
||||
]),
|
||||
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]),
|
||||
TypeORMModule,
|
||||
|
||||
+7
@@ -24,6 +24,7 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
|
||||
@@ -66,6 +67,12 @@ describe('UserWorkspaceService', () => {
|
||||
findOneOrFail: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RoleTargetsEntity),
|
||||
useValue: {
|
||||
findOneOrFail: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DataSourceService,
|
||||
useValue: {
|
||||
|
||||
+19
@@ -30,6 +30,7 @@ import {
|
||||
PermissionsExceptionCode,
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
@@ -42,6 +43,8 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly userRepository: Repository<UserEntity>,
|
||||
@InjectRepository(RoleTargetsEntity)
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
@@ -222,6 +225,22 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
return await this.userWorkspaceRepository.count({ where: { userId } });
|
||||
}
|
||||
|
||||
async deleteUserWorkspace({
|
||||
userWorkspaceId,
|
||||
softDelete = false,
|
||||
}: {
|
||||
userWorkspaceId: string;
|
||||
softDelete?: boolean;
|
||||
}): Promise<void> {
|
||||
if (softDelete) {
|
||||
await this.roleTargetsRepository.softRemove({ userWorkspaceId });
|
||||
await this.userWorkspaceRepository.softDelete({ id: userWorkspaceId });
|
||||
} else {
|
||||
await this.roleTargetsRepository.delete({ userWorkspaceId }); // TODO remove once userWorkspace foreign key is added on roleTarget
|
||||
await this.userWorkspaceRepository.delete({ id: userWorkspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
async findAvailableWorkspacesByEmail(email: string) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { type Repository } from 'typeorm';
|
||||
import { type Repository, type UpdateResult } from 'typeorm';
|
||||
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
@@ -40,6 +42,7 @@ describe('UserService', () => {
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -58,6 +61,12 @@ describe('UserService', () => {
|
||||
validateUserWorkspaceIsNotUniqueAdminOrThrow: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {
|
||||
deleteUserWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -311,9 +320,14 @@ describe('UserService', () => {
|
||||
});
|
||||
|
||||
it('deletes workspace member and workspace when user is sole member', async () => {
|
||||
const mockedUserWorkspace = {
|
||||
id: 'uw2',
|
||||
workspaceId: 'w2',
|
||||
} as UserWorkspaceEntity;
|
||||
|
||||
(userRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
id: 'u2',
|
||||
userWorkspaces: [{ id: 'uw2', workspaceId: 'w2' }],
|
||||
userWorkspaces: [mockedUserWorkspace],
|
||||
});
|
||||
|
||||
jest
|
||||
@@ -323,11 +337,14 @@ describe('UserService', () => {
|
||||
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
|
||||
.mockResolvedValue(mockWorkspaceMemberRepo);
|
||||
|
||||
(userRepository.softDelete as jest.Mock).mockResolvedValue({
|
||||
affected: 1,
|
||||
raw: [],
|
||||
generatedMaps: [],
|
||||
} as UpdateResult);
|
||||
|
||||
const res = await service.deleteUser('u2');
|
||||
|
||||
expect(mockWorkspaceMemberRepo.delete).toHaveBeenCalledWith({
|
||||
userId: 'u2',
|
||||
});
|
||||
expect(workspaceService.deleteWorkspace).toHaveBeenCalledWith('w2');
|
||||
expect(res).toMatchObject({ id: 'u2' });
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import assert from 'assert';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
@@ -33,6 +35,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
) {
|
||||
super(userRepository);
|
||||
}
|
||||
@@ -89,7 +92,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(userId: string): Promise<UserEntity> {
|
||||
async deleteUser(userId: string) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -99,78 +102,116 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(user);
|
||||
|
||||
const prepareForUserDeletionInWorkspaces = await Promise.all(
|
||||
user.userWorkspaces.map(async (userWorkspace) => {
|
||||
const { workspaceId } = userWorkspace;
|
||||
for (const userWorkspace of user.userWorkspaces) {
|
||||
await this.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(
|
||||
userWorkspace,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceMemberRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
|
||||
await this.userRepository.softDelete({ id: userId });
|
||||
|
||||
return await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
withDeleted: true,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUserWorkspaceAndPotentiallyDeleteUser({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
relations: { userWorkspaces: true },
|
||||
});
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(user);
|
||||
|
||||
const userWorkspace = user.userWorkspaces.find(
|
||||
(userWorkspace) => userWorkspace.workspaceId === workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(userWorkspace)) {
|
||||
throw new Error('User workspace not found.');
|
||||
}
|
||||
|
||||
await this.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(
|
||||
userWorkspace,
|
||||
);
|
||||
|
||||
if (user.userWorkspaces.length === 1) {
|
||||
await this.userRepository.softDelete(userId);
|
||||
}
|
||||
|
||||
return userWorkspace;
|
||||
}
|
||||
|
||||
async removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(
|
||||
userWorkspace: UserWorkspaceEntity,
|
||||
) {
|
||||
const workspaceId = userWorkspace.workspaceId;
|
||||
|
||||
const workspaceMemberRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const workspaceMembers = await workspaceMemberRepository.find();
|
||||
|
||||
const userWorkspaceId = userWorkspace.id;
|
||||
|
||||
if (workspaceMembers.length === 1) {
|
||||
await this.workspaceService.deleteWorkspace(workspaceId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspaceMembers.length > 1) {
|
||||
try {
|
||||
await this.userRoleService.validateUserWorkspaceIsNotUniqueAdminOrThrow(
|
||||
{
|
||||
workspaceId,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const workspaceMembers = await workspaceMemberRepository.find();
|
||||
|
||||
if (workspaceMembers.length > 1) {
|
||||
try {
|
||||
await this.userRoleService.validateUserWorkspaceIsNotUniqueAdminOrThrow(
|
||||
{
|
||||
workspaceId,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PermissionsException &&
|
||||
error.code === PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN
|
||||
) {
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.CANNOT_DELETE_LAST_ADMIN_USER,
|
||||
PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER,
|
||||
{
|
||||
userFriendlyMessage: msg`Cannot delete account: you are the only admin. Assign another admin or delete the workspace(s) first.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceMember = workspaceMembers.find(
|
||||
(member: WorkspaceMemberWorkspaceEntity) => member.userId === userId,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PermissionsException &&
|
||||
error.code === PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN
|
||||
) {
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.CANNOT_DELETE_LAST_ADMIN_USER,
|
||||
PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER,
|
||||
{
|
||||
userFriendlyMessage: msg`Cannot delete account: you are the only admin. Assign another admin or delete the workspace(s) first.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
assert(workspaceMember, 'WorkspaceMember not found');
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
workspaceMemberRepository,
|
||||
workspaceMembers,
|
||||
workspaceMember,
|
||||
};
|
||||
}),
|
||||
const workspaceMember = workspaceMembers.find(
|
||||
(member: WorkspaceMemberWorkspaceEntity) =>
|
||||
member.userId === userWorkspace.userId,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
prepareForUserDeletionInWorkspaces.map(
|
||||
async ({
|
||||
workspaceId,
|
||||
workspaceMemberRepository,
|
||||
workspaceMembers,
|
||||
}) => {
|
||||
await workspaceMemberRepository.delete({ userId });
|
||||
assert(workspaceMember, 'WorkspaceMember not found');
|
||||
|
||||
if (workspaceMembers.length === 1) {
|
||||
await this.workspaceService.deleteWorkspace(workspaceId);
|
||||
await workspaceMemberRepository.delete({ userId: userWorkspace.userId });
|
||||
|
||||
return;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return user;
|
||||
await this.userWorkspaceService.deleteUserWorkspace({
|
||||
userWorkspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async hasUserAccessToWorkspaceOrThrow(userId: string, workspaceId: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { BadRequestException, UseFilters, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Mutation,
|
||||
@@ -11,6 +11,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { GraphQLJSONObject } from 'graphql-type-json';
|
||||
import { FileUpload, GraphQLUpload } from 'graphql-upload';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -48,17 +49,27 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { type UserWorkspacePermissions } from 'src/engine/metadata-modules/permissions/types/user-workspace-permissions';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
import { fromUserWorkspacePermissionsToUserWorkspacePermissionsDto } from 'src/engine/metadata-modules/role/utils/fromUserWorkspacePermissionsToUserWorkspacePermissionsDto';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
const getHMACKey = (email?: string, key?: string | null) => {
|
||||
@@ -87,6 +98,7 @@ export class UserResolver {
|
||||
|
||||
private readonly workspaceMemberTranspiler: WorkspaceMemberTranspiler,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {}
|
||||
|
||||
private async getUserWorkspacePermissions({
|
||||
@@ -391,6 +403,70 @@ export class UserResolver {
|
||||
return this.userService.deleteUser(userId);
|
||||
}
|
||||
|
||||
@Mutation(() => UserWorkspaceEntity)
|
||||
@UseGuards(UserAuthGuard)
|
||||
async deleteUserFromWorkspace(
|
||||
@Args('workspaceMemberIdToDelete') workspaceMemberIdToDelete: string,
|
||||
@AuthUser() { id: userId }: UserEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@AuthWorkspace()
|
||||
workspace: WorkspaceEntity,
|
||||
@AuthApiKey() apiKey?: string,
|
||||
) {
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceMemberRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
|
||||
workspace.id,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const workspaceMemberToDelete = await workspaceMemberRepository.findOne({
|
||||
where: {
|
||||
id: workspaceMemberIdToDelete,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(workspaceMemberToDelete)) {
|
||||
throw new BadRequestException(
|
||||
'Workspace member to delete not found in workspace',
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceMemberToDeleteIsAuthenticatedUser =
|
||||
workspaceMemberToDelete.userId === userId;
|
||||
|
||||
const canDeleteUserFromWorkspace =
|
||||
workspaceMemberToDeleteIsAuthenticatedUser ||
|
||||
(await this.permissionsService.userHasWorkspaceSettingPermission({
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
setting: PermissionFlagType.WORKSPACE_MEMBERS,
|
||||
apiKeyId: apiKey ?? undefined,
|
||||
}));
|
||||
|
||||
if (!canDeleteUserFromWorkspace) {
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
PermissionsExceptionCode.PERMISSION_DENIED,
|
||||
{
|
||||
userFriendlyMessage: msg`You do not have permission to delete this user from the workspace. Please contact your workspace administrator for access.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return this.userService.deleteUserWorkspaceAndPotentiallyDeleteUser({
|
||||
userId: workspaceMemberToDelete.userId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => OnboardingStatus, {
|
||||
nullable: true,
|
||||
})
|
||||
|
||||
+50
-18
@@ -39,6 +39,7 @@ describe('WorkspaceService', () => {
|
||||
let messageQueueService: MessageQueueService;
|
||||
let dnsManagerService: DnsManagerService;
|
||||
let billingSubscriptionService: BillingSubscriptionService;
|
||||
let userWorkspaceService: UserWorkspaceService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -121,6 +122,12 @@ describe('WorkspaceService', () => {
|
||||
flushFlatEntityMaps: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {
|
||||
deleteUserWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getQueueToken(MessageQueue.deleteCascadeQueue),
|
||||
useValue: {
|
||||
@@ -151,6 +158,8 @@ describe('WorkspaceService', () => {
|
||||
billingSubscriptionService = module.get<BillingSubscriptionService>(
|
||||
BillingSubscriptionService,
|
||||
);
|
||||
userWorkspaceService =
|
||||
module.get<UserWorkspaceService>(UserWorkspaceService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -163,7 +172,13 @@ describe('WorkspaceService', () => {
|
||||
|
||||
describe('handleRemoveWorkspaceMember', () => {
|
||||
it('should soft delete the user workspace record', async () => {
|
||||
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([]);
|
||||
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([
|
||||
{
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
id: 'user-workspace-id',
|
||||
} as UserWorkspaceEntity,
|
||||
]);
|
||||
|
||||
await service.handleRemoveWorkspaceMember(
|
||||
'workspace-id',
|
||||
@@ -171,15 +186,21 @@ describe('WorkspaceService', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
expect(userWorkspaceRepository.softDelete).toHaveBeenCalledWith({
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
expect(userWorkspaceService.deleteUserWorkspace).toHaveBeenCalledWith({
|
||||
userWorkspaceId: 'user-workspace-id',
|
||||
softDelete: true,
|
||||
});
|
||||
expect(userWorkspaceRepository.delete).not.toHaveBeenCalled();
|
||||
expect(userRepository.softDelete).toHaveBeenCalledWith('user-id');
|
||||
});
|
||||
it('should destroy the user workspace record', async () => {
|
||||
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([]);
|
||||
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([
|
||||
{
|
||||
id: 'user-workspace-id',
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
} as UserWorkspaceEntity,
|
||||
]);
|
||||
|
||||
await service.handleRemoveWorkspaceMember(
|
||||
'workspace-id',
|
||||
@@ -187,20 +208,26 @@ describe('WorkspaceService', () => {
|
||||
false,
|
||||
);
|
||||
|
||||
expect(userWorkspaceRepository.delete).toHaveBeenCalledWith({
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
expect(userWorkspaceService.deleteUserWorkspace).toHaveBeenCalledWith({
|
||||
userWorkspaceId: 'user-workspace-id',
|
||||
softDelete: false,
|
||||
});
|
||||
expect(userWorkspaceRepository.softDelete).not.toHaveBeenCalled();
|
||||
expect(userRepository.softDelete).toHaveBeenCalledWith('user-id');
|
||||
});
|
||||
|
||||
it('should not soft delete the user record if there are other user workspace records', async () => {
|
||||
jest
|
||||
.spyOn(userWorkspaceRepository, 'find')
|
||||
.mockResolvedValue([
|
||||
{ id: 'remaining-user-workspace-id' } as UserWorkspaceEntity,
|
||||
]);
|
||||
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([
|
||||
{
|
||||
id: 'remaining-user-workspace-id',
|
||||
userId: 'user-id',
|
||||
workspaceId: 'other-workspace-id',
|
||||
} as UserWorkspaceEntity,
|
||||
{
|
||||
id: 'user-workspace-id',
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
} as UserWorkspaceEntity,
|
||||
]);
|
||||
|
||||
await service.handleRemoveWorkspaceMember(
|
||||
'workspace-id',
|
||||
@@ -208,11 +235,16 @@ describe('WorkspaceService', () => {
|
||||
false,
|
||||
);
|
||||
|
||||
expect(userWorkspaceRepository.delete).toHaveBeenCalledWith({
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
expect(userWorkspaceService.deleteUserWorkspace).toHaveBeenCalledWith({
|
||||
userWorkspaceId: 'user-workspace-id',
|
||||
softDelete: false,
|
||||
});
|
||||
expect(userWorkspaceRepository.softDelete).not.toHaveBeenCalled();
|
||||
expect(userWorkspaceService.deleteUserWorkspace).not.toHaveBeenCalledWith(
|
||||
{
|
||||
userWorkspaceId: 'remaining-user-workspace-id',
|
||||
softDelete: false,
|
||||
},
|
||||
);
|
||||
expect(userRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+22
-20
@@ -247,7 +247,9 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
}
|
||||
|
||||
async deleteMetadataSchemaCacheAndUserWorkspace(workspace: WorkspaceEntity) {
|
||||
await this.userWorkspaceRepository.delete({ workspaceId: workspace.id });
|
||||
await this.userWorkspaceService.deleteUserWorkspace({
|
||||
userWorkspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (this.billingService.isBillingEnabled()) {
|
||||
await this.billingSubscriptionService.deleteSubscriptions(workspace.id);
|
||||
@@ -259,12 +261,6 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
}
|
||||
|
||||
async deleteWorkspace(id: string, softDelete = false) {
|
||||
//TODO: delete all logs when #611 closed
|
||||
|
||||
this.logger.log(
|
||||
`${softDelete ? 'Soft' : 'Hard'} deleting workspace ${id} ...`,
|
||||
);
|
||||
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: { id },
|
||||
withDeleted: true,
|
||||
@@ -335,25 +331,31 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
userId: string,
|
||||
softDelete = false,
|
||||
) {
|
||||
if (softDelete) {
|
||||
await this.userWorkspaceRepository.softDelete({
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
} else {
|
||||
await this.userWorkspaceRepository.delete({
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (userWorkspaces.length === 0) {
|
||||
const userWorkspaceOfRemovedWorkspaceMember = userWorkspaces?.find(
|
||||
(userWorkspace: UserWorkspaceEntity) =>
|
||||
userWorkspace.workspaceId === workspaceId,
|
||||
);
|
||||
|
||||
if (isDefined(userWorkspaceOfRemovedWorkspaceMember)) {
|
||||
await this.userWorkspaceService.deleteUserWorkspace({
|
||||
userWorkspaceId: userWorkspaceOfRemovedWorkspaceMember.id,
|
||||
softDelete,
|
||||
});
|
||||
}
|
||||
|
||||
const hasOtherUserWorkspaces = isDefined(
|
||||
userWorkspaceOfRemovedWorkspaceMember,
|
||||
)
|
||||
? userWorkspaces.length > 1
|
||||
: userWorkspaces.length > 0;
|
||||
|
||||
if (!hasOtherUserWorkspaces) {
|
||||
await this.userRepository.softDelete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -46,7 +46,6 @@ export const permissionGraphqlApiExceptionHandler = (
|
||||
case PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS:
|
||||
throw new UserInputError(error);
|
||||
case PermissionsExceptionCode.ROLE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND:
|
||||
case PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND:
|
||||
case PermissionsExceptionCode.PERMISSION_NOT_FOUND:
|
||||
@@ -69,6 +68,7 @@ export const permissionGraphqlApiExceptionHandler = (
|
||||
case PermissionsExceptionCode.API_KEY_ROLE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.JOIN_COLUMN_NAME_REQUIRED:
|
||||
case PermissionsExceptionCode.COMPOSITE_TYPE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
|
||||
throw error;
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
|
||||
@@ -155,13 +155,13 @@ export class RoleService {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const defaultRole = await this.workspaceRepository.findOne({
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
const defaultRoleId = defaultRole?.defaultRoleId;
|
||||
const defaultRoleId = workspace?.defaultRoleId;
|
||||
|
||||
if (!isDefined(defaultRoleId)) {
|
||||
throw new PermissionsException(
|
||||
|
||||
+4
@@ -8,6 +8,10 @@ export const formatColumnNameForRelationField = (
|
||||
fieldName: string,
|
||||
fieldMetadataSettings: FieldMetadataRelationSettings,
|
||||
): string => {
|
||||
if (fieldMetadataSettings.relationType === RelationType.ONE_TO_MANY) {
|
||||
throw new Error('No column exists for one to many relation fields');
|
||||
}
|
||||
|
||||
if (fieldMetadataSettings.relationType === RelationType.MANY_TO_ONE) {
|
||||
if (!isDefined(fieldMetadataSettings.joinColumnName)) {
|
||||
throw new Error(`Join column name is not defined for field ${fieldName}`);
|
||||
|
||||
-1
@@ -299,7 +299,6 @@ export class CleanerWorkspaceService {
|
||||
await this.workspaceService.handleRemoveWorkspaceMember(
|
||||
workspace.id,
|
||||
userWorkspace.userId,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+8
-16
@@ -1,16 +1,12 @@
|
||||
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
|
||||
import { type DeleteManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
|
||||
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
|
||||
@WorkspaceQueryHook(`workspaceMember.deleteMany`)
|
||||
export class WorkspaceMemberDeleteManyPreQueryHook
|
||||
@@ -18,14 +14,10 @@ export class WorkspaceMemberDeleteManyPreQueryHook
|
||||
{
|
||||
constructor() {}
|
||||
|
||||
async execute(authContext: AuthContext): Promise<DeleteManyResolverArgs> {
|
||||
const workspace = authContext.workspace;
|
||||
|
||||
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
|
||||
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
PermissionsExceptionCode.PERMISSION_DENIED,
|
||||
async execute(_authContext: AuthContext): Promise<DeleteManyResolverArgs> {
|
||||
throw new CommonQueryRunnerException(
|
||||
'Please use /deleteUserFromWorkspace to remove a workspace member.',
|
||||
CommonQueryRunnerExceptionCode.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -9,6 +9,7 @@ import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runne
|
||||
import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
|
||||
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import {
|
||||
PermissionsException,
|
||||
@@ -30,6 +31,7 @@ export class WorkspaceMemberDeleteOnePostQueryHook
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
private readonly workspaceMemberPreQueryHookService: WorkspaceMemberPreQueryHookService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
@@ -91,6 +93,8 @@ export class WorkspaceMemberDeleteOnePostQueryHook
|
||||
);
|
||||
}
|
||||
|
||||
await this.userWorkspaceRepository.delete(userWorkspace.id);
|
||||
await this.userWorkspaceService.deleteUserWorkspace({
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
|
||||
import { type RestoreOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
|
||||
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
@WorkspaceQueryHook(`workspaceMember.deleteOne`)
|
||||
export class WorkspaceMemberDeleteOnePreQueryHook
|
||||
implements WorkspacePreQueryHookInstance
|
||||
{
|
||||
constructor() {}
|
||||
|
||||
async execute(_authContext: AuthContext): Promise<RestoreOneResolverArgs> {
|
||||
throw new CommonQueryRunnerException(
|
||||
'Please use /deleteUserFromWorkspace to remove a workspace member.',
|
||||
CommonQueryRunnerExceptionCode.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceMemberCreateManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-create-many.pre-query.hook';
|
||||
import { WorkspaceMemberCreateOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-create-one.pre-query.hook';
|
||||
import { WorkspaceMemberDeleteManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-delete-many.pre-query.hook';
|
||||
import { WorkspaceMemberDeleteOnePostQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-delete-one.post-query.hook';
|
||||
import { WorkspaceMemberDeleteOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook';
|
||||
import { WorkspaceMemberDestroyManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-destroy-many.pre-query.hook';
|
||||
import { WorkspaceMemberDestroyOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-destroy-one.pre-query.hook';
|
||||
import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service';
|
||||
@@ -21,6 +23,7 @@ import { WorkspaceMemberUpdateOnePreQueryHook } from 'src/modules/workspace-memb
|
||||
WorkspaceMemberPreQueryHookService,
|
||||
WorkspaceMemberCreateOnePreQueryHook,
|
||||
WorkspaceMemberCreateManyPreQueryHook,
|
||||
WorkspaceMemberDeleteOnePreQueryHook,
|
||||
WorkspaceMemberDeleteOnePostQueryHook,
|
||||
WorkspaceMemberDeleteManyPreQueryHook,
|
||||
WorkspaceMemberDestroyOnePreQueryHook,
|
||||
@@ -33,6 +36,7 @@ import { WorkspaceMemberUpdateOnePreQueryHook } from 'src/modules/workspace-memb
|
||||
imports: [
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
UserWorkspaceModule,
|
||||
TypeOrmModule.forFeature([UserWorkspaceEntity]),
|
||||
],
|
||||
})
|
||||
|
||||
+19
-21
@@ -1,5 +1,4 @@
|
||||
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
|
||||
import { makeGraphqlAPIRequestWithAcmeMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-acme-member-role.util';
|
||||
import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
|
||||
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
|
||||
|
||||
@@ -11,7 +10,7 @@ const WORKSPACE_MEMBER_GQL_FIELDS = `
|
||||
id
|
||||
name {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
describe('workspace members permissions', () => {
|
||||
@@ -64,7 +63,7 @@ describe('workspace members permissions', () => {
|
||||
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
|
||||
});
|
||||
|
||||
it('should throw when user does not have permission (member role)', async () => {
|
||||
it('should throw when calling deleteOne ', async () => {
|
||||
const graphqlOperation = deleteOneOperationFactory({
|
||||
objectMetadataSingularName: 'workspaceMember',
|
||||
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
|
||||
@@ -77,31 +76,30 @@ describe('workspace members permissions', () => {
|
||||
expect(response.body.data).toStrictEqual({ deleteWorkspaceMember: null });
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
'Please use /deleteUserFromWorkspace to remove a workspace member.',
|
||||
);
|
||||
expect(response.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.BAD_USER_INPUT,
|
||||
);
|
||||
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
|
||||
});
|
||||
|
||||
// This test is not idempotent
|
||||
it('should allow delete when user is deleting themself (member role)', async () => {
|
||||
const deleteOperation = deleteOneOperationFactory({
|
||||
it('should throw when calling deleteMany', async () => {
|
||||
const graphqlOperation = deleteOneOperationFactory({
|
||||
objectMetadataSingularName: 'workspaceMember',
|
||||
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
|
||||
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
|
||||
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
|
||||
});
|
||||
|
||||
const deleteResponse =
|
||||
await makeGraphqlAPIRequestWithAcmeMemberRole(deleteOperation);
|
||||
const response =
|
||||
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
|
||||
|
||||
expect(deleteResponse.body.errors).not.toBeDefined();
|
||||
expect(deleteResponse.body.data).toStrictEqual({
|
||||
deleteWorkspaceMember: {
|
||||
id: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
|
||||
name: {
|
||||
firstName: 'Jony',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(deleteResponse.body.errors).toBeUndefined();
|
||||
expect(response.body.data).toStrictEqual({ deleteWorkspaceMember: null });
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toBe(
|
||||
'Please use /deleteUserFromWorkspace to remove a workspace member.',
|
||||
);
|
||||
expect(response.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.BAD_USER_INPUT,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import request from 'supertest';
|
||||
import { signUpOperationFactory } from 'test/integration/graphql/utils/sign-up-operation-factory.util';
|
||||
|
||||
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
|
||||
|
||||
const client = request(`http://localhost:${APP_PORT}`);
|
||||
|
||||
@@ -29,4 +32,159 @@ describe('deleteUser', () => {
|
||||
expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
|
||||
});
|
||||
});
|
||||
|
||||
it('should deny deleting another user when caller lacks WORKSPACE_MEMBERS permission', async () => {
|
||||
const query = {
|
||||
query: `
|
||||
mutation DeleteUserFromWorkspace {
|
||||
deleteUserFromWorkspace(workspaceMemberIdToDelete: "${WORKSPACE_MEMBER_DATA_SEED_IDS.JANE}") {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
|
||||
.send(query)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
|
||||
});
|
||||
});
|
||||
|
||||
it('should soft delete user and remove workspace relations when deleting a user in their only workspace', async () => {
|
||||
// 1. Arrange
|
||||
// Enable public invite link to allow sign up without personal token
|
||||
const enablePublicInviteLinkMutation = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isPublicInviteLinkEnabled: true }) {
|
||||
id
|
||||
isPublicInviteLinkEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send(enablePublicInviteLinkMutation)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
expect(res.body.data.updateWorkspace.isPublicInviteLinkEnabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Sign up a new user into the current workspace via public invite link
|
||||
const testEmail = `test_user_${Date.now()}@example.com`;
|
||||
const signUpMutation = signUpOperationFactory({
|
||||
email: testEmail,
|
||||
password: 'Password123!',
|
||||
});
|
||||
|
||||
const signUpResponse = await client.post('/graphql').send(signUpMutation);
|
||||
|
||||
expect(signUpResponse.status).toBe(200);
|
||||
expect(signUpResponse.body.errors).toBeUndefined();
|
||||
|
||||
// Query workspace members and find the created user by email to get workspaceMemberId
|
||||
const newWorkspaceMemberQuery = {
|
||||
query: `
|
||||
query WorkspaceMember($workspaceMemberFilter: WorkspaceMemberFilterInput!) {
|
||||
workspaceMember(filter: $workspaceMemberFilter) {
|
||||
id
|
||||
userId
|
||||
userWorkspaceId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
workspaceMemberFilter: {
|
||||
userEmail: {
|
||||
eq: testEmail,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const newMemberResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send(newWorkspaceMemberQuery);
|
||||
|
||||
expect(newMemberResponse.status).toBe(200);
|
||||
|
||||
const createdMember = newMemberResponse.body.data.workspaceMember;
|
||||
|
||||
expect(createdMember).toBeDefined();
|
||||
const createdWorkspaceMemberId = createdMember.id;
|
||||
|
||||
// 2. Act
|
||||
const deleteUserFromWorkspaceMutation = {
|
||||
query: `
|
||||
mutation DeleteUserFromWorkspace {
|
||||
deleteUserFromWorkspace(workspaceMemberIdToDelete: "${createdWorkspaceMemberId}") {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const deleteResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send(deleteUserFromWorkspaceMutation);
|
||||
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
|
||||
expect(deleteResponse.body.errors).toBeUndefined();
|
||||
|
||||
// 3. Assert
|
||||
const membersAfterDeletionResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send(newWorkspaceMemberQuery);
|
||||
|
||||
const createdMemberAfterDeletion =
|
||||
membersAfterDeletionResponse.body.data.workspaceMember;
|
||||
|
||||
expect(createdMemberAfterDeletion).toBeNull();
|
||||
|
||||
const getRolesWithMembersQuery = {
|
||||
query: `
|
||||
query GetRoles {
|
||||
getRoles { id label workspaceMembers { id } }
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const rolesResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send(getRolesWithMembersQuery);
|
||||
|
||||
expect(rolesResponse.status).toBe(200);
|
||||
|
||||
expect(rolesResponse.body.errors).toBeUndefined();
|
||||
const roles = rolesResponse.body.data.getRoles as Array<{
|
||||
id: string;
|
||||
workspaceMembers: Array<{ id: string }>;
|
||||
}>;
|
||||
|
||||
for (const role of roles) {
|
||||
expect(
|
||||
role.workspaceMembers.find((wm) => wm.id === createdWorkspaceMemberId),
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util';
|
||||
|
||||
export const signUpOperationFactory = ({
|
||||
email,
|
||||
password,
|
||||
workspaceInviteHash,
|
||||
workspaceId,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
workspaceInviteHash?: string;
|
||||
workspaceId?: string;
|
||||
}) => ({
|
||||
query: `
|
||||
mutation SignUpInWorkspace {
|
||||
signUpInWorkspace(
|
||||
email: "${email}"
|
||||
password: "${password}"
|
||||
workspaceInviteHash: "${workspaceInviteHash ?? 'apple.dev-invite-hash'}"
|
||||
workspaceId: "${workspaceId ?? SEED_APPLE_WORKSPACE_ID}"
|
||||
) {
|
||||
workspace { id }
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
} from '@nestjs/testing';
|
||||
|
||||
import { AppModule } from 'src/app.module';
|
||||
import { CommandModule } from 'src/command/command.module';
|
||||
import { StripeSDKMockService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service';
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { CAPTCHA_DRIVER } from 'src/engine/core-modules/captcha/constants/captcha-driver.constants';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { ExceptionHandlerMockService } from 'src/engine/core-modules/exception-handler/mocks/exception-handler-mock.service';
|
||||
import { MockedUnhandledExceptionFilter } from 'src/engine/core-modules/exception-handler/mocks/mock-unhandled-exception.filter';
|
||||
import { CommandModule } from 'src/command/command.module';
|
||||
|
||||
interface TestingModuleCreatePreHook {
|
||||
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
|
||||
@@ -48,7 +49,11 @@ export const createApp = async (
|
||||
.overrideProvider(StripeSDKService)
|
||||
.useValue(stripeSDKMockService)
|
||||
.overrideProvider(ExceptionHandlerService)
|
||||
.useValue(mockExceptionHandlerService);
|
||||
.useValue(mockExceptionHandlerService)
|
||||
.overrideProvider(CAPTCHA_DRIVER)
|
||||
.useValue({
|
||||
validate: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
if (config.moduleBuilderHook) {
|
||||
moduleBuilder = config.moduleBuilderHook(moduleBuilder);
|
||||
|
||||
Reference in New Issue
Block a user