feat: account deletion for Dokploy Cloud - #5463
Siumauricio wants to merge 2 commits into
Conversation
Self-service deletion from Settings > Profile confirmed with the account password (when present) plus a 6-digit code sent by email, and a root-only "delete by email" tool in the impersonation bar to fulfil GDPR requests. Both paths run the same flow: cancel every Stripe subscription (customer is kept for invoicing), delete the user and all owned organizations in one transaction, anonymize audit trail left in other organizations, reassign pending invitations to the org owner, remove the HubSpot contact when HUBSPOT_ACCESS_TOKEN is configured, and log a JSON deletion record. Also blocks better-auth's raw /admin/remove-user endpoint and makes the Stripe webhook tolerate subscription/customer events for already deleted users.
| const result = checkDeletionCode(row.value, code); | ||
| if (!result.valid) { | ||
| if (result.attempts >= ACCOUNT_DELETION_CODE_MAX_ATTEMPTS) { | ||
| await db.delete(verification).where(eq(verification.id, row.id)); | ||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: "Too many incorrect attempts, request a new code", | ||
| }); | ||
| } | ||
| await db | ||
| .update(verification) | ||
| .set({ value: result.nextValue, updatedAt: new Date() }) | ||
| .where(eq(verification.id, row.id)); |
There was a problem hiding this comment.
Concurrent incorrect-code submissions can read the same attempt count and overwrite the row with the same increment. An authenticated caller can therefore make more than the intended five guesses without invalidating the account-deletion code. Make the increment and invalidation atomic, such as with a transaction and row lock or a conditional update.
How this was verified: The protected mutation performs an unlocked read followed by an unconditional update, and the verification identifier has no uniqueness or request-serialization guarantee.
| .set({ userId: null }) | ||
| .where(eq(backups.userId, userId)); | ||
|
|
||
| await tx.delete(user).where(eq(user.id, userId)); |
There was a problem hiding this comment.
Deleting a user cascades away invitations they sent because
invitation.inviterId uses ON DELETE CASCADE. This transaction never performs the promised reassignment of pending invitations to each organization's owner, so recipients lose valid outstanding invitations when their inviter deletes their account.
| const { organizationsDeleted } = await deleteUserAccountData(userId); | ||
|
|
||
| const hubspot = IS_CLOUD | ||
| ? await deleteHubSpotContactByEmail(target.email) | ||
| : "skipped"; |
There was a problem hiding this comment.
A HubSpot network or API failure becomes the
"failed" status after local data has already been deleted, but the mutation still resolves successfully and creates no retry or durable remediation. The self-service UI consequently tells the user deletion succeeded while their HubSpot contact may remain, leaving the erasure request incomplete.
| const subscriptions = await stripe.subscriptions | ||
| .list({ customer: stripeCustomerId, status: "all", limit: 100 }) |
There was a problem hiding this comment.
The Stripe query processes only the first 100 subscriptions and never follows pagination. The checkout flow permits repeated subscription creation for an existing customer, so a customer with more than 100 historical subscriptions can have a non-terminal subscription on a later page that remains active and billable after account deletion.
| const record = { | ||
| userId, | ||
| email: target.email, | ||
| requestedBy, | ||
| requestedAt: options?.requestedAt?.toISOString() ?? startedAt, | ||
| startedAt, | ||
| completedAt: new Date().toISOString(), | ||
| organizationsDeleted, | ||
| stripe, | ||
| hubspot, | ||
| }; | ||
|
|
||
| return record; |
There was a problem hiding this comment.
The detailed deletion record is only returned in memory and is neither logged nor persisted. The self-service caller discards it, while the pre-deletion audit entry lacks completion, organization, Stripe, and HubSpot outcomes. The new flow therefore does not retain the promised evidence that erasure actually completed.
| const code = await createAccountDeletionCode(ctx.user.id); | ||
| try { | ||
| await sendAccountDeletionCodeEmail({ | ||
| email: ctx.user.email, | ||
| userName: ctx.user.name || ctx.user.email, | ||
| code, | ||
| expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES, | ||
| }); | ||
| } catch (error) { | ||
| console.error(error); | ||
| throw new TRPCError({ | ||
| code: "INTERNAL_SERVER_ERROR", | ||
| message: "The confirmation email could not be sent", | ||
| }); |
There was a problem hiding this comment.
The code is stored before the email is sent. If SMTP delivery fails, the request reports an error but an immediate retry encounters the 60-second resend cooldown, forcing the user to wait even though no confirmation code was delivered. Delete the unusable row on delivery failure or begin the cooldown only after successful delivery.
Adds a complete account deletion flow so GDPR/CCPA erasure requests can be fulfilled (and evidenced) instead of handled by hand.
Self-service (Settings → Profile)
verificationtable, expires in 10 minutes, 60s resend cooldown, 5 wrong attempts invalidate it.Root tool (impersonation bar)
USER_ADMIN_ID, cloud only. Shows the resulting deletion record as JSON with a copy button, meant as evidence for the request.What deletion does
invoice_now: false,prorate: false). The Stripe customer is kept so invoices remain for accounting.backups.userId(FK has noON DELETE).HUBSPOT_ACCESS_TOKENis set, otherwiseskipped.[account-deletion] {...}withrequestedAt,startedAt,completedAt, organizations deleted, Stripe and HubSpot outcome.Other changes
hooks.beforereturns 403 for/admin/remove-userso users can't be removed bypassing this flow.customer.subscription.deleted/customer.deletedwhen the user is already gone.account-deletion-code.tsx.This PR is not safe to merge until confirmation-code consumption is atomic and the deletion workflow reliably completes, records, or remediates every promised cleanup action.
Summary
Reviews (1) · Last reviewed commit: "feat: account deletion for Dokploy Cloud"