Skip to content

Commit 9328e66

Browse files
authored
feat(account): let users delete their own account (#6831)
* feat(account): let users delete their own account Adds a GDPR self-serve account deletion path: a preflight that reports what deletion would remove and every reason it would be refused, and a confirmed delete that erases the account and everything only it can reach. Deletion refuses while the account is still entangled rather than reassigning its content. Most tables reference user.id with ON DELETE CASCADE, and those cascades do not distinguish content in the account's own workspace from content it created inside somebody else's, so each blocker names the existing action that untangles it (leave the workspace, leave the organization, cancel the plan) — all of which already hand work over on their own tested paths. * fix(account): make deletion atomic and re-check privacy at delete time Reorders the teardown so nothing irreversible happens before the deletion is certain: anchors are handed over first (the fallible step, while everything is still recoverable), the workspace and user deletes now share one transaction, and the object-storage purge runs only after that commits. The workspace delete also re-checks inside the transaction that each workspace is still private, so a membership granted between the preview and the delete aborts the whole thing instead of destroying the new member's access. * fix(account): close deletion gaps found in review - Run the whole teardown in one transaction. The billing and ownership handovers now take the caller's transaction, so a refused deletion can no longer leave a workspace reassigned for a deletion that never happened. - Fail closed on a subscription read error. getHighestPriorityPersonalSubscription defaulted to returning null, which read as "no plan" and would have erased an account Stripe was still billing. - Erase the account's profile picture. It is personal data under our own storage prefix; an external provider avatar is left alone. - Enforce the storage purge cap while collecting keys rather than after, so an oversized account cannot exhaust memory before the cap applies.
1 parent b6ca0d7 commit 9328e66

16 files changed

Lines changed: 1421 additions & 50 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { deleteAccountContract, getAccountDeletionPlanContract } from '@/lib/api/contracts'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import {
9+
deleteAccountUseCase,
10+
previewAccountDeletionUseCase,
11+
} from '@/lib/users/application/delete-account'
12+
import { userAccountOperations } from '@/lib/users/application/operations'
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
export const GET = defineInternalJsonRoute({
17+
contract: getAccountDeletionPlanContract,
18+
auth: internalSessionAuth,
19+
operation: userAccountOperations.previewDeletion,
20+
rateLimit: internalRateLimits.none({ reason: 'Read-only preview of the caller’s own account' }),
21+
errorPolicy: internalOrchestrationErrorPolicy,
22+
mapInput: () => ({}),
23+
useCase: previewAccountDeletionUseCase,
24+
present: (plan) => ({ plan }),
25+
})
26+
27+
/**
28+
* `AccountDeletionBlockedError` classifies itself as a conflict, so the shared
29+
* orchestration policy renders a refused deletion as a 409 carrying the first
30+
* blocker's sentence. The dialog lists every blocker from the GET above; this
31+
* message covers only the race where one appears between the two calls.
32+
*/
33+
export const POST = defineInternalJsonRoute({
34+
contract: deleteAccountContract,
35+
auth: internalSessionAuth,
36+
operation: userAccountOperations.delete,
37+
rateLimit: internalRateLimits.none({ reason: 'Guarded by the email confirmation it requires' }),
38+
errorPolicy: internalOrchestrationErrorPolicy,
39+
mapInput: ({ body }) => ({ confirmEmail: body.confirmEmail }),
40+
useCase: deleteAccountUseCase,
41+
present: () => ({ success: true as const }),
42+
})
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { ChipConfirmModal, ChipModalError, ChipModalField } from '@sim/emcn'
5+
import { createLogger } from '@sim/logger'
6+
import { sleep } from '@sim/utils/helpers'
7+
import { formatQuotedNameList, normalizeEmail } from '@sim/utils/string'
8+
import { signOut } from '@/lib/auth/auth-client'
9+
import { useAccountDeletionPlan, useDeleteAccount } from '@/hooks/queries/account-deletion'
10+
import { clearUserData } from '@/stores'
11+
12+
const logger = createLogger('DeleteAccountModal')
13+
14+
/** Matches the naming used in the server's blocker sentences. */
15+
const MAX_NAMES_LISTED = 3
16+
17+
/** How long the post-deletion sign-out and store cleanup may take before the redirect goes anyway. */
18+
const SIGN_OUT_TIMEOUT_MS = 3000
19+
20+
interface DeleteAccountModalProps {
21+
open: boolean
22+
onOpenChange: (open: boolean) => void
23+
/** The signed-in account's email, which must be retyped to confirm. */
24+
email: string
25+
}
26+
27+
function names(workspaces: { name: string }[]): string {
28+
return formatQuotedNameList(
29+
workspaces.map((workspace) => workspace.name),
30+
MAX_NAMES_LISTED
31+
)
32+
}
33+
34+
/**
35+
* Confirms and performs account deletion.
36+
*
37+
* The dialog is deliberately explicit rather than alarming: it names every
38+
* workspace that goes, every workspace that changes hands, and — when the account
39+
* cannot be deleted yet — exactly what has to happen first. Retyping the account's
40+
* own email address is the only guard, which is the point: the decision should
41+
* cost a deliberate action, not a hunt for the right button.
42+
*/
43+
export function DeleteAccountModal({ open, onOpenChange, email }: DeleteAccountModalProps) {
44+
const [confirmEmail, setConfirmEmail] = useState('')
45+
const { data: plan, isFetching: isPlanFetching, error: planError } = useAccountDeletionPlan(open)
46+
const deleteAccount = useDeleteAccount()
47+
48+
const blockers = plan?.blockers ?? []
49+
const toDelete = plan?.workspacesToDelete ?? []
50+
const toTransfer = plan?.workspacesToTransfer ?? []
51+
const isBlocked = blockers.length > 0
52+
const isConfirmed = normalizeEmail(confirmEmail) === normalizeEmail(email)
53+
const isPending = deleteAccount.isPending
54+
55+
const close = () => {
56+
onOpenChange(false)
57+
setConfirmEmail('')
58+
deleteAccount.reset()
59+
}
60+
61+
const handleDelete = () => {
62+
deleteAccount.mutate(
63+
{ confirmEmail },
64+
{
65+
onSuccess: async () => {
66+
/**
67+
* The session row is already gone, so signing out can only fail by
68+
* telling us so — what matters is that its cookie is dropped and no
69+
* cached client state survives the redirect. The race bounds that
70+
* cleanup: the account is deleted either way, so a request left hanging
71+
* must not strand the user on "Deleting..." forever. The redirect is a
72+
* full document load, which discards anything the cleanup missed.
73+
*/
74+
await Promise.race([
75+
Promise.allSettled([signOut(), clearUserData()]),
76+
sleep(SIGN_OUT_TIMEOUT_MS),
77+
])
78+
window.location.href = '/login?fromLogout=true'
79+
},
80+
onError: (error) => {
81+
logger.error('Account deletion failed', { error })
82+
},
83+
}
84+
)
85+
}
86+
87+
const errorMessage =
88+
deleteAccount.error?.message ??
89+
(planError ? 'Could not check whether this account can be deleted. Try again.' : null)
90+
91+
return (
92+
<ChipConfirmModal
93+
open={open}
94+
onOpenChange={(next) => {
95+
if (!next) close()
96+
}}
97+
size='md'
98+
title='Delete account'
99+
confirm={{
100+
label: 'Delete account',
101+
pendingLabel: 'Deleting...',
102+
onClick: handleDelete,
103+
pending: isPending,
104+
disabled: isBlocked || isPlanFetching || !isConfirmed || !plan,
105+
disabledTooltip: isBlocked
106+
? 'Resolve the items above first'
107+
: isConfirmed
108+
? undefined
109+
: 'Enter your account email to confirm',
110+
}}
111+
>
112+
{isBlocked ? (
113+
<div className='flex flex-col gap-2 px-2'>
114+
<p className='text-[var(--text-primary)] text-sm'>Your account can’t be deleted yet:</p>
115+
<ul className='flex list-disc flex-col gap-1 pl-4'>
116+
{blockers.map((blocker) => (
117+
<li key={blocker.code} className='text-[var(--text-secondary)] text-sm'>
118+
{blocker.message}
119+
</li>
120+
))}
121+
</ul>
122+
</div>
123+
) : (
124+
<div className='flex flex-col gap-2 px-2'>
125+
<p className='text-[var(--text-primary)] text-sm'>
126+
This permanently deletes <span className='font-medium'>{email}</span> along with its
127+
workflows, chats, files, knowledge bases and credentials.{' '}
128+
<span className='text-[var(--text-error)]'>This cannot be undone.</span>
129+
</p>
130+
{toDelete.length > 0 && (
131+
<p className='text-[var(--text-secondary)] text-sm'>
132+
{toDelete.length === 1 ? 'The workspace ' : 'The workspaces '}
133+
<span className='text-[var(--text-primary)]'>{names(toDelete)}</span> and everything
134+
in {toDelete.length === 1 ? 'it' : 'them'} will be deleted.
135+
</p>
136+
)}
137+
{toTransfer.length > 0 && (
138+
<p className='text-[var(--text-secondary)] text-sm'>
139+
Billing for <span className='text-[var(--text-primary)]'>{names(toTransfer)}</span>{' '}
140+
moves to another admin. Nothing in {toTransfer.length === 1 ? 'it' : 'them'} changes.
141+
</p>
142+
)}
143+
</div>
144+
)}
145+
{!isBlocked && (
146+
<ChipModalField
147+
type='email'
148+
title='Confirm your email'
149+
value={confirmEmail}
150+
onChange={setConfirmEmail}
151+
placeholder={email}
152+
autoComplete='off'
153+
disabled={isPending || isPlanFetching}
154+
required
155+
/>
156+
)}
157+
<ChipModalError>{errorMessage}</ChipModalError>
158+
</ChipConfirmModal>
159+
)
160+
}

apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useEffect, useRef, useState } from 'react'
44
import {
55
Button,
6+
Chip,
67
ChipCombobox,
78
ChipModal,
89
ChipModalBody,
@@ -26,6 +27,7 @@ import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
2627
import { isHosted } from '@/lib/core/config/env-flags'
2728
import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone'
2829
import { getBaseUrl } from '@/lib/core/utils/urls'
30+
import { DeleteAccountModal } from '@/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal'
2931
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
3032
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
3133
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -93,6 +95,8 @@ export function General() {
9395
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false)
9496
const resetPassword = useResetPassword()
9597

98+
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
99+
96100
const [uploadError, setUploadError] = useState<string | null>(null)
97101

98102
const snapToGridValue = settings?.snapToGridSize ?? 0
@@ -572,6 +576,21 @@ export function General() {
572576
</p>
573577
</div>
574578
</SettingsSection>
579+
580+
{!isAuthDisabled && (
581+
<SettingsSection label='Account'>
582+
<div className='flex flex-col gap-3'>
583+
<div className='flex items-center justify-between'>
584+
<Label>Delete account</Label>
585+
<Chip onClick={() => setShowDeleteAccountModal(true)}>Delete</Chip>
586+
</div>
587+
<p className='text-[var(--text-muted)] text-small'>
588+
Permanently deletes your account and everything only you can reach — workflows,
589+
chats, files, knowledge bases and credentials. This cannot be undone.
590+
</p>
591+
</div>
592+
</SettingsSection>
593+
)}
575594
</SettingsPanel>
576595

577596
<ChipModal
@@ -604,6 +623,12 @@ export function General() {
604623
}}
605624
/>
606625
</ChipModal>
626+
627+
<DeleteAccountModal
628+
open={showDeleteAccountModal}
629+
onOpenChange={setShowDeleteAccountModal}
630+
email={profile?.email || ''}
631+
/>
607632
</>
608633
)
609634
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useMutation, useQuery } from '@tanstack/react-query'
2+
import { requestJson } from '@/lib/api/client/request'
3+
import {
4+
type AccountDeletionPlan,
5+
type DeleteAccountBody,
6+
deleteAccountContract,
7+
getAccountDeletionPlanContract,
8+
} from '@/lib/api/contracts/user'
9+
10+
export const accountDeletionKeys = {
11+
all: ['account-deletion'] as const,
12+
plan: () => [...accountDeletionKeys.all, 'plan'] as const,
13+
}
14+
15+
/**
16+
* Zero: the plan is a consent disclosure, so every dialog open must refetch — its
17+
* blockers must reflect the account as it is right now, and a workspace that
18+
* gained an admin a minute ago changes the answer.
19+
*
20+
* The dialog stays mounted while closed, so the previous open's plan is still in
21+
* the cache and `isLoading` is false during that refetch. The dialog therefore
22+
* holds its confirm on `isFetching`, not `isLoading`, until fresh data lands;
23+
* `gcTime: 0` only evicts once the settings panel itself unmounts.
24+
*/
25+
export const ACCOUNT_DELETION_PLAN_STALE_TIME = 0
26+
27+
async function fetchAccountDeletionPlan(signal?: AbortSignal): Promise<AccountDeletionPlan> {
28+
const data = await requestJson(getAccountDeletionPlanContract, { signal })
29+
return data.plan
30+
}
31+
32+
export function useAccountDeletionPlan(enabled: boolean) {
33+
return useQuery({
34+
queryKey: accountDeletionKeys.plan(),
35+
queryFn: ({ signal }) => fetchAccountDeletionPlan(signal),
36+
enabled,
37+
staleTime: ACCOUNT_DELETION_PLAN_STALE_TIME,
38+
gcTime: 0,
39+
retry: false,
40+
})
41+
}
42+
43+
/**
44+
* Succeeds exactly once per account: the session that authorized it is gone by
45+
* the time the response lands, so there is no cache left to invalidate. The
46+
* caller is responsible for clearing local state and sending the user to sign-in.
47+
*/
48+
export function useDeleteAccount() {
49+
return useMutation({
50+
mutationFn: async (body: DeleteAccountBody) => {
51+
await requestJson(deleteAccountContract, { body })
52+
},
53+
})
54+
}

