Keep synced messages and events when removing a workspace member (#21443)

## Context

Removing a workspace member deletes their connected accounts, which
cascades into deleting every message and calendar event those accounts
synced. For a CRM, losing the email history of departed teammates is a
big deal.

## What this does

Connected accounts are now kept and reassigned instead of deleted when a
member is removed:

- Ownership moves to the acting user (whoever removed the member). When
members remove themselves (leave workspace, account deletion), it falls
back to the oldest admin.
- OAuth tokens are revoked, credentials wiped, message/calendar channels
get `isSyncEnabled = false`, and the account is stamped with a new
`archivedAt` column (fast instance command included).
- Synced messages, threads and calendar events stay in the workspace.
Channel visibility settings keep applying as before, since channels and
associations survive.
- The reassigned account appears in the new owner's Settings → Accounts,
where it can still be deleted (with its data) like any other account.

The transfer happens synchronously during removal, while the member's
userWorkspace row still exists. This also removes
`DeleteWorkspaceMemberConnectedAccountsCleanupJob` and its listener: the
async job had to reconstruct the account-owner link from rows the
removal flow had just deleted, which was race-prone (see 2181fb541e).

Archived accounts are excluded from the workflow send-email default
account resolution, and both removal confirmation modals now mention
what happens to synced data.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-06-13 15:52:13 +02:00
committed by GitHub
parent f63f053444
commit 76f69efb43
27 changed files with 485 additions and 398 deletions
@@ -11,7 +11,7 @@ import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspac
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
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 { UserService } from 'src/engine/core-modules/user/services/user.service';
import { WorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/workspace-member-transpiler.service';
@@ -23,6 +23,7 @@ import {
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@@ -96,6 +97,18 @@ describe('UserService', () => {
validateUserWorkspaceIsNotUniqueAdminOrThrow: jest.fn(),
},
},
{
provide: getRepositoryToken(UserWorkspaceEntity),
useValue: {
find: jest.fn().mockResolvedValue([]),
},
},
{
provide: ConnectedAccountMetadataService,
useValue: {
transferOwnership: jest.fn(),
},
},
{
provide: UserWorkspaceService,
useValue: {
@@ -44,9 +44,11 @@ import {
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
// oxlint-disable-next-line twenty/inject-workspace-repository
@@ -54,6 +56,9 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly emailVerificationService: EmailVerificationService,
private readonly workspaceService: WorkspaceService,
@@ -295,9 +300,11 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
async deleteUserWorkspaceAndPotentiallyDeleteUser({
userId,
workspaceId,
actingUserWorkspaceId,
}: {
userId: string;
workspaceId: string;
actingUserWorkspaceId?: string;
}) {
const user = await this.userRepository.findOne({
where: {
@@ -318,6 +325,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
await this.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(
userWorkspace,
actingUserWorkspaceId,
);
if (user.userWorkspaces.length === 1) {
@@ -330,6 +338,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
async removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(
userWorkspace: UserWorkspaceEntity,
actingUserWorkspaceId?: string,
) {
const workspaceId = userWorkspace.workspaceId;
const authContext = buildSystemAuthContext(workspaceId);
@@ -390,6 +399,20 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
assert(workspaceMember, 'WorkspaceMember not found');
const custodianUserWorkspaceId =
await this.resolveConnectedAccountsCustodianUserWorkspaceId({
removedUserWorkspace: userWorkspace,
actingUserWorkspaceId,
});
if (isDefined(custodianUserWorkspaceId)) {
await this.connectedAccountMetadataService.transferOwnership({
fromUserWorkspaceId: userWorkspaceId,
toUserWorkspaceId: custodianUserWorkspaceId,
workspaceId,
});
}
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
@@ -408,6 +431,55 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
});
}
private async resolveConnectedAccountsCustodianUserWorkspaceId({
removedUserWorkspace,
actingUserWorkspaceId,
}: {
removedUserWorkspace: UserWorkspaceEntity;
actingUserWorkspaceId?: string;
}): Promise<string | undefined> {
const otherUserWorkspaces = await this.userWorkspaceRepository.find({
where: {
workspaceId: removedUserWorkspace.workspaceId,
id: Not(removedUserWorkspace.id),
},
order: { createdAt: 'ASC' },
});
if (otherUserWorkspaces.length === 0) {
return undefined;
}
const actingUserWorkspace = otherUserWorkspaces.find(
(otherUserWorkspace) => otherUserWorkspace.id === actingUserWorkspaceId,
);
if (isDefined(actingUserWorkspace)) {
return actingUserWorkspace.id;
}
const rolesByUserWorkspaceId =
await this.userRoleService.getRolesByUserWorkspaces({
userWorkspaceIds: otherUserWorkspaces.map(
(otherUserWorkspace) => otherUserWorkspace.id,
),
workspaceId: removedUserWorkspace.workspaceId,
});
const oldestAdminUserWorkspace = otherUserWorkspaces.find(
(otherUserWorkspace) =>
rolesByUserWorkspaceId
.get(otherUserWorkspace.id)
?.some(
(role) =>
role.universalIdentifier ===
STANDARD_ROLE.admin.universalIdentifier,
),
);
return (oldestAdminUserWorkspace ?? otherUserWorkspaces[0]).id;
}
async hasUserAccessToWorkspaceOrThrow(userId: string, workspaceId: string) {
const user = await this.userRepository.findOne({
where: {
@@ -22,6 +22,7 @@ import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserResolver } from 'src/engine/core-modules/user/user.resolver';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@@ -48,6 +49,7 @@ import { UserService } from './services/user.service';
UserVarsModule,
UserWorkspaceModule,
UserRoleModule,
ConnectedAccountMetadataModule,
FeatureFlagModule,
PermissionsModule,
EmailVerificationModule,
@@ -443,6 +443,7 @@ export class UserResolver {
return this.userService.deleteUserWorkspaceAndPotentiallyDeleteUser({
userId: workspaceMemberToDelete.userId,
workspaceId: workspace.id,
actingUserWorkspaceId: userWorkspaceId,
});
}