feat: self-serve account deletion by email link + keep shared data when a user is deleted

Merged
EV
17d ago
+5669-61

Why

An account deletion request for app.gauzy.co had to be done by hand in the production database. Users need a self-serve way to delete their account and data, including when they cannot sign in, and the existing in-app "Delete account" had bugs (one of them destroys other members' data).

What changes

1. Email-link account deletion (new, public, no sign-in)

POST /api/account-deletion/request → email with a link → /#/auth/delete-account/confirm?token=… → POST /api/account-deletion/verify (preview) → POST /api/account-deletion/confirm (delete).

RequirementHow
High-entropy token, only its hash stored32 random bytes (base64url); account_deletion_request.tokenHash = SHA-256
Single useone conditional UPDATE … SET usedAt WHERE tokenHash AND usedAt IS NULL AND revokedAt IS NULL AND expiresAt > now; affected must be 1
Short expiry30 min (ACCOUNT_DELETION_TOKEN_TTL_MINUTES, clamped 5–60); a new request revokes older links
Scoped to deletion onlyopaque token, not a JWT; only these 3 routes read it; no session is created
No account enumerationrequest always answers 202 {"status":"accepted"} and returns before the lookup / email run
Rate limitsper IP: @Throttle 5/min (request, confirm), 10/min (verify); per address: 3 emails/hour (DB count, cross-pod)
Confirm by POSTopening the link deletes nothing; confirm needs {token, confirm: true} + UI checkbox and typed DELETE
Token out of logs / referrerstoken only in the URL fragment; page strips it from the address bar at once and sets no-referrer; deletion email not copied to email_history, sent with link tracking off; Sentry + PostHog redact token/code URL params; logs carry a 12-char email-hash prefix, never the token or address
Audit logthe request row stays: hashed email, hashed IP, timestamps, result counts; clear address nulled on completion
Confirmation emailaccount-deletion-completed template after deletion
Several tenants / ownercovers every account (tenant) of the address: a workspace nobody else uses is deleted whole; in a shared one only the user's account goes; verify flags soleAdmin
Link targetcaller's callbackUrl only when its origin passes EMAIL_LINK_ALLOWED_ORIGINS (existing allowlist); else ACCOUNT_DELETION_CONFIRM_URL or ${CLIENT_BASE_URL}/#/auth/delete-account/confirm

1b. Signed-in deletion for the in-app Danger Zones: GET|POST /api/account-deletion/me

ACCESS_DELETE_ACCOUNT, first-party only, { "confirm": true }. Same rules as the link, for the current account only: a workspace nobody else uses goes whole (before, "Delete account" left it and all its data behind), otherwise the account only. Same audit row and confirmation email. GET previews it so the dialog says what will happen. Gauzy's Danger Zone and Ever Teams' "Delete all data" (which called no API at all) use it; DELETE /api/user/:id stays for admins removing other users.

1c. Stored files and leftovers

  • The DB cascade runs no entity subscriber, so screenshots and profile pictures stayed in object storage. They are now collected before the delete and removed after the commit (whole workspace: every screenshot and image of the tenant). The profile picture row goes with the account.
  • An unused request (expired or replaced) keeps only the email hash: the clear address is cleared on the next request.
  • An unknown locale (e.g. pt-BR forwarded by ever.co) is ignored instead of failing validation.

2. Deleting a user no longer deletes other members' data

createdByUserId / updatedByUserId / deletedByUserId reference user(id) with ON DELETE CASCADE on ~220 tables, so DELETE /api/user/:id also deleted every task, project, invoice, comment… the user had ever created, edited or soft-deleted, including those of other members. UserService.delete (and the email flow) now set those columns to NULL first (user/user-authorship.ts, driven by the DataSource metadata, so plugin tables are covered).

3. Angular

  • New public pages /#/auth/delete-account and /#/auth/delete-account/confirm; link from the sign-in page and the Danger Zone.
  • Danger Zone "Delete account" awaited the API only after showing success and navigating away; it now awaits, shows the API error, and signs this browser out (/auth/logout). "Delete all data" now catches errors. Help text explains each button.

4. DELETE /api/user/reset ("Delete all data") was unreachable

UserController declared DELETE /:id (UUID pipe) before DELETE /reset; routes match in declaration order, so the reset request was answered 400 by the wrong route. /reset is now declared first.

Migration

1791000003450-CreateAccountDeletionRequestTable: one new table + upsert of the two email templates. Additive only, guarded by hasTable, Postgres advisory lock / MySQL named lock, same pattern as CreateEmployeeAgentProfileTable.

Deployment notes

  • New env (optional): ACCOUNT_DELETION_TOKEN_TTL_MINUTES (default 30), ACCOUNT_DELETION_CONFIRM_URL. Documented in .env.sample.
  • The emailed link only points at an origin the deployment serves (CLIENT_BASE_URL, APP_* URLs, EMAIL_LINK_ALLOWED_ORIGINS). api.gauzy.co must allow https://app.gauzy.co, api.ever.team https://app.ever.team (already required for magic-link sign-in).
  • The per-IP limit needs THROTTLE_ENABLED=true (the per-address limit works regardless).

Review rounds (CodeRabbit, Greptile, Copilot; cubic over quota)

All threads answered. Main changes after review:

  • Confirm bound to the preview: verify / GET /me return planHash (accounts × workspaces × outcome × last-admin); confirm / POST /me require it; a changed plan is a 409 and deletes nothing (a 409 after some accounts were deleted says so).
  • Per-account transaction: locks the workspace row only when the whole workspace goes (a joining member waits), locks the user and their employee rows, re-checks address and outcome, then: cancels the tenant's Stripe subscriptions (whole workspace only; configured-but-unusable billing refuses), removes stored files with the owning tenant's storage settings, clears references, deletes.
  • References: every nullable reference to the user or their employees outside their own rows is cleared (authorship, approvedById, defaultAssigneeId, resolvedByEmployeeId, comment/resource-link authors, capture uploaders, …); required blocking ones (key_result.ownerId) are deleted; orphaned member contacts deleted; shared avatars kept.
  • Files: screenshots, camshots, soundshots, videos, documents, inbox attachments, images; captures found via userId, uploadedById and time slots; factory reset removes the files of what it deletes.
  • Link lifecycle: issuing serialized per address (pg advisory xact lock / MySQL named lock through commit); a new request revokes every non-completed link; claim = lease owned by a random claimId, renewed per account, cumulative counts, completion strict; soft-deleted accounts included.
  • Privacy: token redaction in Sentry (events, breadcrumbs; no Replay on token URLs) and PostHog (properties incl. route/referrer/initial/session-entry URLs; no session recording on token URLs); no-tracking headers for Postmark, Mailgun, SendGrid; "deleted" email carries the requesting app's branding.

Test plan

  • jest packages/core account-deletion, user, billing, email-send: 520/520
  • jest packages/ui-auth delete-account: 9/9; jest packages/ui-core redact-url-secrets: 3/3
  • Angular AOT: nx run ui-auth:build, nx run plugin-posthog-ui:build; tsc apps/gauzy; full app compiled in nx serve gauzy
  • Migration: SQLite up→down→up; Postgres body == TypeORM synchronize schema (22 columns, 8 indexes), re-run no-op
  • Local API on Postgres, end-to-end script (19/19): /me sole owner (workspace deleted, stale planHash 409 deletes nothing, completion email, audit row), email link for an address with a shared-workspace account + own workspace (shared org kept with author cleared, employee + member contact deleted, own workspace gone, admin untouched), confirm without planHash 400, wrong planHash 409, reuse 410, identical 202 for known/unknown addresses
  • Earlier runs: revoked link 410, expired 410, 3/hour per address then silent drop, per-IP 429, deletion email not stored in email_sent, X-PM-TrackLinks: None
  • Ever Teams web (ever-co/ever-teams#4876) against this API: Danger Zone + email link, sole member and sole admin cases
  • Not run: Stripe cancellation against a Stripe test account; S3/MinIO file removal (no bucket configured locally); MySQL

Open decisions for the owner

  1. key_result.ownerId is required with no cascade: the member's own key results are deleted with them (alternative: block deletion / reassign).
  2. A Stripe customer not linked to the tenant (stripeCustomerId empty) is not looked up by email (it could belong to another workspace).
  3. Files on a per-tenant Cloudinary account may remain (global SDK config); failures are counted in the audit row. A durable cleanup queue is a follow-up.
  4. Factory reset does not cover Documents / Inbox plugin rows of the kept organization (pre-existing).

Screenshots (local run: Angular app + API on Postgres; test data only)

Images live on the pr-assets/account-deletion branch (not for merging).

Sign-in page: "Delete my account" link

Settings → Danger Zone: help text, email-link entry, buttons

Delete account dialog says the whole workspace goes (only member)

Deleted (API confirmed), then signed out

Email-link request form (/#/auth/delete-account)

Same answer for every address

The email (token masked here)

Confirm page: token already gone from the URL; two workspaces, two outcomes

Checkbox + typed DELETE

Deleted: 2 accounts, 1 whole workspace

Completion email

The same link again: refused

Per the owner's rule for Gauzy: develop only; no stage/master promotion without the owner's OK.

Next agent: see docs/handoffs/HANDOVER-2026-10-27-account-deletion-self-serve.md in ever-co/homelab.

🤖 Generated with Claude Code