apps/sim/lib/api/contracts/user.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,75 @@ export const subscriptionTransferContract = defineRouteContract({
413413
}),
414414
},
415415
})
416+
417+
/** Every reason an account cannot be erased on its own, as rendered to its owner. */
418+
export const accountDeletionBlockerSchema = z.object({
419+
code: z.enum([
420+
'paid_organization_owner',
421+
'organization_member',
422+
'active_subscription',
423+
'shared_workspace',
424+
'organization_workspace',
425+
'data_drain_owner',
426+
]),
427+
/** A sentence naming both the obstacle and the way out. */
428+
message: z.string(),
429+
})
430+
431+
const accountDeletionResourceSchema = z.object({
432+
id: z.string(),
433+
name: z.string(),
434+
})
435+
436+
export type AccountDeletionResource = z.output<typeof accountDeletionResourceSchema>
437+
438+
export const accountDeletionPlanSchema = z.object({
439+
blockers: z.array(accountDeletionBlockerSchema),
440+
/** Workspaces nobody else can reach — erased along with the account. */
441+
workspacesToDelete: z.array(accountDeletionResourceSchema),
442+
/**
443+
* Workspaces the account only anchors — it pays for them or is recorded as
444+
* their owner while holding no access to them. The anchor moves to an admin
445+
* who does; nothing inside changes hands.
446+
*/
447+
workspacesToTransfer: z.array(accountDeletionResourceSchema),
448+
})
449+
450+
export type AccountDeletionBlocker = z.output<typeof accountDeletionBlockerSchema>
451+
export type AccountDeletionPlan = z.output<typeof accountDeletionPlanSchema>
452+
453+
export const getAccountDeletionPlanContract = defineRouteContract({
454+
method: 'GET',
455+
path: '/api/users/me/deletion',
456+
response: {
457+
mode: 'json',
458+
schema: z.object({
459+
plan: accountDeletionPlanSchema,
460+
}),
461+
},
462+
})
463+
464+
export const deleteAccountBodySchema = z.object({
465+
/**
466+
* The account's own email address, retyped. Checked server-side against the
467+
* session's account so a mis-wired client cannot delete anything else.
468+
*/
469+
confirmEmail: z
470+
.string({ error: 'Confirm your email address to delete your account' })
471+
.min(1, 'Confirm your email address to delete your account')
472+
.max(320, 'Email address is too long'),
473+
})
474+
475+
export type DeleteAccountBody = z.input<typeof deleteAccountBodySchema>
476+
477+
export const deleteAccountContract = defineRouteContract({
478+
method: 'POST',
479+
path: '/api/users/me/deletion',
480+
body: deleteAccountBodySchema,
481+
response: {
482+
mode: 'json',
483+
schema: z.object({
484+
success: z.literal(true),
485+
}),
486+
},
487+
})

0 commit comments

Comments
 (0)