From 4ce93aee5299f510d2355f4f2abefc70340b6647 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:29:12 +0100 Subject: [PATCH] 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 --- .../src/generated-metadata/graphql.ts | 46 +++++ .../twenty-front/src/generated/graphql.ts | 6 + .../object-record/hooks/useFindManyRecords.ts | 3 +- .../profile/components/DeleteAccount.tsx | 80 +++++++- .../graphql/mutations/deleteUserWorkspace.ts | 11 ++ .../settings/SettingsWorkspaceMembers.tsx | 23 ++- .../commands/database-command.module.ts | 2 +- ...-11-clean-orphaned-role-targets.command.ts | 91 +++++++++ ...-clean-orphaned-user-workspaces.command.ts | 99 ++++++++++ .../1-11-upgrade-version-command.module.ts | 37 ++++ .../upgrade-version-command.module.ts | 2 + .../upgrade.command.ts | 15 ++ .../common-selected-fields.handler.ts | 9 +- .../errors/common-query-runner.exception.ts | 1 + ...r-to-graphql-api-exception-handler.util.ts | 1 + ...nner-to-rest-api-exception-handler.util.ts | 1 + .../auth/services/auth.service.ts | 5 +- .../token/services/access-token.service.ts | 4 + .../user-workspace/user-workspace.module.ts | 2 + .../user-workspace.service.spec.ts | 7 + .../user-workspace/user-workspace.service.ts | 19 ++ .../user/services/user.service.spec.ts | 27 ++- .../user/services/user.service.ts | 173 +++++++++++------- .../engine/core-modules/user/user.resolver.ts | 78 +++++++- .../__tests__/workspace.service.spec.ts | 68 +++++-- .../workspace/services/workspace.service.ts | 42 +++-- ...sion-graphql-api-exception-handler.util.ts | 2 +- .../metadata-modules/role/role.service.ts | 4 +- ...mat-column-name-for-relation-field.util.ts | 4 + .../services/cleaner.workspace-service.ts | 1 - ...space-member-delete-many.pre-query.hook.ts | 24 +-- ...space-member-delete-one.post-query.hook.ts | 6 +- ...kspace-member-delete-one.pre-query.hook.ts | 23 +++ .../workspace-member-query-hook.module.ts | 4 + .../workspace-members.integration-spec.ts | 40 ++-- .../graphql/suites/user.integration-spec.ts | 158 ++++++++++++++++ .../utils/sign-up-operation-factory.util.ts | 26 +++ .../test/integration/utils/create-app.ts | 9 +- 38 files changed, 980 insertions(+), 173 deletions(-) create mode 100644 packages/twenty-front/src/modules/users/graphql/mutations/deleteUserWorkspace.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-role-targets.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-user-workspaces.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-upgrade-version-command.module.ts create mode 100644 packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/sign-up-operation-factory.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 34a1d1c515d..11ee366f987 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -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; export type DeleteUserAccountMutationResult = Apollo.MutationResult; export type DeleteUserAccountMutationOptions = Apollo.BaseMutationOptions; +export const DeleteUserWorkspaceDocument = gql` + mutation DeleteUserWorkspace($workspaceMemberIdToDelete: String!) { + deleteUserFromWorkspace(workspaceMemberIdToDelete: $workspaceMemberIdToDelete) { + id + } +} + `; +export type DeleteUserWorkspaceMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteUserWorkspaceDocument, options); + } +export type DeleteUserWorkspaceMutationHookResult = ReturnType; +export type DeleteUserWorkspaceMutationResult = Apollo.MutationResult; +export type DeleteUserWorkspaceMutationOptions = Apollo.BaseMutationOptions; export const UploadProfilePictureDocument = gql` mutation UploadProfilePicture($file: Upload!) { uploadProfilePicture(file: $file) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index dea848eb6df..d13f9251df9 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -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; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts index 8967982e9c0..16bdb20b740 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts @@ -85,7 +85,7 @@ export const useFindManyRecords = ({ const hasReadPermission = objectPermissions.canReadObjectRecords; - const { data, loading, error, fetchMore } = + const { data, loading, error, fetchMore, refetch } = useQuery(findManyRecordsQuery, { skip: skip || !objectMetadataItem || !hasReadPermission, variables: { @@ -126,5 +126,6 @@ export const useFindManyRecords = ({ queryIdentifier, hasNextPage, pageInfo, + refetch, }; }; diff --git a/packages/twenty-front/src/modules/settings/profile/components/DeleteAccount.tsx b/packages/twenty-front/src/modules/settings/profile/components/DeleteAccount.tsx index 4bb942f524a..06503647a12 100644 --- a/packages/twenty-front/src/modules/settings/profile/components/DeleteAccount.tsx +++ b/packages/twenty-front/src/modules/settings/profile/components/DeleteAccount.tsx @@ -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 ( <> + {userHasMultipleWorkspaces && ( + +