From 522009374767ae6279b0189ef02fba65a01f1d25 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 16:54:36 -0700 Subject: [PATCH 1/3] feat(account): let users delete their own account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/app/api/users/me/deletion/route.ts | 42 ++ .../components/delete-account-modal.tsx | 160 ++++++ .../settings/components/general/general.tsx | 25 + apps/sim/hooks/queries/account-deletion.ts | 54 ++ apps/sim/lib/api/contracts/user.ts | 72 +++ apps/sim/lib/auth/auth.ts | 42 +- apps/sim/lib/users/account-deletion.test.ts | 165 ++++++ apps/sim/lib/users/account-deletion.ts | 491 ++++++++++++++++++ .../users/application/delete-account.test.ts | 63 +++ .../lib/users/application/delete-account.ts | 90 ++++ apps/sim/lib/users/application/operations.ts | 13 + packages/audit/src/types.ts | 4 + .../src/components/chip-modal/chip-modal.tsx | 42 +- packages/testing/src/mocks/audit.mock.ts | 2 + scripts/check-api-validation-contracts.ts | 4 +- 15 files changed, 1228 insertions(+), 41 deletions(-) create mode 100644 apps/sim/app/api/users/me/deletion/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal.tsx create mode 100644 apps/sim/hooks/queries/account-deletion.ts create mode 100644 apps/sim/lib/users/account-deletion.test.ts create mode 100644 apps/sim/lib/users/account-deletion.ts create mode 100644 apps/sim/lib/users/application/delete-account.test.ts create mode 100644 apps/sim/lib/users/application/delete-account.ts create mode 100644 apps/sim/lib/users/application/operations.ts diff --git a/apps/sim/app/api/users/me/deletion/route.ts b/apps/sim/app/api/users/me/deletion/route.ts new file mode 100644 index 00000000000..ebb8b64aca7 --- /dev/null +++ b/apps/sim/app/api/users/me/deletion/route.ts @@ -0,0 +1,42 @@ +import { deleteAccountContract, getAccountDeletionPlanContract } from '@/lib/api/contracts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + deleteAccountUseCase, + previewAccountDeletionUseCase, +} from '@/lib/users/application/delete-account' +import { userAccountOperations } from '@/lib/users/application/operations' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: getAccountDeletionPlanContract, + auth: internalSessionAuth, + operation: userAccountOperations.previewDeletion, + rateLimit: internalRateLimits.none({ reason: 'Read-only preview of the caller’s own account' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => ({}), + useCase: previewAccountDeletionUseCase, + present: (plan) => ({ plan }), +}) + +/** + * `AccountDeletionBlockedError` classifies itself as a conflict, so the shared + * orchestration policy renders a refused deletion as a 409 carrying the first + * blocker's sentence. The dialog lists every blocker from the GET above; this + * message covers only the race where one appears between the two calls. + */ +export const POST = defineInternalJsonRoute({ + contract: deleteAccountContract, + auth: internalSessionAuth, + operation: userAccountOperations.delete, + rateLimit: internalRateLimits.none({ reason: 'Guarded by the email confirmation it requires' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => ({ confirmEmail: body.confirmEmail }), + useCase: deleteAccountUseCase, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal.tsx new file mode 100644 index 00000000000..0d3b639067c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal, ChipModalError, ChipModalField } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' +import { formatQuotedNameList, normalizeEmail } from '@sim/utils/string' +import { signOut } from '@/lib/auth/auth-client' +import { useAccountDeletionPlan, useDeleteAccount } from '@/hooks/queries/account-deletion' +import { clearUserData } from '@/stores' + +const logger = createLogger('DeleteAccountModal') + +/** Matches the naming used in the server's blocker sentences. */ +const MAX_NAMES_LISTED = 3 + +/** How long the post-deletion sign-out and store cleanup may take before the redirect goes anyway. */ +const SIGN_OUT_TIMEOUT_MS = 3000 + +interface DeleteAccountModalProps { + open: boolean + onOpenChange: (open: boolean) => void + /** The signed-in account's email, which must be retyped to confirm. */ + email: string +} + +function names(workspaces: { name: string }[]): string { + return formatQuotedNameList( + workspaces.map((workspace) => workspace.name), + MAX_NAMES_LISTED + ) +} + +/** + * Confirms and performs account deletion. + * + * The dialog is deliberately explicit rather than alarming: it names every + * workspace that goes, every workspace that changes hands, and — when the account + * cannot be deleted yet — exactly what has to happen first. Retyping the account's + * own email address is the only guard, which is the point: the decision should + * cost a deliberate action, not a hunt for the right button. + */ +export function DeleteAccountModal({ open, onOpenChange, email }: DeleteAccountModalProps) { + const [confirmEmail, setConfirmEmail] = useState('') + const { data: plan, isFetching: isPlanFetching, error: planError } = useAccountDeletionPlan(open) + const deleteAccount = useDeleteAccount() + + const blockers = plan?.blockers ?? [] + const toDelete = plan?.workspacesToDelete ?? [] + const toTransfer = plan?.workspacesToTransfer ?? [] + const isBlocked = blockers.length > 0 + const isConfirmed = normalizeEmail(confirmEmail) === normalizeEmail(email) + const isPending = deleteAccount.isPending + + const close = () => { + onOpenChange(false) + setConfirmEmail('') + deleteAccount.reset() + } + + const handleDelete = () => { + deleteAccount.mutate( + { confirmEmail }, + { + onSuccess: async () => { + /** + * The session row is already gone, so signing out can only fail by + * telling us so — what matters is that its cookie is dropped and no + * cached client state survives the redirect. The race bounds that + * cleanup: the account is deleted either way, so a request left hanging + * must not strand the user on "Deleting..." forever. The redirect is a + * full document load, which discards anything the cleanup missed. + */ + await Promise.race([ + Promise.allSettled([signOut(), clearUserData()]), + sleep(SIGN_OUT_TIMEOUT_MS), + ]) + window.location.href = '/login?fromLogout=true' + }, + onError: (error) => { + logger.error('Account deletion failed', { error }) + }, + } + ) + } + + const errorMessage = + deleteAccount.error?.message ?? + (planError ? 'Could not check whether this account can be deleted. Try again.' : null) + + return ( + { + if (!next) close() + }} + size='md' + title='Delete account' + confirm={{ + label: 'Delete account', + pendingLabel: 'Deleting...', + onClick: handleDelete, + pending: isPending, + disabled: isBlocked || isPlanFetching || !isConfirmed || !plan, + disabledTooltip: isBlocked + ? 'Resolve the items above first' + : isConfirmed + ? undefined + : 'Enter your account email to confirm', + }} + > + {isBlocked ? ( +
+

Your account can’t be deleted yet:

+
    + {blockers.map((blocker) => ( +
  • + {blocker.message} +
  • + ))} +
+
+ ) : ( +
+

+ This permanently deletes {email} along with its + workflows, chats, files, knowledge bases and credentials.{' '} + This cannot be undone. +

+ {toDelete.length > 0 && ( +

+ {toDelete.length === 1 ? 'The workspace ' : 'The workspaces '} + {names(toDelete)} and everything + in {toDelete.length === 1 ? 'it' : 'them'} will be deleted. +

+ )} + {toTransfer.length > 0 && ( +

+ Billing for {names(toTransfer)}{' '} + moves to another admin. Nothing in {toTransfer.length === 1 ? 'it' : 'them'} changes. +

+ )} +
+ )} + {!isBlocked && ( + + )} + {errorMessage} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 97b22aa3073..13a85bc3d9e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react' import { Button, + Chip, ChipCombobox, ChipModal, ChipModalBody, @@ -26,6 +27,7 @@ import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' import { isHosted } from '@/lib/core/config/env-flags' import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone' import { getBaseUrl } from '@/lib/core/utils/urls' +import { DeleteAccountModal } from '@/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -93,6 +95,8 @@ export function General() { const [showResetPasswordModal, setShowResetPasswordModal] = useState(false) const resetPassword = useResetPassword() + const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false) + const [uploadError, setUploadError] = useState(null) const snapToGridValue = settings?.snapToGridSize ?? 0 @@ -572,6 +576,21 @@ export function General() {

+ + {!isAuthDisabled && ( + +
+
+ + setShowDeleteAccountModal(true)}>Delete +
+

+ Permanently deletes your account and everything only you can reach — workflows, + chats, files, knowledge bases and credentials. This cannot be undone. +

+
+
+ )} + + ) } diff --git a/apps/sim/hooks/queries/account-deletion.ts b/apps/sim/hooks/queries/account-deletion.ts new file mode 100644 index 00000000000..60fc77ca836 --- /dev/null +++ b/apps/sim/hooks/queries/account-deletion.ts @@ -0,0 +1,54 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type AccountDeletionPlan, + type DeleteAccountBody, + deleteAccountContract, + getAccountDeletionPlanContract, +} from '@/lib/api/contracts/user' + +export const accountDeletionKeys = { + all: ['account-deletion'] as const, + plan: () => [...accountDeletionKeys.all, 'plan'] as const, +} + +/** + * Zero: the plan is a consent disclosure, so every dialog open must refetch — its + * blockers must reflect the account as it is right now, and a workspace that + * gained an admin a minute ago changes the answer. + * + * The dialog stays mounted while closed, so the previous open's plan is still in + * the cache and `isLoading` is false during that refetch. The dialog therefore + * holds its confirm on `isFetching`, not `isLoading`, until fresh data lands; + * `gcTime: 0` only evicts once the settings panel itself unmounts. + */ +export const ACCOUNT_DELETION_PLAN_STALE_TIME = 0 + +async function fetchAccountDeletionPlan(signal?: AbortSignal): Promise { + const data = await requestJson(getAccountDeletionPlanContract, { signal }) + return data.plan +} + +export function useAccountDeletionPlan(enabled: boolean) { + return useQuery({ + queryKey: accountDeletionKeys.plan(), + queryFn: ({ signal }) => fetchAccountDeletionPlan(signal), + enabled, + staleTime: ACCOUNT_DELETION_PLAN_STALE_TIME, + gcTime: 0, + retry: false, + }) +} + +/** + * Succeeds exactly once per account: the session that authorized it is gone by + * the time the response lands, so there is no cache left to invalidate. The + * caller is responsible for clearing local state and sending the user to sign-in. + */ +export function useDeleteAccount() { + return useMutation({ + mutationFn: async (body: DeleteAccountBody) => { + await requestJson(deleteAccountContract, { body }) + }, + }) +} diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 2056b7e2a95..9b34e9bbf5a 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -413,3 +413,75 @@ export const subscriptionTransferContract = defineRouteContract({ }), }, }) + +/** Every reason an account cannot be erased on its own, as rendered to its owner. */ +export const accountDeletionBlockerSchema = z.object({ + code: z.enum([ + 'paid_organization_owner', + 'organization_member', + 'active_subscription', + 'shared_workspace', + 'organization_workspace', + 'data_drain_owner', + ]), + /** A sentence naming both the obstacle and the way out. */ + message: z.string(), +}) + +const accountDeletionResourceSchema = z.object({ + id: z.string(), + name: z.string(), +}) + +export type AccountDeletionResource = z.output + +export const accountDeletionPlanSchema = z.object({ + blockers: z.array(accountDeletionBlockerSchema), + /** Workspaces nobody else can reach — erased along with the account. */ + workspacesToDelete: z.array(accountDeletionResourceSchema), + /** + * Workspaces the account only anchors — it pays for them or is recorded as + * their owner while holding no access to them. The anchor moves to an admin + * who does; nothing inside changes hands. + */ + workspacesToTransfer: z.array(accountDeletionResourceSchema), +}) + +export type AccountDeletionBlocker = z.output +export type AccountDeletionPlan = z.output + +export const getAccountDeletionPlanContract = defineRouteContract({ + method: 'GET', + path: '/api/users/me/deletion', + response: { + mode: 'json', + schema: z.object({ + plan: accountDeletionPlanSchema, + }), + }, +}) + +export const deleteAccountBodySchema = z.object({ + /** + * The account's own email address, retyped. Checked server-side against the + * session's account so a mis-wired client cannot delete anything else. + */ + confirmEmail: z + .string({ error: 'Confirm your email address to delete your account' }) + .min(1, 'Confirm your email address to delete your account') + .max(320, 'Email address is too long'), +}) + +export type DeleteAccountBody = z.input + +export const deleteAccountContract = defineRouteContract({ + method: 'POST', + path: '/api/users/me/deletion', + body: deleteAccountBodySchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + }), + }, +}) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index b012ce3732b..8f7cb189175 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -262,40 +262,18 @@ export const auth = betterAuth({ }, }, user: { + /** + * Account deletion runs through `POST /api/users/me/deletion`, which owns the + * whole procedure — the blocker preflight, the storage purge, and the + * constraint-ordered teardown that a bare `DELETE FROM "user"` cannot + * express. Better Auth's endpoint stays off, and `beforeDelete` refuses + * unconditionally so that flipping `enabled` can never route a deletion + * around any of it. + */ deleteUser: { enabled: false, - beforeDelete: async (deletingUser) => { - const { isSoleOwnerOfPaidOrganization } = await import( - '@/lib/billing/organizations/membership' - ) - const check = await isSoleOwnerOfPaidOrganization(deletingUser.id) - if (check.isBlocker) { - throw new Error( - `You are the owner of ${check.organizationName ?? 'an active paid organization'}. Transfer ownership before deleting your account.` - ) - } - - const { reassignBilledAccountForUser, reassignOwnedWorkspacesForUser } = await import( - '@/lib/workspaces/utils' - ) - const { unresolved } = await reassignBilledAccountForUser(deletingUser.id) - if (unresolved.length > 0) { - throw new Error( - `Your account is the billing account for ${unresolved.length} workspace${unresolved.length === 1 ? '' : 's'} with no other admin to take it over. Add another admin to ${unresolved.length === 1 ? 'that workspace' : 'those workspaces'} or delete ${unresolved.length === 1 ? 'it' : 'them'} before deleting your account.` - ) - } - - // Reassign workspace ownership BEFORE deletion so the `workspace.owner_id` - // ON DELETE CASCADE can never silently nuke workspaces this user owns - // (e.g. org workspaces they created but are billed to the org owner). - const { unresolved: ownedUnresolved } = await reassignOwnedWorkspacesForUser( - deletingUser.id - ) - if (ownedUnresolved.length > 0) { - throw new Error( - `Your account owns ${ownedUnresolved.length} workspace${ownedUnresolved.length === 1 ? '' : 's'} with no other admin to take over ownership. Add another admin to ${ownedUnresolved.length === 1 ? 'that workspace' : 'those workspaces'} or delete ${ownedUnresolved.length === 1 ? 'it' : 'them'} before deleting your account.` - ) - } + beforeDelete: async () => { + throw new Error('Account deletion runs through POST /api/users/me/deletion') }, }, }, diff --git a/apps/sim/lib/users/account-deletion.test.ts b/apps/sim/lib/users/account-deletion.test.ts new file mode 100644 index 00000000000..7459d0ef457 --- /dev/null +++ b/apps/sim/lib/users/account-deletion.test.ts @@ -0,0 +1,165 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type AccountDeletionFacts, + classifyAccountDeletion, + type WorkspaceCompany, + type WorkspaceRow, +} from '@/lib/users/account-deletion' + +function workspace(overrides: Partial = {}): WorkspaceRow { + return { id: 'ws-1', name: 'My workspace', organizationId: null, ...overrides } +} + +function company(overrides: Partial = {}): WorkspaceCompany { + return { hasOtherMembers: false, isMember: true, hasAdminSuccessor: false, ...overrides } +} + +function facts(overrides: Partial = {}): AccountDeletionFacts { + return { + workspaces: [], + company: new Map(), + organizationNames: [], + paidOrganizationName: null, + personalPlan: null, + hasDataDrains: false, + ...overrides, + } +} + +function codes(plan: { blockers: { code: string }[] }): string[] { + return plan.blockers.map((blocker) => blocker.code) +} + +describe('classifyAccountDeletion', () => { + it('deletes a solo personal workspace with no blockers — the ordinary individual account', () => { + const ws = workspace() + const plan = classifyAccountDeletion( + facts({ workspaces: [ws], company: new Map([[ws.id, company()]]) }) + ) + + expect(plan.blockers).toEqual([]) + expect(plan.workspacesToDelete).toEqual([{ id: ws.id, name: ws.name }]) + expect(plan.workspacesToTransfer).toEqual([]) + }) + + it('blocks a workspace that other people are in rather than reassigning it', () => { + const ws = workspace({ name: 'Shared' }) + const plan = classifyAccountDeletion( + facts({ + workspaces: [ws], + company: new Map([[ws.id, company({ hasOtherMembers: true, hasAdminSuccessor: true })]]), + }) + ) + + expect(codes(plan)).toEqual(['shared_workspace']) + expect(plan.workspacesToDelete).toEqual([]) + expect(plan.workspacesToTransfer).toEqual([]) + expect(plan.blockers[0].message).toContain('Shared') + }) + + it('blocks a workspace the account merely belongs to, where it is not the anchor', () => { + const ws = workspace() + const plan = classifyAccountDeletion( + facts({ + workspaces: [ws], + company: new Map([[ws.id, company({ hasOtherMembers: true, hasAdminSuccessor: true })]]), + }) + ) + + expect(codes(plan)).toEqual(['shared_workspace']) + }) + + it('transfers a billing anchor the account holds no access to', () => { + const ws = workspace({ name: 'Anchored', ownerId: 'other-admin' }) + const plan = classifyAccountDeletion( + facts({ + workspaces: [ws], + company: new Map([ + [ws.id, company({ hasOtherMembers: true, isMember: false, hasAdminSuccessor: true })], + ]), + }) + ) + + expect(plan.blockers).toEqual([]) + expect(plan.workspacesToTransfer).toEqual([{ id: ws.id, name: 'Anchored' }]) + }) + + it('blocks an anchor nobody can inherit, rather than orphaning the billing reference', () => { + const ws = workspace() + const plan = classifyAccountDeletion( + facts({ + workspaces: [ws], + company: new Map([[ws.id, company({ hasOtherMembers: true, isMember: false })]]), + }) + ) + + expect(codes(plan)).toEqual(['shared_workspace']) + }) + + it('blocks a solo workspace that belongs to an organization, whose ledger is shared', () => { + const ws = workspace({ organizationId: 'org-1', name: 'Org space' }) + const plan = classifyAccountDeletion( + facts({ workspaces: [ws], company: new Map([[ws.id, company()]]) }) + ) + + expect(codes(plan)).toEqual(['organization_workspace']) + expect(plan.workspacesToDelete).toEqual([]) + }) + + it('reports paid organization ownership instead of plain membership', () => { + const plan = classifyAccountDeletion( + facts({ + paidOrganizationName: 'Acme', + organizationNames: ['Acme'], + }) + ) + + expect(codes(plan)).toEqual(['paid_organization_owner']) + expect(plan.blockers[0].message).toContain('Acme') + }) + + it('asks a plain organization member to leave first, so their seat is released', () => { + const plan = classifyAccountDeletion(facts({ organizationNames: ['Acme'] })) + + expect(codes(plan)).toEqual(['organization_member']) + }) + + it('collects every independent blocker in one pass', () => { + const ws = workspace({ name: 'Shared' }) + const plan = classifyAccountDeletion( + facts({ + workspaces: [ws], + company: new Map([[ws.id, company({ hasOtherMembers: true })]]), + organizationNames: ['Acme'], + personalPlan: 'pro', + hasDataDrains: true, + }) + ) + + expect(codes(plan)).toEqual([ + 'organization_member', + 'active_subscription', + 'data_drain_owner', + 'shared_workspace', + ]) + }) + + it('names up to three workspaces and summarizes the rest', () => { + const workspaces = ['One', 'Two', 'Three', 'Four'].map((name, index) => + workspace({ id: `ws-${index}`, name }) + ) + const plan = classifyAccountDeletion( + facts({ + workspaces, + company: new Map( + workspaces.map((ws) => [ws.id, company({ hasOtherMembers: true })] as const) + ), + }) + ) + + expect(plan.blockers[0].message).toContain('"One", "Two", "Three" and 1 more') + }) +}) diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts new file mode 100644 index 00000000000..32444065249 --- /dev/null +++ b/apps/sim/lib/users/account-deletion.ts @@ -0,0 +1,491 @@ +import { db } from '@sim/db' +import { + dataDrains, + document, + knowledgeBase, + member, + organization, + permissions, + user, + workspaceFile, + workspaceFiles, + workspace as workspaceTable, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { formatQuotedNameList } from '@sim/utils/string' +import { and, eq, gt, inArray, isNotNull, or, sql } from 'drizzle-orm' +import type { + AccountDeletionBlocker, + AccountDeletionPlan, + AccountDeletionResource, +} from '@/lib/api/contracts/user' +import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan' +import { isSoleOwnerOfPaidOrganization } from '@/lib/billing/organizations/membership' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { StorageContext } from '@/lib/uploads' +import { isUsingCloudStorage, StorageService } from '@/lib/uploads' +import { + reassignBilledAccountForUser, + reassignOwnedWorkspacesForUser, +} from '@/lib/workspaces/utils' + +const logger = createLogger('AccountDeletion') + +/** + * Rows per storage page, and keys per delete call. `StorageService.deleteFiles` + * chunks internally at S3's 1,000-key `DeleteObjects` limit, so anything smaller + * just under-fills that call and multiplies round trips. + */ +const STORAGE_PAGE_SIZE = 1000 + +/** Names listed inline in a blocker sentence before it summarizes the rest. */ +const MAX_NAMES_LISTED = 3 + +/** + * Refuses a deletion whose preconditions are not met. Classified as a conflict + * rather than a bad request: the caller asked for something legitimate that + * their current entanglements do not allow yet, and the shared orchestration + * policy already renders that as a 409 carrying this message. + */ +export class AccountDeletionBlockedError extends OrchestrationError { + constructor(readonly blockers: AccountDeletionBlocker[]) { + super('conflict', blockers[0]?.message ?? 'This account cannot be deleted yet.') + this.name = 'AccountDeletionBlockedError' + } +} + +export interface WorkspaceRow { + id: string + name: string + organizationId: string | null +} + +const WORKSPACE_COLUMNS = { + id: workspaceTable.id, + name: workspaceTable.name, + organizationId: workspaceTable.organizationId, +} as const + +/** + * Loads every workspace the account touches — the ones it anchors as owner or + * billing account, and the ones it merely has access to. + * + * Anchors have to be here because `owner_id` cascades (and would silently take + * the workspace with it) while `billed_account_user_id` is `NO ACTION` (and fails + * the statement outright, ahead of that cascade). Plain memberships have to be + * here for the opposite reason: they impose no constraint at all, yet the + * account's workflows, knowledge bases and files inside them would cascade away + * with it. + */ +async function loadRelatedWorkspaces(userId: string): Promise { + const [anchored, joined] = await Promise.all([ + db + .select(WORKSPACE_COLUMNS) + .from(workspaceTable) + .where( + or(eq(workspaceTable.ownerId, userId), eq(workspaceTable.billedAccountUserId, userId)) + ), + db + .select(WORKSPACE_COLUMNS) + .from(permissions) + .innerJoin(workspaceTable, eq(workspaceTable.id, permissions.entityId)) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.userId, userId))), + ]) + + const byId = new Map() + for (const row of [...anchored, ...joined]) byId.set(row.id, row) + return [...byId.values()] +} + +export interface WorkspaceCompany { + /** Whether anyone other than the departing account holds access to the workspace. */ + hasOtherMembers: boolean + /** Whether the departing account itself holds access to it. */ + isMember: boolean + /** + * Whether some other admin could inherit the billing and ownership anchors. + * Only the existence matters here — the handover itself is performed by + * `reassignBilledAccountForUser` / `reassignOwnedWorkspacesForUser`, which + * resolve the successor themselves. + */ + hasAdminSuccessor: boolean +} + +/** + * Answers, for each related workspace, who else is in it and whether the + * departing account is in it at all. + * + * Aggregated in Postgres rather than folded in JS: the three facts are booleans, + * and a workspace with thousands of members would otherwise transfer thousands of + * rows to compute them. One statement still means the answers cannot be read at + * different moments. + */ +async function loadWorkspaceCompany( + userId: string, + workspaces: WorkspaceRow[] +): Promise> { + const company = new Map() + if (workspaces.length === 0) return company + + const rows = await db + .select({ + entityId: permissions.entityId, + isMember: sql`bool_or(${permissions.userId} = ${userId})`, + hasOtherMembers: sql`bool_or(${permissions.userId} <> ${userId})`, + hasAdminSuccessor: sql`coalesce(bool_or(${permissions.userId} <> ${userId} and ${permissions.permissionType} = 'admin'), false)`, + }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + inArray( + permissions.entityId, + workspaces.map((workspace) => workspace.id) + ) + ) + ) + .groupBy(permissions.entityId) + + for (const workspace of workspaces) { + company.set(workspace.id, { + hasOtherMembers: false, + isMember: false, + hasAdminSuccessor: false, + }) + } + for (const row of rows) { + company.set(row.entityId, { + isMember: Boolean(row.isMember), + hasOtherMembers: Boolean(row.hasOtherMembers), + hasAdminSuccessor: Boolean(row.hasAdminSuccessor), + }) + } + + return company +} + +async function loadOrganizationNames(userId: string): Promise { + const rows = await db + .select({ name: organization.name }) + .from(member) + .innerJoin(organization, eq(organization.id, member.organizationId)) + .where(eq(member.userId, userId)) + + return rows.map((row) => row.name) +} + +/** Everything the classifier needs, gathered by {@link getAccountDeletionPlan}. */ +export interface AccountDeletionFacts { + /** Every workspace the account anchors or has access to. */ + workspaces: WorkspaceRow[] + /** Who else is in each of those workspaces, keyed by workspace id. */ + company: Map + organizationNames: string[] + /** The organization the account solely owns on a paid plan, if any. */ + paidOrganizationName: string | null + /** The account's own paid plan, if it still entitles them. */ + personalPlan: string | null + hasDataDrains: boolean +} + +/** + * Turns the gathered facts into the full picture of an account deletion: what it + * removes, what it hands off, and every reason it would be refused. + * + * The governing rule is that an account is erased only once it stands alone. + * Nearly every table that points at `user.id` does so with `ON DELETE CASCADE`, + * and those cascades do not distinguish a workflow in the account's own workspace + * from a knowledge base it happened to create inside somebody else's — both would + * go. Rather than chase that blast radius across every creator column (and + * silently lose whichever one is added next), deletion refuses while the account + * is still entangled and names the existing action that untangles it: leave the + * workspace, leave the organization, cancel the plan. Each of those already hands + * the account's content to a surviving member on its own well-tested path. + * + * What remains is provably private, so a workspace falls into exactly one bucket: + * - **delete** — nobody else can reach it, so it is erased with the account. + * - **transfer** — the account only pays for it or is recorded as its owner + * while holding no access to it, so moving that anchor to a real admin + * changes nothing anyone can see. + * - **blocked** — anything else. + */ +export function classifyAccountDeletion(facts: AccountDeletionFacts): AccountDeletionPlan { + const blockers: AccountDeletionBlocker[] = [] + const workspacesToDelete: AccountDeletionResource[] = [] + const workspacesToTransfer: AccountDeletionResource[] = [] + const sharedWorkspaces: AccountDeletionResource[] = [] + const organizationWorkspaces: AccountDeletionResource[] = [] + + if (facts.paidOrganizationName) { + blockers.push({ + code: 'paid_organization_owner', + message: `You own ${facts.paidOrganizationName}. Transfer ownership to another member, or cancel the organization’s plan, before deleting your account.`, + }) + } else if (facts.organizationNames.length > 0) { + blockers.push({ + code: 'organization_member', + message: `Leave ${formatNames(facts.organizationNames)} before deleting your account, so your seat is released and your work is handed over.`, + }) + } + + if (facts.personalPlan) { + blockers.push({ + code: 'active_subscription', + message: `Your ${facts.personalPlan} plan is still active. Cancel it in Billing before deleting your account.`, + }) + } + + if (facts.hasDataDrains) { + blockers.push({ + code: 'data_drain_owner', + message: + 'You created one or more data drains that other people still depend on. Ask an organization admin to delete them before deleting your account.', + }) + } + + for (const workspace of facts.workspaces) { + const entry = facts.company.get(workspace.id) + const summary = { id: workspace.id, name: workspace.name } + + if (!entry?.hasOtherMembers) { + if (workspace.organizationId) organizationWorkspaces.push(summary) + else workspacesToDelete.push(summary) + } else if (!entry.isMember && entry.hasAdminSuccessor) { + workspacesToTransfer.push(summary) + } else { + sharedWorkspaces.push(summary) + } + } + + if (organizationWorkspaces.length > 0) { + const [belongs, theyAre, them] = + organizationWorkspaces.length === 1 + ? (['belongs', 'it is', 'it'] as const) + : (['belong', 'they are', 'them'] as const) + blockers.push({ + code: 'organization_workspace', + message: `${formatResourceNames(organizationWorkspaces)} ${belongs} to an organization, whose storage and billing ${theyAre} part of. Ask an organization admin to take ${them} over or delete ${them} before deleting your account.`, + }) + } + + if (sharedWorkspaces.length > 0) { + const them = sharedWorkspaces.length === 1 ? 'it' : 'them' + blockers.push({ + code: 'shared_workspace', + message: `Leave ${formatResourceNames(sharedWorkspaces)} — or remove everyone else from ${them} — before deleting your account, so nothing of yours that others rely on is deleted with you.`, + }) + } + + return { blockers, workspacesToDelete, workspacesToTransfer } +} + +function formatNames(names: string[]): string { + return formatQuotedNameList(names, MAX_NAMES_LISTED) +} + +function formatResourceNames(resources: AccountDeletionResource[]): string { + return formatNames(resources.map((resource) => resource.name)) +} + +/** Gathers the facts above and classifies them. */ +export async function getAccountDeletionPlan(userId: string): Promise { + const [workspaces, organizationNames, paidOrgCheck, personalSubscription, drains] = + await Promise.all([ + loadRelatedWorkspaces(userId), + loadOrganizationNames(userId), + isSoleOwnerOfPaidOrganization(userId), + getHighestPriorityPersonalSubscription(userId), + db + .select({ id: dataDrains.id }) + .from(dataDrains) + .where(eq(dataDrains.createdBy, userId)) + .limit(1), + ]) + + return classifyAccountDeletion({ + workspaces, + company: await loadWorkspaceCompany(userId, workspaces), + organizationNames, + paidOrganizationName: paidOrgCheck.isBlocker + ? (paidOrgCheck.organizationName ?? 'a paid organization') + : null, + personalPlan: personalSubscription?.plan ?? null, + hasDataDrains: drains.length > 0, + }) +} + +interface StorageKeyRow { + id: string + key: string | null + /** Set only by the multi-context table, whose rows carry their own context. */ + context?: string | null +} + +/** + * Walks a table in id order, handing each page to `handle`. + * + * Keyset paging rather than `OFFSET`: the caller deletes stored objects but + * leaves the rows in place for the cascade, so an offset would make Postgres + * re-scan and discard everything already visited on every page. + */ +async function forEachPage( + page: (afterId: string) => Promise, + handle: (rows: StorageKeyRow[]) => Promise +): Promise { + let afterId = '' + for (;;) { + const rows = await page(afterId) + if (rows.length === 0) return + await handle(rows) + if (rows.length < STORAGE_PAGE_SIZE) return + afterId = rows[rows.length - 1].id + } +} + +/** + * Erases one batch of stored objects. A storage failure is logged but never + * aborts the deletion: the account holder asked to be erased, and leaving their + * identity in place because an object store hiccuped would be the worse outcome. + * An orphaned object is recoverable from the log; a half-deleted account is not. + */ +async function eraseStorageKeys(context: StorageContext, keys: string[]): Promise { + if (keys.length === 0) return + try { + const { failed } = await StorageService.deleteFiles(keys, context) + for (const { key, error } of failed) { + logger.error('Failed to erase stored object during account deletion', { key, context, error }) + } + } catch (error) { + logger.error('Storage batch deletion failed during account deletion', { context, error }) + } +} + +function collectKeys(rows: StorageKeyRow[]): string[] { + const keys: string[] = [] + for (const row of rows) if (row.key) keys.push(row.key) + return keys +} + +/** + * Erases the stored objects held by workspaces that go with the account. + * + * This has to happen before the rows do. The rows disappear with the workspace + * through `ON DELETE CASCADE`, and the retention sweep that normally reclaims + * storage is driven entirely by those rows — once they are gone it has no way to + * find the objects. Each table is paged and erased a page at a time so an account + * with a very large library never materializes its whole key set. + * + * The tables are drained in sequence rather than concurrently to keep in-flight + * object-store deletions bounded to one page. + */ +async function purgeWorkspaceStorageObjects(workspaceIds: string[]): Promise { + if (workspaceIds.length === 0 || !isUsingCloudStorage()) return + + await forEachPage( + (afterId) => + db + .select({ id: workspaceFile.id, key: workspaceFile.key }) + .from(workspaceFile) + .where(and(inArray(workspaceFile.workspaceId, workspaceIds), gt(workspaceFile.id, afterId))) + .orderBy(workspaceFile.id) + .limit(STORAGE_PAGE_SIZE), + (rows) => eraseStorageKeys('workspace', collectKeys(rows)) + ) + + await forEachPage( + (afterId) => + db + .select({ + id: workspaceFiles.id, + key: workspaceFiles.key, + context: workspaceFiles.context, + }) + .from(workspaceFiles) + .where( + and(inArray(workspaceFiles.workspaceId, workspaceIds), gt(workspaceFiles.id, afterId)) + ) + .orderBy(workspaceFiles.id) + .limit(STORAGE_PAGE_SIZE), + async (rows) => { + const keysByContext = new Map() + for (const row of rows) { + if (!row.key) continue + const context = (row.context as StorageContext | null) ?? 'workspace' + const bucket = keysByContext.get(context) + if (bucket) bucket.push(row.key) + else keysByContext.set(context, [row.key]) + } + for (const [context, keys] of keysByContext) await eraseStorageKeys(context, keys) + } + ) + + await forEachPage( + (afterId) => + db + .select({ id: document.id, key: document.storageKey }) + .from(document) + .innerJoin(knowledgeBase, eq(knowledgeBase.id, document.knowledgeBaseId)) + .where( + and( + inArray(knowledgeBase.workspaceId, workspaceIds), + isNotNull(document.storageKey), + gt(document.id, afterId) + ) + ) + .orderBy(document.id) + .limit(STORAGE_PAGE_SIZE), + (rows) => eraseStorageKeys('knowledge-base', collectKeys(rows)) + ) +} + +/** + * Erases an account and everything only it can reach. + * + * The order is load-bearing. Postgres evaluates the `NO ACTION` check on + * `workspace.billed_account_user_id` *before* the `owner_id` cascade that would + * have removed the very same workspace, so a workspace the account bills for must + * be gone — or handed to someone else — before the `user` row is touched. Every + * remaining reference either cascades or is set to null by the schema. + * + * The plan is recomputed here rather than accepted from the caller: a preview is + * a display, never an authorization. + */ +export async function deleteUserAccount(userId: string): Promise { + const plan = await getAccountDeletionPlan(userId) + if (plan.blockers.length > 0) throw new AccountDeletionBlockedError(plan.blockers) + + const doomedWorkspaceIds = plan.workspacesToDelete.map((workspace) => workspace.id) + + await purgeWorkspaceStorageObjects(doomedWorkspaceIds) + + if (doomedWorkspaceIds.length > 0) { + await db.delete(workspaceTable).where(inArray(workspaceTable.id, doomedWorkspaceIds)) + } + + /** + * Sequential by necessity, not oversight: the billing pass reads `owner_id` + * while it still names the departing account, and the ownership pass reads the + * `billed_account_user_id` the billing pass has just rewritten. + */ + const { unresolved: billingUnresolved } = await reassignBilledAccountForUser(userId) + const { unresolved: ownershipUnresolved } = await reassignOwnedWorkspacesForUser(userId) + if (billingUnresolved.length > 0 || ownershipUnresolved.length > 0) { + throw new AccountDeletionBlockedError([ + { + code: 'shared_workspace', + message: + 'A workspace changed while your account was being deleted and can no longer be handed over. Try again.', + }, + ]) + } + + await db.delete(user).where(eq(user.id, userId)) + + logger.info('Deleted account', { + userId, + workspacesDeleted: doomedWorkspaceIds.length, + workspacesTransferred: plan.workspacesToTransfer.length, + }) + + return plan +} diff --git a/apps/sim/lib/users/application/delete-account.test.ts b/apps/sim/lib/users/application/delete-account.test.ts new file mode 100644 index 00000000000..4d17cc0893a --- /dev/null +++ b/apps/sim/lib/users/application/delete-account.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserProfile, mockDeleteUserAccount, mockGetAccountDeletionPlan } = vi.hoisted( + () => ({ + mockGetUserProfile: vi.fn(), + mockDeleteUserAccount: vi.fn(), + mockGetAccountDeletionPlan: vi.fn(), + }) +) + +vi.mock('@/lib/users/queries', () => ({ getUserProfile: mockGetUserProfile })) +vi.mock('@/lib/users/account-deletion', () => ({ + deleteUserAccount: mockDeleteUserAccount, + getAccountDeletionPlan: mockGetAccountDeletionPlan, +})) + +import { deleteAccountUseCase } from '@/lib/users/application/delete-account' + +const SESSION = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const +const EMPTY_PLAN = { blockers: [], workspacesToDelete: [], workspacesToTransfer: [] } + +describe('deleteAccountUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUserProfile.mockResolvedValue({ id: 'user-1', email: 'Ada@Example.com' }) + mockDeleteUserAccount.mockResolvedValue(EMPTY_PLAN) + }) + + it('deletes when the confirmation matches the account email, ignoring case and padding', async () => { + await deleteAccountUseCase.execute({ + principal: SESSION, + input: { confirmEmail: ' ada@example.com ' }, + }) + + expect(mockDeleteUserAccount).toHaveBeenCalledWith('user-1') + }) + + it('refuses a confirmation that names a different address', async () => { + await expect( + deleteAccountUseCase.execute({ + principal: SESSION, + input: { confirmEmail: 'someone-else@example.com' }, + }) + ).rejects.toThrow(/account email/i) + + expect(mockDeleteUserAccount).not.toHaveBeenCalled() + }) + + it('refuses any principal that is not a first-party session', async () => { + await expect( + deleteAccountUseCase.execute({ + principal: { kind: 'api-key', userId: 'user-1' } as never, + input: { confirmEmail: 'ada@example.com' }, + }) + ).rejects.toThrow(/session/i) + + expect(mockGetUserProfile).not.toHaveBeenCalled() + expect(mockDeleteUserAccount).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/users/application/delete-account.ts b/apps/sim/lib/users/application/delete-account.ts new file mode 100644 index 00000000000..54c4ccd0730 --- /dev/null +++ b/apps/sim/lib/users/application/delete-account.ts @@ -0,0 +1,90 @@ +import { AuditAction, AuditResourceType, recordAuditBatch } from '@sim/audit' +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { normalizeEmail } from '@sim/utils/string' +import type { AccountDeletionPlan } from '@/lib/api/contracts/user' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { deleteUserAccount, getAccountDeletionPlan } from '@/lib/users/account-deletion' +import { userAccountOperations } from '@/lib/users/application/operations' +import { getUserProfile } from '@/lib/users/queries' + +/** + * An account operation is only ever performed by the account itself, so every use + * case here starts by refusing anything that is not a first-party session — an API + * key or a delegated service must never be able to erase the human behind it. + * Defence in depth: the route's `internalSessionAuth` already returns nothing else. + */ +function requireSelf(principal: Principal): asserts principal is SessionPrincipal { + if (principal.kind !== 'session') { + throw new OrchestrationError('forbidden', 'Session authentication required') + } +} + +export const previewAccountDeletionUseCase: OperationUseCase< + typeof userAccountOperations.previewDeletion, + Record, + AccountDeletionPlan +> = { + operation: userAccountOperations.previewDeletion, + async execute({ principal }) { + requireSelf(principal) + return getAccountDeletionPlan(principal.userId) + }, +} + +export interface DeleteAccountInput { + /** The account's own email address, retyped. */ + confirmEmail: string +} + +export const deleteAccountUseCase: OperationUseCase< + typeof userAccountOperations.delete, + DeleteAccountInput, + AccountDeletionPlan +> = { + operation: userAccountOperations.delete, + async execute({ principal, input }) { + requireSelf(principal) + + const profile = await getUserProfile(principal.userId) + if (!profile) throw new OrchestrationError('not_found', 'Account not found') + + /** + * Normalized on both sides because the confirmation exists to prove intent, + * not to test typing — but it is still compared against the account's own + * address, so a mis-wired client cannot delete somebody else's account. + */ + if (normalizeEmail(input.confirmEmail) !== normalizeEmail(profile.email)) { + throw new OrchestrationError( + 'validation', + 'Enter your account email exactly as it appears above to confirm.' + ) + } + + const plan = await deleteUserAccount(principal.userId) + + /** + * The compliance record deliberately carries no actor identity: the person it + * would name has just exercised their right to erasure. `recordAuditBatch` + * inserts exactly what it is given, unlike `recordAudit`, whose lazy actor + * lookup would race the row that no longer exists. + */ + recordAuditBatch([ + { + workspaceId: null, + actorId: null, + action: AuditAction.ACCOUNT_DELETED, + resourceType: AuditResourceType.ACCOUNT, + resourceId: principal.userId, + description: 'Account deleted at the account holder’s request', + metadata: { + operation: userAccountOperations.delete.id, + workspacesDeleted: plan.workspacesToDelete.length, + workspacesTransferred: plan.workspacesToTransfer.length, + }, + }, + ]) + + return plan + }, +} diff --git a/apps/sim/lib/users/application/operations.ts b/apps/sim/lib/users/application/operations.ts new file mode 100644 index 00000000000..145f2c9bfa3 --- /dev/null +++ b/apps/sim/lib/users/application/operations.ts @@ -0,0 +1,13 @@ +import type { ApplicationOperation } from '@/lib/core/application' + +/** + * Operations an account performs on itself. They carry no workspace scope and no + * role: the resource *is* the authenticated principal, so a session is both the + * only acceptable credential and the whole authorization story. That policy is + * enforced where it can actually hold — `internalSessionAuth` on the route and + * the principal guard in each use case — rather than restated as inert data here. + */ +export const userAccountOperations = { + previewDeletion: { id: 'users.account.deletion_preview' }, + delete: { id: 'users.account.delete' }, +} as const satisfies Record diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index d82dc76bbaf..91ed561d91b 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -2,6 +2,9 @@ * All auditable actions in the platform, grouped by resource type. */ export const AuditAction = { + // Accounts + ACCOUNT_DELETED: 'account.deleted', + // API Keys API_KEY_CREATED: 'api_key.created', API_KEY_UPDATED: 'api_key.updated', @@ -220,6 +223,7 @@ export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction] * All resource types that can appear in audit log entries. */ export const AuditResourceType = { + ACCOUNT: 'account', API_KEY: 'api_key', BILLING: 'billing', BYOK_KEY: 'byok_key', diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx index 4909e62009b..95eead03fbd 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -1274,6 +1274,12 @@ export interface ChipConfirmAction { pendingLabel?: string /** Additional disable condition independent of `pending` (e.g. an unmet "type to confirm"). */ disabled?: boolean + /** + * Explains why the confirm is unavailable — shown in a tooltip on hover/focus + * while `disabled` is true, so a blocked confirmation states its own remedy + * instead of looking inert. Ignored while the action is enabled or `pending`. + */ + disabledTooltip?: string } /** @@ -1401,6 +1407,34 @@ export interface ChipConfirmModalProps { srTitle?: string } +/** + * The confirm chip, wrapped in a tooltip only when it is disabled and the action + * explained why. A disabled `