Skip to content

feat: account deletion for Dokploy Cloud - #5463

Open
Siumauricio wants to merge 2 commits into
canaryfrom
feat/self-delete-account
Open

Siumauricio wants to merge 2 commits into
canaryfrom
feat/self-delete-account

Conversation

@Siumauricio

@Siumauricio Siumauricio commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

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)

  • New Delete account card. If the account has a password it is required; then a 6-digit code is emailed and must be entered to confirm.
  • Code is stored hashed (SHA-256) in better-auth's verification table, expires in 10 minutes, 60s resend cooldown, 5 wrong attempts invalidate it.
  • Cloud only and only for organization owners (the card is hidden otherwise and the server enforces it). Blocked while impersonating.

Root tool (impersonation bar)

  • Delete account by email for 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

  • Cancels every non-terminal Stripe subscription (invoice_now: false, prorate: false). The Stripe customer is kept so invoices remain for accounting.
  • In one transaction: deletes the user and every organization they own (cascade), deletes audit logs of those orgs, anonymizes the user's email in audit logs of other orgs, deletes invitations addressed to them, reassigns pending invitations they sent to the org owner, nulls backups.userId (FK has no ON DELETE).
  • In organizations the user only belongs to: membership, API keys and their Git providers are removed (apps keep running, provider FK set to null); org resources stay.
  • Removes the HubSpot contact via the GDPR endpoint when HUBSPOT_ACCESS_TOKEN is set, otherwise skipped.
  • Logs [account-deletion] {...} with requestedAt, startedAt, completedAt, organizations deleted, Stripe and HubSpot outcome.

Other changes

  • better-auth hooks.before returns 403 for /admin/remove-user so users can't be removed bypassing this flow.
  • Stripe webhook no longer answers 400 on customer.subscription.deleted / customer.deleted when the user is already gone.
  • New email template account-deletion-code.tsx.
  • Unit tests for the Stripe cancellation, HubSpot deletion and code hashing/attempt counting.

RetriggerConfidence Score: 0/5

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

  • Introduces hashed six-digit deletion codes with expiry, resend cooldown, and attempt tracking.
  • Deletes owned organizations and user-linked data while anonymizing retained audit records.
  • Cancels Stripe subscriptions and requests HubSpot GDPR contact deletion.
  • Adds profile and impersonation-bar deletion interfaces plus a confirmation email template.
  • The current implementation has correctness gaps around atomic confirmation, invitation reassignment, external cleanup, subscription pagination, and durable evidence.

Reviews (1) · Last reviewed commit: "feat: account deletion for Dokploy Cloud"

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.
Comment on lines +106 to +118
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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +25 to +29
const { organizationsDeleted } = await deleteUserAccountData(userId);

const hubspot = IS_CLOUD
? await deleteHubSpotContactByEmail(target.email)
: "skipped";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +164 to +165
const subscriptions = await stripe.subscriptions
.list({ customer: stripeCustomerId, status: "all", limit: 100 })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +31 to +43
const record = {
userId,
email: target.email,
requestedBy,
requestedAt: options?.requestedAt?.toISOString() ?? startedAt,
startedAt,
completedAt: new Date().toISOString(),
organizationsDeleted,
stripe,
hubspot,
};

return record;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +516 to +529
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",
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant