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 19:29:12 +01:00
committed by GitHub
parent bfe1f47065
commit 4ce93aee52
38 changed files with 980 additions and 173 deletions
@@ -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}