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:

+ +
+ ) : ( +
+

+ 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..b9a06c66eec --- /dev/null +++ b/apps/sim/lib/users/account-deletion.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + AccountDeletionBlockedError, + type AccountDeletionFacts, + classifyAccountDeletion, + extractProfilePictureKey, + 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') + }) +}) + +describe('AccountDeletionBlockedError', () => { + it('classifies itself as a conflict so the route renders a refusal as 409, not 500', () => { + const error = new AccountDeletionBlockedError([ + { code: 'active_subscription', message: 'Your pro plan is still active.' }, + ]) + + expect(error.code).toBe('conflict') + expect(error.message).toBe('Your pro plan is still active.') + }) + + it('still carries a message when constructed with no blockers', () => { + expect(new AccountDeletionBlockedError([]).message).toMatch(/cannot be deleted/i) + }) +}) + +describe('extractProfilePictureKey', () => { + it('extracts the storage key from an uploaded avatar path', () => { + expect(extractProfilePictureKey('/api/files/serve/profile-pictures%2Fu1%2Favatar.png')).toBe( + 'profile-pictures/u1/avatar.png' + ) + }) + + it('strips the storage-provider segment', () => { + expect(extractProfilePictureKey('/api/files/serve/s3/profile-pictures%2Fu1%2Fa.png')).toBe( + 'profile-pictures/u1/a.png' + ) + }) + + it('ignores an external avatar, which is the provider’s object and not ours to delete', () => { + expect(extractProfilePictureKey('https://lh3.googleusercontent.com/a/abc123')).toBeNull() + }) + + it('ignores a served key outside the profile-pictures prefix', () => { + expect(extractProfilePictureKey('/api/files/serve/workspace%2Fw1%2Freport.pdf')).toBeNull() + }) + + it('handles an account with no picture', () => { + expect(extractProfilePictureKey(null)).toBeNull() + expect(extractProfilePictureKey('')).toBeNull() + }) +}) diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts new file mode 100644 index 00000000000..98c063edc0f --- /dev/null +++ b/apps/sim/lib/users/account-deletion.ts @@ -0,0 +1,619 @@ +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, ne, notExists, 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 + +/** + * Upper bound on stored-object keys held in memory between the pre-commit + * collection and the post-commit purge. Beyond this the remainder is left + * orphaned and logged — leaking objects beats exhausting the process mid-erasure. + */ +const MAX_PURGE_KEYS = 100_000 + +/** 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), + /** + * `onError: 'throw'` rather than the default `'return-null'`: a failed + * subscription read would otherwise read as "no plan", skipping the + * blocker and erasing an account Stripe is still billing. + */ + getHighestPriorityPersonalSubscription(userId, { onError: 'throw' }), + 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 +} + +/** One page of keys destined for a single storage context. */ +interface StorageKeyBatch { + context: StorageContext + keys: string[] +} + +/** + * Walks a table in id order, collecting each page's keys. + * + * Keyset paging rather than `OFFSET`, so Postgres never re-scans and discards + * everything already visited on every subsequent page. + */ +async function collectPages( + page: (afterId: string) => Promise, + into: StorageKeyBatch[], + contextFor: (row: StorageKeyRow) => StorageContext +): Promise { + let afterId = '' + for (;;) { + if (countKeys(into) >= MAX_PURGE_KEYS) { + logger.error('Account deletion hit the storage purge cap; the remainder is orphaned', { + cap: MAX_PURGE_KEYS, + }) + return + } + + const rows = await page(afterId) + if (rows.length === 0) return + + const keysByContext = new Map() + for (const row of rows) { + if (!row.key) continue + const context = contextFor(row) + const bucket = keysByContext.get(context) + if (bucket) bucket.push(row.key) + else keysByContext.set(context, [row.key]) + } + for (const [context, keys] of keysByContext) into.push({ context, keys }) + + if (rows.length < STORAGE_PAGE_SIZE) return + afterId = rows[rows.length - 1].id + } +} + +function countKeys(batches: StorageKeyBatch[]): number { + let total = 0 + for (const batch of batches) total += batch.keys.length + return total +} + +/** + * The account's own uploaded avatar, as a storage key. + * + * `user.image` holds either a `/api/files/serve/...` path (an upload we own) or + * an absolute URL from an OAuth provider (which we must not try to delete). Only + * the former yields a key, and only under the `profile-pictures/` prefix — the + * image is personal data, so an erasure that leaves it in the bucket is not an + * erasure. + */ +export function extractProfilePictureKey(image: string | null): string | null { + if (!image) return null + try { + const parsed = new URL(image, 'http://placeholder') + if (parsed.origin !== 'http://placeholder') return null + const segments = parsed.pathname.split('/') + if (segments[1] !== 'api' || segments[2] !== 'files' || segments[3] !== 'serve') return null + let keySegments = segments.slice(4) + if (['s3', 'blob', 'gcs'].includes(keySegments[0])) keySegments = keySegments.slice(1) + const key = decodeURIComponent(keySegments.join('/')) + return key.startsWith('profile-pictures/') ? key : null + } catch { + return null + } +} + +/** + * Collects every stored object held by workspaces that go with the account. + * + * This has to run *before* the rows are deleted: they 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. The keys are held in memory only between this call + * and the purge that follows the commit; collection stops at `MAX_PURGE_KEYS`, so + * an oversized account leaks objects rather than exhausting the process. + */ +async function collectAccountStorageKeys( + userId: string, + workspaceIds: string[] +): Promise { + const batches: StorageKeyBatch[] = [] + if (!isUsingCloudStorage()) return batches + + const [profile] = await db + .select({ image: user.image }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + const profilePictureKey = extractProfilePictureKey(profile?.image ?? null) + if (profilePictureKey) { + batches.push({ context: 'profile-pictures', keys: [profilePictureKey] }) + } + + if (workspaceIds.length === 0) return batches + + await collectPages( + (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), + batches, + () => 'workspace' + ) + + await collectPages( + (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), + batches, + (row) => (row.context as StorageContext | null) ?? 'workspace' + ) + + await collectPages( + (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), + batches, + () => 'knowledge-base' + ) + + return batches +} + +/** + * Erases stored objects. A storage failure is logged but never rethrown: by the + * time this runs the account is already gone, and the request must not report a + * failure for work that cannot be undone. An orphaned object is recoverable from + * the log; a deletion the caller believes failed is not. + */ +async function purgeStorageObjects(batches: StorageKeyBatch[]): Promise { + for (const { context, keys } of batches) { + if (keys.length === 0) continue + 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 }) + } + } +} + +/** + * Erases an account and everything only it can reach. + * + * Sequenced so that nothing irreversible happens until the deletion is certain: + * + * 1. **Collect the storage keys.** The rows that name them cascade away with the + * workspace, 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, so the keys must be read while they still exist. Reading is + * harmless if the deletion is later refused. + * 2. **Do the whole teardown in one transaction** — the billing and ownership + * handovers, the workspace deletes, and the `user` delete. Any failure rolls + * all of it back, so a refused deletion can never leave a workspace + * reassigned or removed. Workspaces on their way out are expected to come + * back unresolved from the handovers and are filtered against the doomed set + * rather than treated as failures. + * 3. **Purge storage last**, once that transaction has committed. Object + * deletion cannot be rolled back, so it must not precede the point of no + * return. + * + * The ordering inside step 2 is load-bearing too: 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 handed over or gone before the `user` row is touched. + * + * 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) + const doomed = new Set(doomedWorkspaceIds) + const storageKeys = await collectAccountStorageKeys(userId, doomedWorkspaceIds) + + await db.transaction(async (tx) => { + if (doomedWorkspaceIds.length > 0) { + /** + * Re-checked here rather than trusted from the plan: a workspace that + * gained a member since the preview is no longer private, and deleting it + * would destroy somebody else's work. The guard makes the delete a no-op + * for that row, and the short count aborts the transaction — including the + * handovers below — so the account survives to be re-previewed. + */ + const deleted = await tx + .delete(workspaceTable) + .where( + and( + inArray(workspaceTable.id, doomedWorkspaceIds), + notExists( + tx + .select({ one: sql`1` }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceTable.id), + ne(permissions.userId, userId) + ) + ) + ) + ) + ) + .returning({ id: workspaceTable.id }) + + if (deleted.length !== doomedWorkspaceIds.length) { + throw new AccountDeletionBlockedError([ + { + code: 'shared_workspace', + message: + 'Someone was given access to one of your workspaces while your account was being deleted. Nothing was changed — reopen this dialog to see the difference.', + }, + ]) + } + } + + /** + * 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, tx) + const { unresolved: ownershipUnresolved } = await reassignOwnedWorkspacesForUser(userId, tx) + const stranded = [...billingUnresolved, ...ownershipUnresolved].filter((id) => !doomed.has(id)) + if (stranded.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. Nothing was changed — try again.', + }, + ]) + } + + await tx.delete(user).where(eq(user.id, userId)) + }) + + await purgeStorageObjects(storageKeys) + + 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/apps/sim/lib/workspaces/utils.ts b/apps/sim/lib/workspaces/utils.ts index 05dd7a62466..545542ba105 100644 --- a/apps/sim/lib/workspaces/utils.ts +++ b/apps/sim/lib/workspaces/utils.ts @@ -360,11 +360,18 @@ export async function reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({ * * Returns the list of workspaces that could not be reassigned (no owner + no admin). Callers should * block user deletion when `unresolved.length > 0` so we never leave an orphaned billing reference. + * + * Pass `executor` to enroll the handover in a caller-owned transaction — account + * deletion does, so a later failure in the same transaction rolls the transfers + * back instead of leaving a workspace reassigned for a deletion that never + * happened. The per-workspace `transaction` call below becomes a savepoint in + * that case, preserving the payer-ledger atomicity it exists for. */ export async function reassignBilledAccountForUser( - departingUserId: string + departingUserId: string, + executor: DbOrTx = db ): Promise { - const billedWorkspaces = await db + const billedWorkspaces = await executor .select({ id: workspaceTable.id, ownerId: workspaceTable.ownerId, @@ -384,7 +391,7 @@ export async function reassignBilledAccountForUser( let replacement: string | null = ws.ownerId !== departingUserId ? ws.ownerId : null if (!replacement) { - const [admin] = await db + const [admin] = await executor .select({ userId: permissions.userId }) .from(permissions) .where( @@ -405,7 +412,7 @@ export async function reassignBilledAccountForUser( continue } - await db.transaction(async (tx) => { + await executor.transaction(async (tx) => { await changeWorkspaceStoragePayerInTx(tx, { workspaceId: ws.id, organizationId: ws.organizationId, @@ -452,11 +459,16 @@ export interface ReassignOwnedWorkspacesResult { * Returns workspaces that could not be reassigned (no distinct billed account and * no other admin). Callers MUST block user deletion when `unresolved.length > 0` * so the cascade can never nuke a workspace. + * + * Takes the same optional `executor` as {@link reassignBilledAccountForUser}, for + * the same reason: account deletion runs both inside one transaction so a partial + * teardown cannot leave ownership transferred for a deletion that was refused. */ export async function reassignOwnedWorkspacesForUser( - departingUserId: string + departingUserId: string, + executor: DbOrTx = db ): Promise { - const ownedWorkspaces = await db + const ownedWorkspaces = await executor .select({ id: workspaceTable.id, billedAccountUserId: workspaceTable.billedAccountUserId, @@ -476,7 +488,7 @@ export async function reassignOwnedWorkspacesForUser( ws.billedAccountUserId !== departingUserId ? ws.billedAccountUserId : null if (!replacement) { - const [admin] = await db + const [admin] = await executor .select({ userId: permissions.userId }) .from(permissions) .where( @@ -498,13 +510,13 @@ export async function reassignOwnedWorkspacesForUser( } const now = new Date() - await db + await executor .update(workspaceTable) .set({ ownerId: replacement, updatedAt: now }) .where(eq(workspaceTable.id, ws.id)) // Owners are admins — guarantee the new owner holds an admin permission row. - await db + await executor .insert(permissions) .values({ id: generateId(), 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 `