mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
feat(account): let users delete their own account (#6831)
* feat(account): let users delete their own account Adds a GDPR self-serve account deletion path: a preflight that reports what deletion would remove and every reason it would be refused, and a confirmed delete that erases the account and everything only it can reach. Deletion refuses while the account is still entangled rather than reassigning its content. Most tables reference user.id with ON DELETE CASCADE, and those cascades do not distinguish content in the account's own workspace from content it created inside somebody else's, so each blocker names the existing action that untangles it (leave the workspace, leave the organization, cancel the plan) — all of which already hand work over on their own tested paths. * fix(account): make deletion atomic and re-check privacy at delete time Reorders the teardown so nothing irreversible happens before the deletion is certain: anchors are handed over first (the fallible step, while everything is still recoverable), the workspace and user deletes now share one transaction, and the object-storage purge runs only after that commits. The workspace delete also re-checks inside the transaction that each workspace is still private, so a membership granted between the preview and the delete aborts the whole thing instead of destroying the new member's access. * fix(account): close deletion gaps found in review - Run the whole teardown in one transaction. The billing and ownership handovers now take the caller's transaction, so a refused deletion can no longer leave a workspace reassigned for a deletion that never happened. - Fail closed on a subscription read error. getHighestPriorityPersonalSubscription defaulted to returning null, which read as "no plan" and would have erased an account Stripe was still billing. - Erase the account's profile picture. It is personal data under our own storage prefix; an external provider avatar is left alone. - Enforce the storage purge cap while collecting keys rather than after, so an oversized account cannot exhaust memory before the cap applies.
This commit is contained in:
@@ -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 }),
|
||||
})
|
||||
+160
@@ -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 (
|
||||
<ChipConfirmModal
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
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 ? (
|
||||
<div className='flex flex-col gap-2 px-2'>
|
||||
<p className='text-[var(--text-primary)] text-sm'>Your account can’t be deleted yet:</p>
|
||||
<ul className='flex list-disc flex-col gap-1 pl-4'>
|
||||
{blockers.map((blocker) => (
|
||||
<li key={blocker.code} className='text-[var(--text-secondary)] text-sm'>
|
||||
{blocker.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-2 px-2'>
|
||||
<p className='text-[var(--text-primary)] text-sm'>
|
||||
This permanently deletes <span className='font-medium'>{email}</span> along with its
|
||||
workflows, chats, files, knowledge bases and credentials.{' '}
|
||||
<span className='text-[var(--text-error)]'>This cannot be undone.</span>
|
||||
</p>
|
||||
{toDelete.length > 0 && (
|
||||
<p className='text-[var(--text-secondary)] text-sm'>
|
||||
{toDelete.length === 1 ? 'The workspace ' : 'The workspaces '}
|
||||
<span className='text-[var(--text-primary)]'>{names(toDelete)}</span> and everything
|
||||
in {toDelete.length === 1 ? 'it' : 'them'} will be deleted.
|
||||
</p>
|
||||
)}
|
||||
{toTransfer.length > 0 && (
|
||||
<p className='text-[var(--text-secondary)] text-sm'>
|
||||
Billing for <span className='text-[var(--text-primary)]'>{names(toTransfer)}</span>{' '}
|
||||
moves to another admin. Nothing in {toTransfer.length === 1 ? 'it' : 'them'} changes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isBlocked && (
|
||||
<ChipModalField
|
||||
type='email'
|
||||
title='Confirm your email'
|
||||
value={confirmEmail}
|
||||
onChange={setConfirmEmail}
|
||||
placeholder={email}
|
||||
autoComplete='off'
|
||||
disabled={isPending || isPlanFetching}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<ChipModalError>{errorMessage}</ChipModalError>
|
||||
</ChipConfirmModal>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
|
||||
const snapToGridValue = settings?.snapToGridSize ?? 0
|
||||
@@ -572,6 +576,21 @@ export function General() {
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{!isAuthDisabled && (
|
||||
<SettingsSection label='Account'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label>Delete account</Label>
|
||||
<Chip onClick={() => setShowDeleteAccountModal(true)}>Delete</Chip>
|
||||
</div>
|
||||
<p className='text-[var(--text-muted)] text-small'>
|
||||
Permanently deletes your account and everything only you can reach — workflows,
|
||||
chats, files, knowledge bases and credentials. This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsPanel>
|
||||
|
||||
<ChipModal
|
||||
@@ -604,6 +623,12 @@ export function General() {
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
|
||||
<DeleteAccountModal
|
||||
open={showDeleteAccountModal}
|
||||
onOpenChange={setShowDeleteAccountModal}
|
||||
email={profile?.email || ''}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<AccountDeletionPlan> {
|
||||
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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<typeof accountDeletionResourceSchema>
|
||||
|
||||
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<typeof accountDeletionBlockerSchema>
|
||||
export type AccountDeletionPlan = z.output<typeof accountDeletionPlanSchema>
|
||||
|
||||
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<typeof deleteAccountBodySchema>
|
||||
|
||||
export const deleteAccountContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/users/me/deletion',
|
||||
body: deleteAccountBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
+10
-32
@@ -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')
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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> = {}): WorkspaceRow {
|
||||
return { id: 'ws-1', name: 'My workspace', organizationId: null, ...overrides }
|
||||
}
|
||||
|
||||
function company(overrides: Partial<WorkspaceCompany> = {}): WorkspaceCompany {
|
||||
return { hasOtherMembers: false, isMember: true, hasAdminSuccessor: false, ...overrides }
|
||||
}
|
||||
|
||||
function facts(overrides: Partial<AccountDeletionFacts> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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<WorkspaceRow[]> {
|
||||
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<string, WorkspaceRow>()
|
||||
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<Map<string, WorkspaceCompany>> {
|
||||
const company = new Map<string, WorkspaceCompany>()
|
||||
if (workspaces.length === 0) return company
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
entityId: permissions.entityId,
|
||||
isMember: sql<boolean>`bool_or(${permissions.userId} = ${userId})`,
|
||||
hasOtherMembers: sql<boolean>`bool_or(${permissions.userId} <> ${userId})`,
|
||||
hasAdminSuccessor: sql<boolean>`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<string[]> {
|
||||
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<string, WorkspaceCompany>
|
||||
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<AccountDeletionPlan> {
|
||||
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<StorageKeyRow[]>,
|
||||
into: StorageKeyBatch[],
|
||||
contextFor: (row: StorageKeyRow) => StorageContext
|
||||
): Promise<void> {
|
||||
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<StorageContext, string[]>()
|
||||
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<StorageKeyBatch[]> {
|
||||
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<void> {
|
||||
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<AccountDeletionPlan> {
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, never>,
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -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<string, ApplicationOperation>
|
||||
@@ -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<ReassignBilledAccountResult> {
|
||||
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<ReassignOwnedWorkspacesResult> {
|
||||
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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 `<button>` is not a hit-test target, so the wrapping
|
||||
* span carries `pointer-events-none` off the chip for the tooltip to fire.
|
||||
*/
|
||||
function renderChipConfirmButton(confirm: ChipConfirmAction, confirmLabel: string) {
|
||||
const disabled = Boolean(confirm.disabled || confirm.pending)
|
||||
const chip = (
|
||||
<Chip
|
||||
variant={confirm.variant ?? 'destructive'}
|
||||
onClick={confirm.onClick}
|
||||
disabled={disabled}
|
||||
className={cn(confirm.disabledTooltip && disabled && 'pointer-events-none')}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Chip>
|
||||
)
|
||||
if (!confirm.disabledTooltip || !disabled) return chip
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='inline-flex'>{chip}</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{confirm.disabledTooltip}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "are you sure?" confirmation dialog. Models the confirmation button
|
||||
* grammar directly — a named dismiss decision plus a (usually destructive)
|
||||
@@ -1466,13 +1500,7 @@ function ChipConfirmModal({
|
||||
<Chip onClick={dismiss} disabled={confirm.pending}>
|
||||
{dismissLabel}
|
||||
</Chip>
|
||||
<Chip
|
||||
variant={confirm.variant ?? 'destructive'}
|
||||
onClick={confirm.onClick}
|
||||
disabled={confirm.disabled || confirm.pending}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Chip>
|
||||
{renderChipConfirmButton(confirm, confirmLabel)}
|
||||
</ChipModalFooterShell>
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
@@ -190,8 +190,10 @@ export const auditMock = {
|
||||
TABLE_EXPORTED: 'table.exported',
|
||||
WORKFLOW_PUBLIC_API_TOGGLED: 'workflow.public_api_toggled',
|
||||
WORKFLOW_EXPORTED: 'workflow.exported',
|
||||
ACCOUNT_DELETED: 'account.deleted',
|
||||
},
|
||||
AuditResourceType: {
|
||||
ACCOUNT: 'account',
|
||||
API_KEY: 'api_key',
|
||||
BILLING: 'billing',
|
||||
BYOK_KEY: 'byok_key',
|
||||
|
||||
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
||||
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
||||
|
||||
const BASELINE = {
|
||||
totalRoutes: 1121,
|
||||
zodRoutes: 1121,
|
||||
totalRoutes: 1122,
|
||||
zodRoutes: 1122,
|
||||
nonZodRoutes: 0,
|
||||
} as const
|
||||
|
||||
|
||||
Reference in New Issue
Block a user