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:
Marie
2025-11-06 18:29:12 +00:00
committed by GitHub
parent bfe1f47065
commit 4ce93aee52
38 changed files with 980 additions and 173 deletions
@@ -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';
@@ -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;
}
}
@@ -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}`,
);
}
}
@@ -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,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],
@@ -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,
};
}
@@ -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({
@@ -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',
}
@@ -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);
@@ -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.`,
},
);
}
@@ -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,
@@ -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: {
@@ -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,
})
@@ -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();
});
});
@@ -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);
}
}
@@ -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(
@@ -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}`);
@@ -299,7 +299,6 @@ export class CleanerWorkspaceService {
await this.workspaceService.handleRemoveWorkspaceMember(
workspace.id,
userWorkspace.userId,
false,
);
}
@@ -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,
);
}
}
@@ -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,
});
}
}
@@ -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,
);
}
}
@@ -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]),
],
})
@@ -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();
}
});
});
@@ -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);