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
@@ -8,6 +8,9 @@ export type ConnectedAccount = {
handle: string;
provider: ConnectedAccountProvider;
authFailedAt: string | null;
// Set when the account was frozen after its owner left the workspace:
// synced data is kept but the account is read-only.
archivedAt: string | null;
scopes: string[] | null;
handleAliases: string[] | null;
lastSignedInAt: string | null;
@@ -274,6 +274,10 @@ export const SubPageNavigation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Let the side panel finish opening before interacting, otherwise the
// click can land mid-transition and the sub-page navigation is dropped.
await sleep(openTimeout);
const objectButton = await canvas.findByText('Object');
expect(objectButton).toBeVisible();
@@ -4,6 +4,7 @@ import { SyncStatus } from '@/settings/accounts/constants/SyncStatus';
import { computeSyncStatus } from '@/settings/accounts/utils/computeSyncStatus';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { Status } from 'twenty-ui-deprecated/display';
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
@@ -21,8 +22,21 @@ export const SettingsAccountsConnectedAccountsRowRightContainer = ({
const messageChannel = account.messageChannels[0];
const calendarChannel = account.calendarChannels[0];
const isArchived = isDefined(account.archivedAt);
const status = computeSyncStatus(messageChannel, calendarChannel);
// Archived accounts are frozen (owner left the workspace): their synced data
// is kept but sync is disabled, so the live sync status is no longer relevant.
if (isArchived) {
return (
<StyledRowRightContainer>
<Status color="gray" text={t`Archived`} weight="medium" />
<SettingsAccountsRowDropdownMenu account={account} />
</StyledRowRightContainer>
);
}
return (
<StyledRowRightContainer>
{status === SyncStatus.FAILED && (
@@ -7,6 +7,7 @@ export const GET_MY_CONNECTED_ACCOUNTS = gql`
handle
provider
authFailedAt
archivedAt
scopes
handleAliases
lastSignedInAt
@@ -101,7 +101,7 @@ export const DeleteAccount = () => {
title={t`Leave workspace`}
subtitle={
<>
{t`This action cannot be undone. This will permanently remove your membership from this workspace.`}
{t`This action cannot be undone. Your membership will be removed; synced emails and calendars stay with the workspace.`}
<br />
{t`Please type in your email to confirm.`}
</>
@@ -26,6 +26,7 @@ const mockedConnectedAccounts = [
handle: 'tim@apple.dev',
provider: 'google',
authFailedAt: null,
archivedAt: null,
scopes: ['email', 'calendar'],
handleAliases: '',
lastSignedInAt: null,