feat(api): Self-serve account deletion from Settings - #4600
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR implements self-serve account deletion for EE deployments alongside a subscription provisioning refactor. Backend changes include service orchestration across SuperTokens, Stripe, and Loops; an EE-gated DELETE /profile endpoint; and acceptance tests. Frontend adds Settings sidebar Account tab and a deletion confirmation modal. Supporting infrastructure includes member counting, interactive session detection, and contact removal helpers. Documentation covers design decisions and implementation verification. ChangesSelf-Serve Account Deletion
Subscription Provisioning Refactoring
🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (1)
api/ee/tests/pytest/acceptance/accounts/test_account_deletion.py (1)
100-106: ⚡ Quick winAssert the 409 payload contract, not just the status code.
Please assert that the conflict response includes the structured account-deletion error payload (code/details/organizations). This will lock the cross-layer contract and catch regressions early.
Suggested assertion pattern
blocked = requests.delete( f"{api_url}/profile", headers={"Authorization": owner_creds}, timeout=BASE_TIMEOUT, ) assert blocked.status_code == 409, blocked.text + payload = blocked.json()["detail"] + assert payload["code"] == "account_has_members" + assert payload["details"]["organizations"]
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: a83bb4f0-b96e-4633-b90a-f63cebb548bb
📒 Files selected for processing (17)
api/ee/src/core/subscriptions/service.pyapi/ee/src/services/db_manager_ee.pyapi/ee/tests/pytest/acceptance/accounts/test_account_deletion.pyapi/oss/src/core/accounts/errors.pyapi/oss/src/core/accounts/service.pyapi/oss/src/routers/user_profile.pyapi/oss/src/utils/emailing.pydocs/design/self-serve-account-deletion/README.mddocs/design/self-serve-account-deletion/context.mddocs/design/self-serve-account-deletion/decisions.mddocs/design/self-serve-account-deletion/plan.mddocs/design/self-serve-account-deletion/research.mddocs/design/self-serve-account-deletion/status.mdweb/oss/src/components/Sidebar/SettingsSidebar.tsxweb/oss/src/components/pages/settings/Account/DeleteAccount.tsxweb/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsxweb/oss/src/services/profile/index.ts
47997f4 to
7f8c8bd
Compare
… lock to prevent duplicates
Add a self-serve "Delete account" flow so users can remove their own account instead of asking support. EE-only (cloud and self-hosted EE); OSS keeps its shared singleton org and does not expose the route. DELETE /profile deletes the caller, the organizations they solely own, their SuperTokens login, their Stripe subscription, and their Loops contact, in that order. SuperTokens is deleted before the DB cascade so the idempotent signup override cannot recreate the account on next login. If the user owns an org with other members, the request is blocked (409) rather than deleting the team's data. Reuses the existing admin cascade (admin_delete_user_with_cascade + membership cleanup). New pieces: emailing.delete_contact, a HTTP-free SubscriptionsService.cancel_stripe_subscription, count_organization_members, and PlatformAdminAccountsService.delete_own_account. Frontend adds an EE-only Account tab in Settings with a type-your-email confirm modal that signs the user out on success. Acceptance tests (happy path + shared-org block) pass against the EE stack.
7f8c8bd to
c9152c2
Compare
|
@jp-agenta don't forget me :) |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
api/ee/src/core/subscriptions/service.py (1)
202-236:⚠️ Potential issue | 🟠 Major | ⚡ Quick winMake
provision_subscription()fail hard when no subscription row is created.
start_reverse_trial()andstart_plan()still returnNoneon write/provisioning failures, andcreate_organization_for_signup()doesn't inspect the result. That means signup can persist a new organization with no subscription record and still continue into the entitlement path. Treat a missing result as an error here so the existing rollback path runs instead of leaving partial state.Proposed fix
- async def provision_subscription( + async def provision_subscription( self, *, organization_id: str, organization_name: str, organization_email: str, - ) -> Optional[SubscriptionDTO]: + ) -> SubscriptionDTO: @@ if env.stripe.enabled: if trial_enabled(): - return await self.start_reverse_trial( + subscription = await self.start_reverse_trial( organization_id=organization_id, organization_name=organization_name, organization_email=organization_email, ) + if subscription is None: + raise EventException( + f"Failed to provision subscription for organization ID: {organization_id}" + ) + return subscription free_plan = get_free_plan() log.info( "Trial not configured; onboarding org %s on free plan [%s]", organization_id, free_plan, ) - return await self.start_plan( + subscription = await self.start_plan( organization_id=organization_id, plan=free_plan, ) + if subscription is None: + raise EventException( + f"Failed to provision subscription for organization ID: {organization_id}" + ) + return subscription - return await self.start_plan( + subscription = await self.start_plan( organization_id=organization_id, plan=get_default_plan(), ) + if subscription is None: + raise EventException( + f"Failed to provision subscription for organization ID: {organization_id}" + ) + return subscription
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 8444151f-d266-4437-b576-d1e10dc4704d
📒 Files selected for processing (22)
api/ee/src/core/subscriptions/service.pyapi/ee/src/services/commoners.pyapi/ee/src/services/db_manager_ee.pyapi/ee/tests/pytest/acceptance/accounts/test_account_deletion.pyapi/oss/src/core/accounts/errors.pyapi/oss/src/core/accounts/service.pyapi/oss/src/middlewares/auth.pyapi/oss/src/routers/user_profile.pyapi/oss/src/utils/emailing.pydocs/design/ee-self-hosting/plan.mddocs/design/ee-self-hosting/rfc-0.mddocs/design/ee-self-hosting/status.mddocs/design/self-serve-account-deletion/README.mddocs/design/self-serve-account-deletion/context.mddocs/design/self-serve-account-deletion/decisions.mddocs/design/self-serve-account-deletion/plan.mddocs/design/self-serve-account-deletion/research.mddocs/design/self-serve-account-deletion/status.mdweb/oss/src/components/Sidebar/SettingsSidebar.tsxweb/oss/src/components/pages/settings/Account/DeleteAccount.tsxweb/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsxweb/oss/src/services/profile/index.ts
✅ Files skipped from review due to trivial changes (3)
- docs/design/self-serve-account-deletion/README.md
- docs/design/ee-self-hosting/status.md
- docs/design/ee-self-hosting/plan.md
🚧 Files skipped from review as they are similar to previous changes (5)
- web/oss/src/services/profile/index.ts
- api/oss/src/core/accounts/errors.py
- web/oss/src/components/Sidebar/SettingsSidebar.tsx
- web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx
- web/oss/src/components/pages/settings/Account/DeleteAccount.tsx
There was a problem hiding this comment.
Pull request overview
This PR adds an EE-only self-serve “Delete account” flow exposed in Settings, backed by a new authenticated DELETE /profile endpoint that orchestrates Stripe cancellation (best-effort), SuperTokens identity deletion (required), DB cascade deletion, and Loops contact removal (best-effort). It fits into the existing accounts/admin-delete infrastructure by reusing the existing cascade deletion and adding a small amount of orchestration + UI wiring.
Changes:
- Adds
DELETE /profile(EE-gated) with an “interactive session only” guard to prevent API keys from deleting accounts. - Implements backend orchestration for self-serve account deletion (shared-org membership guard, Stripe cancel, SuperTokens deletion, cascade DB delete, Loops removal) plus acceptance tests.
- Adds an EE-only “Account” settings tab with a typed-email confirmation modal and a new
deleteAccount()client call.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| web/oss/src/services/profile/index.ts | Adds deleteAccount() service call to invoke account deletion. |
| web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx | Adds an EE-only “Account” tab and wires the new settings content. |
| web/oss/src/components/Sidebar/SettingsSidebar.tsx | Adds an EE-only “Account” entry to the settings sidebar navigation. |
| web/oss/src/components/pages/settings/Account/DeleteAccount.tsx | Implements the “Delete account” UI with typed-email confirmation and mutation/logout flow. |
| docs/design/self-serve-account-deletion/README.md | Introduces design workspace for the feature. |
| docs/design/self-serve-account-deletion/context.md | Documents goals/non-goals and motivation. |
| docs/design/self-serve-account-deletion/research.md | Captures prior-art and codebase references for deletion mechanics. |
| docs/design/self-serve-account-deletion/plan.md | Documents intended design and implementation plan. |
| docs/design/self-serve-account-deletion/decisions.md | Records design decisions (EE-only, shared-org guard, ordering, etc.). |
| docs/design/self-serve-account-deletion/status.md | Tracks implementation status and follow-ups. |
| docs/design/ee-self-hosting/status.md | Updates naming reference to provision_subscription(). |
| docs/design/ee-self-hosting/rfc-0.md | Updates naming reference to provision_subscription(). |
| docs/design/ee-self-hosting/plan.md | Updates naming reference to provision_subscription(). |
| api/oss/src/utils/emailing.py | Adds Loops remove_contact() helper (best-effort deletion). |
| api/oss/src/middlewares/auth.py | Adds is_interactive_session() helper for interactive-session gating. |
| api/oss/src/routers/user_profile.py | Adds DELETE /profile endpoint (EE-only + interactive-session guard + exception mapping). |
| api/oss/src/core/accounts/errors.py | Adds typed domain errors for self-serve deletion failure modes. |
| api/oss/src/core/accounts/service.py | Adds delete_own_account() orchestration and SuperTokens deletion helper. |
| api/ee/src/services/db_manager_ee.py | Adds count_organization_members() for shared-org guard. |
| api/ee/src/services/commoners.py | Renames callsite to provision_subscription(). |
| api/ee/src/core/subscriptions/service.py | Renames provision_signup_subscription() → provision_subscription(); adds cancel_subscription() used by deletion. |
| api/ee/tests/pytest/acceptance/accounts/test_account_deletion.py | Adds acceptance tests for happy path, shared-org block, and API key rejection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Railway Preview Environment
|
Account deletion 500'd for users who had accepted an invitation into another organization. Accepting an invite stamps the host org's project_invitations.user_id, and that FK (like the modified_by_id audit columns and webhook_subscriptions.created_by_id) has no ON DELETE rule. The host org survives the cascade, so DELETE FROM users hit a foreign key violation after SuperTokens and Stripe were already processed, and the frontend never ran the logout flow. Clear those references inside the same transaction before the user row is deleted: drop the user's invitation rows and webhook subscriptions (created_by_id is NOT NULL), and null the modified_by_id audit columns. This also fixes the same latent bug in the admin delete path. Regression test drives the real invite, accept, delete flow.
Why
Users regularly ask us on chat to delete their accounts, and today an admin has to do it by hand. This adds a self-serve "Delete account" action in Settings, so users can remove themselves and we honor data-deletion requests without manual work.
What it does
A logged-in user opens Settings → Account, types their email to confirm, and deletes their account.
DELETE /profilethen runs, in this order:The frontend signs the user out and redirects on success.
Scope is EE only (cloud and self-hosted EE). OSS keeps its shared singleton org, and the route returns 404 there.
How it reuses what exists
Most of this already existed; the PR mostly wires it together:
admin_delete_user_with_cascade+ the membership cleanup the admin delete already runs.emailing.delete_contact, an HTTP-freeSubscriptionsService.cancel_stripe_subscription,count_organization_members, andPlatformAdminAccountsService.delete_own_account.The existing self-serve
DELETE /organizations/{id}has the same gap (it never cancels Stripe). Foldingcancel_stripe_subscriptioninto it is left as a follow-up.Testing
api/ee/tests/pytest/acceptance/accounts/test_account_deletion.pycovers two cases (happy-path delete, shared-org block). Both pass against the local EE stack.Notes
release/v0.103.0. The feature depends onemailing.py, which is introduced in v0.103.0. Retarget this PR tomainonce v0.103.0 lands.docs/design/self-serve-account-deletion/.deleteAccount()usesfetchJsonto match the existing profile service. Migrate it to the Fern client once the OpenAPI spec is regenerated with the newDELETE /profileroute.