feat: change email, export my data, delete account (with data anonymization)

This commit is contained in:
Florian Sylvain committed 2026-10-30 20:19:07 +01:00
1 parent bb191c389a
commit 10a66cd979
123 files changed
+10789 -76

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
Feature: Delete my own account and my personal data
As a verified RenewCMS user
I want to delete my account with my password and a typed confirmation
So that I can exercise my right to erasure (GDPR art. 17) while the content I wrote stays on the site
without my name, and the workspace is never left without an Owner or an administrator
Background:
Given the design of record is docs/design/screens/account.md "Delete account", the board
"RenewCMS Screens Account" group "Delete account" (10 states) and the board "RenewCMS Screens Auth"
group "Account deleted" (1 state)
And RemoveUserUseCase and DELETE /v1/user/{id} are not reused, because they refuse the caller as the
target (role.ErrOwnRole), require users.remove and a covering role, never remove a Locked role (the
Owner), take no password and only delete the user row; a new DeleteOwnAccountUseCase is added and
RemoveUserUseCase is not changed
And DELETE /v1/user/me is added to NewUserRouter behind RequireSession, Verifier, AuditContext,
RequireSignedIn, Authenticator, RequireCurrentToken and RequireVerifiedUser, with a per-user limiter of
5 attempts per 15 minutes (429 "Too many requests. Please try again later." with Retry-After); an API
key gets 403 "This action needs a real session."
And the body is {"current_password": "...", "confirmation": "DELETE"} validated with the tags
current_password "required" and confirmation "required,eq=DELETE" (exact, case-sensitive) through
validator.Validate.Struct; the board sends the password only, so confirmation is an addition
And password checking uses the domain hash.Hasher
And the check order is: body validation 400, wrong password 403 "wrong_password", last Owner or
administrator 409 "last_admin", then one atomic removal
And "last administrator" means: the user holds the built-in Owner role and no other verified user does
(Owner variant), or the user holds the built-in Admin role and no other verified user holds Owner or
Admin (Admin variant); other roles are never blocked; the 409 carries the header X-Last-Role with
"Owner" or "Admin"
And one atomic persistence operation (owned by persistence; the use case sees only a domain interface)
does, or does none of:
| effect | result |
| user | the users row is deleted; every jwt of the user stops working because RequireCurrentToken cannot find it |
| API keys | all the user's api_keys rows are deleted |
| author | the linked author is deleted if no article credits it; otherwise it is scrubbed in place: name "Deleted user", slug "deleted-user-<authorId>", empty bio, no avatar, website or social links, user_id NULL |
| content | articles (created_by), entries (created_by, updated_by) and images (uploaded_by_id) keep the old id and are not touched |
| audit events | the user's past events lose their name (actor and target names become "Deleted user") and their ip and user_agent become null; the events stay |
| re-check | the role holders are counted again inside the same operation; a change since the check answers 409 "account_not_deletable" and nothing is removed |
And after the commit the use case revokes the current session id, then sends the template
"account-deleted" once to the address the account had (data: Host only), and the handler clears the
jwt cookie (Max-Age 0, same attributes as logout)
And a failed send is logged and does not undo the deletion, and the response is still 204
And the audit event user.delete_account is recorded after the removal: actor and target id of the
deleted user, both named "Deleted user", category users, security true, success; its params hold no email
And a failed attempt records user.delete_account with outcome failed, security true and the reason
"Wrong password." or "Last Owner." / "Last administrator." on the user's own events
And the success response is 204 with no body and an expired jwt cookie
And the layering rule holds and no comment is added anywhere
Rule: The endpoint
Background:
Given the users "marta" (id 7, Writer), "jonas" (id 2, Admin) and "ines" (id 3, Owner)
Scenario: A Writer deletes their account
Given marta has the API keys 1 and 2, an author profile credited on 3 articles, 2 entries, 4 uploaded
images and 12 audit events
And her email was "marta@renew.example"
When marta sends DELETE /v1/user/me with {"current_password": "limewash2026", "confirmation": "DELETE"}
Then the response is 204 with no body and a Set-Cookie that expires jwt
And user 7 no longer exists, API keys 1 and 2 no longer exist
And her author row remains with name "Deleted user", slug "deleted-user-<id>", user_id null and no bio
And the 3 articles, 2 entries and 4 images are unchanged
And her 12 events show "Deleted user" and have a null ip and user_agent
And her old jwt on GET /v1/auth/me answers 401
And one mail "account-deleted" with Host only is sent to "marta@renew.example"
And one audit event user.delete_account is recorded
Scenario: An author profile that credits no article is deleted
Given marta's author profile credits nothing
When she deletes her account
Then the author row is gone
Scenario Outline: Refused requests change nothing
When marta sends DELETE /v1/user/me with <body>
Then the response is <status> with the body "<text>"
And user 7, her API keys, her author and her events are unchanged, and no mail is sent
Examples:
| body | status | text |
| {"current_password": "limewash2026", "confirmation": "delete"} | 400 | the validator message for confirmation |
| {"current_password": "limewash2026", "confirmation": ""} | 400 | the validator message for confirmation |
| {"current_password": "", "confirmation": "DELETE"} | 400 | the validator message for current_password |
| not-json | 400 | The request cannot be processed due to a mismatch in the format of the body. |
| {"current_password": "nope", "confirmation": "DELETE"} | 403 | wrong_password |
Scenario Outline: The last Owner or administrator is blocked
Given the caller holds <role> and <others>
When the caller sends a valid DELETE /v1/user/me
Then the response is <result>
Examples:
| role | others | result |
| Owner | no other Owner, one Admin | 409 "last_admin" with X-Last-Role: Owner |
| Owner | no other Owner, no Admin | 409 "last_admin" with X-Last-Role: Owner |
| Owner | another verified Owner | 204 |
| Admin | no Owner and no other Admin | 409 "last_admin" with X-Last-Role: Admin |
| Admin | one Owner | 204 |
| Admin | another verified Admin and no Owner | 204 |
| Admin | only an unverified Admin and no Owner | 409 "last_admin" with X-Last-Role: Admin |
| Writer | no Owner and no Admin | 204 |
| a custom role with every admin permission | no Owner and no Admin | 204 |
Scenario: A blocked attempt leaves everything and records a failed event
Given the caller is the last Owner
When the caller sends a valid DELETE /v1/user/me
Then the user, keys, author and events are unchanged and no mail is sent
And one audit event user.delete_account is recorded with outcome failed, security true and the reason "Last Owner."
Scenario: A concurrent role change stops the removal
Given the check passed and another user's role changed before the atomic removal
When the removal runs
Then the response is 409 "account_not_deletable" and nothing was removed
Scenario: A mail failure does not undo the deletion
Given the mail repository fails on Send
When marta deletes her account
Then the response is 204, the user is gone and the process log names the failed send
Scenario: A failure in the removal rolls everything back
Given deleting the API keys fails inside the operation
When marta deletes her account
Then the response is 500 "Internal server error" and user 7, her author and her events are unchanged, and no mail is sent
Scenario: Rate limited after five attempts
Given marta made five attempts in the last 15 minutes
When she sends one more
Then the response is 429 with a Retry-After header, and nothing is checked or changed
Scenario Outline: Only a real verified session may delete
When <caller> sends DELETE /v1/user/me
Then the response is <status>
Examples:
| caller | status |
| nobody | 401 |
| an API key bearer | 403 |
| an unverified user | 403 |
Scenario: DELETE /v1/user/{id} is unchanged
Given the scenarios of the users_row_actions feature
Then all still pass, and DELETE /v1/user/me is not routed to the {id} handler
Rule: Mail template
Scenario: account-deleted renders without personal data
When the mailer renders "account-deleted" with Host https://cms.example.com
Then the subject is "Your RenewCMS account has been deleted"
And the HTML has no button, no username and no address, and no unreplaced {{ }} action
Rule: Danger zone and the delete dialog (SPA)
Background:
Given the Danger zone card is the last card of /profile with the title "Danger zone", the text "Delete
your account and your personal data. This can't be undone." and the button "Delete my account"
And a dialog shows the design's content: "Delete your account?", "This permanently deletes your
account. It can't be undone.", the list "Your username, display name and email address", "Your author
profile and API keys", "Your sessions: you'll be logged out everywhere", the section "Your articles
and media stay" / "They stay on the site, credited to "Deleted user". Your name is removed from
them.", the link "Export my data first", the fields "Current password" and "Type DELETE to
confirm", and the actions "Keep my account" and "Delete my account"
And every new SPA string (the Danger zone card, the dialog and its blocked variants, errors, and the
Login notice "Your account has been deleted" with its text) is added to the i18n catalogue
(messages.ts, features/spa_internationalization.feature) in English and in French under the same
keys, with the design's English wording verbatim
And the French wording is an assumption: docs/design has no French copy, so the coder authors it and
flags it for the user's review
And the account-deleted mail is English only, because the existing mailer does not localise
(templates hard-code lang="en" and the subjects are fixed English strings)
And every state is a Vitest spec and a `task visual:check -- account` row, named as in the board
Scenario Outline: Dialog states
Given the user opened the dialog on /profile
When <trigger>
Then the state "<state>" shows <copy>
Examples:
| state | trigger | copy |
| Danger zone | the page loads | the card only; no request |
| Confirm: empty | the dialog opens | "Delete my account" disabled |
| Confirm: typing | a password and "DEL" are entered | still disabled |
| Confirm: ready | a password and exactly "DELETE" are entered | enabled; "delete", "Delete" and "DELETE " (trailing space) keep it disabled |
| Wrong password (403) | DELETE answers 403 "wrong_password" | "That password isn't correct. Try again." under the password, password cleared and focused, the word kept |
| Deleting | DELETE unresolved | "Deleting…", fields and "Keep my account" disabled, no close |
| Rate limited (429) | DELETE answers 429 | "Too many requests. Try again in a few minutes.", Confirm stays enabled |
| Server error | DELETE answers 500 or fails | "Couldn't delete your account. Nothing was deleted. Try again in a moment.", values kept |
| Blocked: last administrator | DELETE answers 409 "last_admin" with X-Last-Role Admin | "You can't delete your account yet", "You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.", "Back to profile", "Go to Users" |
| Blocked: last Owner (Vitest only, no board frame) | the same with X-Last-Role Owner | "You can't delete your account yet", "You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.", "Back to profile", "Go to Users" |
| Blocked: cannot be deleted | DELETE answers 409 "account_not_deletable" | "This account can't be deleted", "RenewCMS can't delete this account right now. Ask an administrator to help, then try again.", "Back to profile" (no "Go to Users") |
Scenario Outline: Blocked dialogs cannot be dismissed
Given a "Blocked" state is shown
When the user <action>
Then the dialog stays open and shows no close button
Examples:
| action |
| presses Escape |
| clicks the backdrop |
Scenario: A blocked dialog always offers a next step
Then "Back to profile" closes the dialog, and "Go to Users" navigates to /users
Scenario: Export my data first
Given the dialog is open
When the user activates "Export my data first"
Then the download of slice account_export_data starts without closing the dialog
Scenario: Success signs out and redirects with the one-time message
Given the user confirmed with a valid password and "DELETE"
When DELETE /v1/user/me answers 204
Then the session store is cleared without calling POST /v1/auth/logout and the router goes to /login?reason=account-deleted
And the Login screen shows a success notice (icon circle-check) "Your account has been deleted" /
"Your articles and media were kept without your name."
And the query is removed from the URL once consumed, so a reload shows the plain Login with no notice
And the leave-page confirmation of Profile does not open
Scenario: The one-time message needs the exact reason
When a visitor opens /login?reason=anything-else
Then no account-deleted notice is shown
Scenario: Deleted creators are named "Deleted user" in the SPA
Given an entry whose created_by is 7 and GET /v1/user/lookup does not return user 7
Then the screen that names its creator shows "Deleted user"
Rule: Documentation and privacy
Scenario: The docs describe the feature
Then docs/api.md documents DELETE /v1/user/me: body, 204, 400, 403 wrong_password, 409 last_admin with
X-Last-Role, 409 account_not_deletable, 429
And docs/privacy.md:
- replaces the Erasure bullet and the "No way to delete a verified user" Gap with the self-service flow;
- states what is kept (articles, entries, media, anonymised author and activity events) and what is removed;
- adds the account-deleted mail to "What leaves the server";
- leaves the Gaps entry about soft-deleted rows and the failed-login username entry intact.