feat(web): delete an account by e-mail link, and make the Danger Zone actions work

Merged
EV
17d ago
+2569-56

Problem

  • Ever Teams had no way to delete an account without signing in. People who lost access, or whose email has accounts in several workspaces, had no path, and store/privacy rules require one. ever.team/delete also described an in-product flow that did not fully work.
  • In Settings → Personal → Danger Zone, "Delete all data" called no API at all: its handler was console.log(user). Every failed action also left its confirm modal open without a word (an unhandled promise rejection).

What changed

Public email-link deletion (contract shared with ever-co/ever-gauzy#11519: POST /api/account-deletion/{request,verify,confirm})

  • /[locale]/delete-account: an email form. It sends email, callbackUrl = <origin>[/<locale>]/delete-account/confirm, locale and the app's email branding (appName, appLogo, appSignature, appLink, companyName, companyLink). These are the same fields the Ever ID sign-in sends, now shared through core/lib/helpers/app-email-branding.ts. The page always shows the same answer: "If an account exists for that email, we sent a link to delete it. The link expires in 30 minutes."
  • /[locale]/delete-account/confirm#token=…:
    • The token is read from the fragment and removed from the address at once, then verify runs.
    • The page lists every workspace and its outcome: workspace-deleted means the person is the only member; account-removed means the account and member records are deleted and shared content stays without the name. A warning shows when soleAdmin is true and otherMembers > 0. The page also says what is kept.
    • The person ticks a checkbox and types DELETE, then confirm runs with the preview's planHash.
    • A 409 (the accounts changed since the preview) shows the new preview with a notice and asks for the confirmation again.
    • A 410 is the only "invalid or expired" answer. Network errors, 5xx and 429 show a transient error with Try again, which re-runs the call with the token kept in memory.
    • After deletion, a local session whose account is gone is cleared (cookies and query cache).
    • A link pasted into the same tab, which only changes the fragment, is also read.
  • Proxy mode (no NEXT_PUBLIC_GAUZY_API_SERVER_URL): app/api/account-deletion/{request,verify,confirm} forward to Gauzy and keep its status. app/api/account-deletion/me (GET/POST) forwards the caller's token and tenant.
  • Entry points: a Delete my account by email link in the sign-in footer (passcode and password pages), and a Delete by email link row in the Danger Zone.

Danger Zone

  • Delete all data now deletes the signed-in account with the same rules: POST /api/account-deletion/me {confirm:true, planHash}, permission ACCESS_DELETE_ACCOUNT.
    • Its modal first shows GET /api/account-deletion/me: the outcome in this workspace, other members, the sole-admin warning, what is kept, and that the user will be signed out. Confirm stays disabled until that preview has loaded.
    • After deletion the user is signed out: cookies and query cache are cleared and the app goes to /auth/passcode.
    • It deliberately does not use DELETE /user/reset. Gauzy's factory reset wipes every member's data in the user's organizations and keeps the account. useUser().resetUser is kept, but nothing calls it now.
  • Delete this account (DELETE /api/user/:id) used to invalidate the user queries before logging out. That refetched /user/me with the deleted account's token, which answered 401. The queries are now removed instead.
  • RemoveModal shows the reason when an action fails and resets its message when it closes. This also fixes the team Danger Zone, which uses the same modal.

i18n: pages.accountDeletion.* (36 keys) and alerts.ACTION_FAILED in all 13 locales.

Danger Zone audit

ButtonAPI and payloadBeforeAfter (verified on the local API)
Remove from all teamsDELETE /api/organization-team/teams/:userId, no body, bearer + tenant-idSuccess: teams, token and user reload. Failure: a toast, plus an unhandled rejection that left the modal openSame success path. Failure: the API's reason shows in the modal ("You are not able to removed account where you are only the manager!")
Delete this accountDELETE /api/user/:id, no body (Gauzy ALL_ORG_EDIT / ACCESS_DELETE_ACCOUNT)Success: invalidate (a 401 refetch), then logOut(). Failure: nothing shown, unhandled rejection, modal stuck open200, then signed out, auth cookies cleared, /auth/passcode. Failure: the reason shows in the modal
Delete all dataBefore: no request. After: GET + POST /api/account-deletion/me {confirm:true, planHash}Did nothing (console.log(user)), and the modal never closedPreview in the modal → 200 → signed out. Signing in again gets no code email, and a guessed code is refused
Delete by email link (new)Link to /delete-accountdid not existThe public email-link flow, for every account with that email

Observed on the API side, not worked around here: Gauzy declares DELETE /api/user/:id (UUID pipe) before DELETE /api/user/reset. The client no longer calls /user/reset.

Security notes

  • The token travels only in the URL fragment, so it never reaches a server log or a Referer.
    • The confirm page reads it in a layout effect, before any passive effect such as analytics page views, and immediately calls history.replaceState(null, '', pathname). The null state makes Next.js sync its router to the clean URL rather than write the old one back. The token then lives only in a React ref.
  • <meta name="referrer" content="no-referrer"> comes from the route layout's metadata. next.config.js also sends Referrer-Policy: no-referrer and X-Robots-Tag: noindex, nofollow for /delete-account/** and /<locale>/delete-account/**.
  • Third-party scripts: the Sentry browser SDK, PostHog and Jitsu stay off on any page load that starts on these routes (isAccountDeletionPath), because they record the URL they start on. The existing PostHog/Jitsu redaction also strips a token fragment. GA is commented out, and Chatwoot only loads on the home page.
  • Both routes are public: they are not in PROTECTED_APP_URL_PATHS and not under /auth/*, so a signed-in visitor is not redirected away. No session is created.
  • The request form always gives the same answer. The public calls send no Authorization header and no tenant or organization ids, even when a session is open in the same browser.
  • Proxy routes forward only the expected string fields and never log bodies.

Screenshots

All screenshots come from real local runs: the Ever Teams dev server in direct mode, against the local Gauzy API from ever-co/ever-gauzy#11519 (with planHash), driven by headless Chromium. 23 and 24 stub the API answers in the browser to show the states the API cannot produce on demand. The files are under docs/pr-assets/account-deletion/.

Danger Zone (signed in)
StepScreenshot
01 Avatar menu → Settings
03 Danger Zone with the new "Delete by email link" row
05 Remove from all teams: the API's refusal now shows in the modal
06 Delete this account (DELETE /api/user/:id)
07 …then signed out, auth cookies cleared
08 Delete all data: preview from GET /api/account-deletion/me
09 …POST /api/account-deletion/me 200, then signed out
10 Signing in again: no code email, and a guessed code is refused
Email link (no sign-in)
StepScreenshot
11 Sign-in footer link
12 /delete-account
13 The same answer whether or not the account exists
14 The email (the token is in the link's fragment)
15 Confirm page: token gone from the address bar; sole member, so the workspace is deleted
16 Checkbox + typed DELETE
17 Deleted
19 The same link again: 410
21 Shared workspace: account removed, sole-admin warning
23 (stubbed API) 409: the new preview, confirmation cleared
24 (stubbed API) 503: Try again re-uses the in-memory token

docs/pr-assets/account-deletion/ also has 02 (Settings), 04 (the remove modal), 18 (the completion email), 20 (sign-in refused) and 22 (the shared-workspace result).

Test plan

Automated (run locally on this branch):

  • jest:
    • core/lib/auth/account-deletion.test.ts: paths, confirm URL, the token is taken from the fragment or query and removed with replaceState(null), the sole-admin rule
    • core/services/server/requests/account-deletion{,-me}.test.ts: the proxy routes forward only the expected fields and planHash, pass 202/410/409/403 through, answer 400 for a missing field and 502 when Gauzy is down
    • test/sentry/sentry-client-instrumentation.test.ts: Sentry stays off on /delete-account, /delete-account/confirm and /fr/delete-account/confirm
    • test/architecture/feature-surface-preserved.test.ts: route surface of 141 files and 188 verbs
    • the Ever ID sign-in tests, which use the shared branding helper
  • tsc --noEmit on apps/web: 0 errors. eslint on the changed files: clean

End to end against the local Gauzy API (ever-co/ever-gauzy#11519):

  • Danger Zone "Delete all data" in a sole-member workspace: the preview says workspace-deleted. POST /me with planHash → 200, then signed out (auth cookies cleared, /auth/passcode). Signing in again gets no code email, and a guessed code → 401
  • Danger Zone "Delete this account": DELETE /user/:id → 200, then signed out
  • Danger Zone "Remove from all teams" as the sole manager: the API's refusal shows in the modal
  • Email link, sole member: request 202 → email with /delete-account/confirm#token=… → the token leaves the address bar on load → verify → confirm (body token, confirm, planHash) → 200 {accounts:1, workspacesDeleted:1} → the same link again → 410 → sign-in refused
  • Email link, sole admin of a workspace with another member: account-removed with the sole-admin warning → {accounts:1, workspacesDeleted:0}
  • Confirm page:
    • Referrer-Policy: no-referrer header and meta, and X-Robots-Tag
    • no third-party script, and no request URL or Referer carries the token
    • Sentry, PostHog and Jitsu are not started (also checked in the built-in browser)
  • The 409 (changed preview) and 503 (transient) states, with the API stubbed in the browser

Not done yet:

  • Proxy mode (NEXT_PUBLIC_GAUZY_API_SERVER_URL unset): unit-tested, not run end to end
  • Locales other than English: the strings are in all 13 locales, but only the English pages were looked at
  • yarn build:web was not run (fast-build rule)

Notes

  • Contract changes adopted:
    • GET|POST /api/account-deletion/me for the Danger Zone (the lead's decision)
    • planHash on verify, /me, confirm and POST /me, with 409 handling
    • only 410 means a dead link; 429, 5xx and network errors are transient
  • Completion email branding: lane A fixed it in ever-gauzy 5b49897e86. The "deleted" email now uses the branding stored with the link's request, and POST /me accepts the same optional branding fields. Ever Teams sends appEmailBranding() with POST /me (commit 02108018c); the screenshots predate that fix. The 500 of POST /me was fixed in ever-gauzy 5406e92b68.
  • Seen but not changed (pre-existing):
    • the auth-active-project and auth-timezone cookies outlive a logout
    • the browser tab title of /delete-account reads "Home" (formatTitle drops hyphenated segments)
    • the sidebar's resize rail draws over modals

Depends on ever-co/ever-gauzy#11519 (lane A: the account deletion API, /account-deletion/me, and a user delete that keeps shared data).

Next agent: nothing is left to build in this lane: the code is complete and verified locally, including the branding on POST /me. Merge after ever-co/ever-gauzy#11519.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a self-service account deletion flow with email verification, a review of affected accounts and workspaces, and explicit confirmation before deletion.
    • Added account deletion from personal settings, including an impact preview and warnings when deletion may affect workspace administration.
    • Added a link to request account deletion from sign-in pages and translated deletion-flow messages across supported languages.
    • If the deletion plan changes before confirmation, the updated impact preview is shown for review.
  • Privacy

    • Account deletion pages are excluded from search indexing and do not share referrer information. Analytics and error monitoring are not initialized on these pages.