feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
1 parent
bb191c389a
commit
10a66cd979
123 files changed
+10789
-76
No files matched your search
@@ -0,0 +1,292 @@
|
||||
Feature: Change my email address from Profile, verified by a link sent to the new address
|
||||
|
||||
As a verified RenewCMS user
|
||||
I want to change my email address with my current password and a link sent to the new address
|
||||
So that I keep control of my account address and nobody can take it over without my password
|
||||
or without access to the new inbox
|
||||
|
||||
Background:
|
||||
Given the design of record is docs/design/screens/account.md "Change email" and "Verification landing",
|
||||
docs/design/routes.md "Account", the board "RenewCMS Screens Account" group "Change email" (14 states)
|
||||
and the board "RenewCMS Screens Auth" group "Verify new email" (7 states)
|
||||
And four endpoints are added under /v1/auth, each behind RequireSession, authtoken.Verifier,
|
||||
AuditContext, RequireSignedIn, authtoken.Authenticator, RequireCurrentToken and RequireVerifiedUser,
|
||||
so a missing or invalid jwt cookie gives 401 "You must be logged in to access this resource.",
|
||||
an API key gives 403 "This action needs a real session." and an unverified user gives
|
||||
403 "You must verify your account to access this resource.":
|
||||
| method | path | body | success |
|
||||
| POST | /v1/auth/email/change | {"new_email": "...", "current_password": "..."} | 200 {"pending_email": "<new>"} |
|
||||
| POST | /v1/auth/email/resend | none | 200 {"pending_email": "<new>"} |
|
||||
| DELETE | /v1/auth/email/change | none | 204, no body |
|
||||
| POST | /v1/auth/email/confirm | {"code": "<raw code>"} | 200 {"email": "<new>"} |
|
||||
And POST change and POST resend share one per-user limiter of 5 attempts per 15 minutes
|
||||
(middleware.NewUserRateLimiter); the sixth answers 429 "Too many requests. Please try again later."
|
||||
with a Retry-After header; DELETE and confirm are not limited
|
||||
And the request struct uses validate tags checked with validator.Validate.Struct:
|
||||
new_email "required,email" (same email rule as the invite request, extended in validator.InitValidator)
|
||||
and current_password "required"; there is no hand-written validation in the handler
|
||||
And error bodies are plain text from http.Error, snake_case codes are the whole body:
|
||||
403 "wrong_password", 400 "same_email", 404 "no_pending_email_change", 403 "invalid_email_change_code",
|
||||
409 "email_unavailable"; success bodies are JSON
|
||||
And the user domain gains PendingEmail, EmailChangeCode (bcrypt hash of a random UUID, like the reset
|
||||
code) and EmailChangeExpiration on user.User and the users table (portable across SQLite, MySQL and
|
||||
PostgreSQL), the sentinels ErrWrongPassword, ErrSameEmail, ErrNoPendingEmailChange,
|
||||
ErrInvalidEmailChangeCode and ErrEmailUnavailable, and repository methods that set, clear and
|
||||
confirm a pending change; confirming is one atomic step that re-checks the address is free
|
||||
And a use case (RequestEmailChange, ResendEmailChange, CancelEmailChange, ConfirmEmailChange) owns
|
||||
every rule; the handler never touches a repository, GORM or the mailer
|
||||
And password checking uses the domain hash.Hasher (never bcrypt in the handler)
|
||||
And the link code lives 2 hours and works once; users.email is untouched until confirmation, so the
|
||||
old address keeps receiving password-reset links and the password and username keep working
|
||||
And GET /v1/auth/me gains "pending_email" (string or null) so the Pending state survives a reload
|
||||
And three mail templates are built from docs/design/source/emails and added to
|
||||
internal/infrastructure/mailer/templates:
|
||||
| template | to | data | subject |
|
||||
| verify-new-email | the new address | Host, Username, VerificationCode | Confirm your new RenewCMS email address |
|
||||
| email-change-notice| the old address | Host, Username, NewEmail | Your RenewCMS email address change was requested |
|
||||
And Host is PUBLIC_URL and Username is the account's username
|
||||
And the audit actions user.request_email_change, user.resend_email_change, user.cancel_email_change and
|
||||
user.confirm_email_change are added (category users, source web); request, resend and confirm are
|
||||
security events; no email address is written into an event
|
||||
And the layering rule holds and no comment is added anywhere
|
||||
|
||||
Rule: Requesting a change
|
||||
|
||||
Background:
|
||||
Given the user "marta" (id 7, verified) has email "marta@renew.example" and the password "limewash2026"
|
||||
|
||||
Scenario: A valid request stores a pending change and sends two mails
|
||||
When marta sends POST /v1/auth/email/change with
|
||||
{"new_email": "marta.alves@atelier-renew.example", "current_password": "limewash2026"}
|
||||
Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"}
|
||||
And users.email is still "marta@renew.example"
|
||||
And PendingEmail is "marta.alves@atelier-renew.example", the stored code is a hash (never the raw
|
||||
code) and the expiration is within 5 seconds of now plus 2 hours
|
||||
And one mail is sent to "marta.alves@atelier-renew.example" with template "verify-new-email" and
|
||||
data Host, Username "marta" and the raw VerificationCode
|
||||
And one mail is sent to "marta@renew.example" with template "email-change-notice" and data Host,
|
||||
Username "marta" and NewEmail "marta.alves@atelier-renew.example"
|
||||
And one audit event user.request_email_change is recorded: actor marta, target user 7, success,
|
||||
security, with no address in its params
|
||||
|
||||
Scenario Outline: A refused request changes nothing and sends nothing
|
||||
When marta sends POST /v1/auth/email/change with <body>
|
||||
Then the response is <status> with the body "<text>"
|
||||
And PendingEmail is empty and no mail is sent
|
||||
|
||||
Examples:
|
||||
| body | status | text |
|
||||
| {"new_email": "marta.alves@atelier", "current_password": "limewash2026"} | 400 | the validator message for new_email |
|
||||
| {"new_email": "", "current_password": "limewash2026"} | 400 | the validator message for new_email |
|
||||
| {"new_email": "marta.alves@atelier-renew.example", "current_password": ""} | 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. |
|
||||
| {"new_email": "MARTA@renew.example", "current_password": "limewash2026"} | 400 | same_email |
|
||||
| {"new_email": "marta.alves@atelier-renew.example", "current_password": "nope"} | 403 | wrong_password |
|
||||
|
||||
Scenario: A wrong password records a failed security event
|
||||
When marta sends the request with the password "nope"
|
||||
Then one audit event user.request_email_change is recorded with outcome failed, security true and
|
||||
failure reason "Wrong password."
|
||||
|
||||
Scenario: An address already in use answers like success and sends no verification mail
|
||||
Given another user has the email "ines@renew.example"
|
||||
When marta sends POST /v1/auth/email/change with new_email "Ines@Renew.example"
|
||||
Then the status, the body and every header except Date are identical to the valid request
|
||||
And a pending change for "Ines@Renew.example" is stored so a reload cannot tell the difference
|
||||
And no mail is sent to "ines@renew.example"
|
||||
And the email-change-notice is still sent to "marta@renew.example"
|
||||
|
||||
Scenario: A new request replaces the previous link
|
||||
Given marta requested a change to "a@renew.example" and received code 1
|
||||
When she requests "b@renew.example"
|
||||
Then only code 2 confirms, code 1 answers 403 "invalid_email_change_code", and PendingEmail is "b@renew.example"
|
||||
|
||||
Scenario: A failed send is logged and the response is unchanged
|
||||
Given the mail repository fails on Send with "smtp: connection refused"
|
||||
When marta sends a valid request
|
||||
Then the response is 200 with the same body, the pending change is stored, and the process log names
|
||||
the failed send and the error but not the raw code
|
||||
And a 500 would reveal whether the address is taken, which is why the failure is not surfaced
|
||||
|
||||
Scenario: The sixth request in 15 minutes is rate limited
|
||||
Given marta made five requests or resends in the last 15 minutes
|
||||
When she sends one more
|
||||
Then the response is 429 "Too many requests. Please try again later." with a Retry-After header
|
||||
And no mail is sent and nothing is stored
|
||||
|
||||
Rule: Resending and cancelling while pending
|
||||
|
||||
Scenario: Resend issues a new code and invalidates the old one
|
||||
Given marta has a pending change to "marta.alves@atelier-renew.example" with code 1
|
||||
When she sends POST /v1/auth/email/resend
|
||||
Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"}
|
||||
And one mail with template "verify-new-email" carries a new code, the expiration is reset to now plus 2 hours
|
||||
And code 1 answers 403 "invalid_email_change_code"
|
||||
And no email-change-notice is sent again
|
||||
And one audit event user.resend_email_change is recorded
|
||||
|
||||
Scenario: Resend for a taken address sends nothing
|
||||
Given marta's pending address belongs to another user
|
||||
When she sends POST /v1/auth/email/resend
|
||||
Then the response is 200 with the same body and no mail is sent
|
||||
|
||||
Scenario: Resend with nothing pending
|
||||
When marta sends POST /v1/auth/email/resend
|
||||
Then the response is 404 "no_pending_email_change"
|
||||
|
||||
Scenario: Cancel clears the pending change
|
||||
Given marta has a pending change
|
||||
When she sends DELETE /v1/auth/email/change
|
||||
Then the response is 204 and PendingEmail, code and expiration are empty
|
||||
And the old link answers 403 "invalid_email_change_code"
|
||||
And one audit event user.cancel_email_change is recorded
|
||||
|
||||
Scenario: Cancel with nothing pending is harmless
|
||||
When marta sends DELETE /v1/auth/email/change
|
||||
Then the response is 204 and no audit event is recorded
|
||||
|
||||
Rule: Confirming from the link
|
||||
|
||||
Scenario: A valid code changes the email and is single use
|
||||
Given marta has a pending change to "marta.alves@atelier-renew.example" with a live code
|
||||
When she sends POST /v1/auth/email/confirm with that code
|
||||
Then the response is 200 with {"email": "marta.alves@atelier-renew.example"}
|
||||
And users.email is "marta.alves@atelier-renew.example", the pending fields are empty
|
||||
And her session keeps working (token_version is unchanged)
|
||||
And one audit event user.confirm_email_change is recorded
|
||||
When she sends the same code again
|
||||
Then the response is 403 "invalid_email_change_code"
|
||||
|
||||
Scenario Outline: A code that does not work answers one 403
|
||||
Given <setup>
|
||||
When marta confirms with <code>
|
||||
Then the response is 403 "invalid_email_change_code" and users.email is unchanged
|
||||
|
||||
Examples:
|
||||
| setup | code |
|
||||
| nothing is pending | any code |
|
||||
| the code expired 1 second ago | the right code |
|
||||
| a pending change exists | a wrong code |
|
||||
| the code was issued to another user | the right code of ines |
|
||||
| the change was cancelled | the old code |
|
||||
|
||||
Scenario: The address was taken in the meantime
|
||||
Given marta has a live code for "ines@renew.example" and the address now belongs to another user
|
||||
When she confirms
|
||||
Then the response is 409 "email_unavailable", users.email is unchanged and the pending change is cleared
|
||||
And the body is the same for an address that was always taken at request time
|
||||
|
||||
Scenario: An empty code is refused before any lookup
|
||||
When marta sends {"code": ""}
|
||||
Then the response is 400 with the validator message and nothing changes
|
||||
|
||||
Rule: Profile, the Account card (SPA)
|
||||
|
||||
Background:
|
||||
Given /profile (docs/design/screens/profile.md, features/profile_page.feature) shows the Email
|
||||
field and a link "Change email"; the order of the cards is Display name, Account, Your role,
|
||||
Author profile, Password, Your data, Danger zone
|
||||
And the session carries pending_email from GET /v1/auth/me
|
||||
And the global handling of 403 in useApi passes a 403 with body "wrong_password" through to the
|
||||
caller; the form reads the body text and never the status alone
|
||||
And every new SPA string (the Account card states, toasts and errors, and the verification landing
|
||||
states) is added to the i18n catalogue (messages.ts, features/spa_internationalization.feature) in
|
||||
English and in French under the same keys, and the English wording is the design's 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 mail templates verify-new-email and email-change-notice are English only, because the existing
|
||||
mailer does not localise (verification, password-reset and invitation hard-code lang="en" and the
|
||||
subjects are fixed English strings)
|
||||
And every state below is one Vitest spec and one row of `task visual:check -- account`, named as in
|
||||
the board, using the sample data of the frame (marta, marta@renew.example,
|
||||
marta.alves@atelier-renew.example)
|
||||
|
||||
Scenario Outline: Form states
|
||||
Given the user is on /profile
|
||||
When <trigger>
|
||||
Then the state "<state>" is visible with <copy>
|
||||
And <requests>
|
||||
|
||||
Examples:
|
||||
| state | trigger | copy | requests |
|
||||
| Default | the page loads, nothing pending | the Email field and the link "Change email" | none |
|
||||
| Form open | "Change email" is activated | "New email address", "Current password", the hint "We'll send a verification link to the new address. Your email stays the same until you open it.", "Cancel", "Send verification link"; focus on the email field | none |
|
||||
| Invalid format | "marta.alves@atelier" is typed and the field blurs or is submitted | "Enter a full email address, like name@example.com." | none |
|
||||
| Same as current | "marta@renew.example" is submitted | "That's already your email address." | none |
|
||||
| Password missing | submitted with an empty password | "Enter your current password.", focus on the password | none |
|
||||
| Wrong password (403) | POST answers 403 "wrong_password" | "That password isn't correct. Try again." under the password, password cleared and focused | one POST |
|
||||
| Sending | POST has not resolved | fields and button disabled, button reads "Sending…" | one POST |
|
||||
| Rate limited (429) | POST answers 429 | "Too many requests. Try again in a few minutes.", "Send verification link" disabled | one POST |
|
||||
| Server or network error | POST answers 500 or fails | "Something went wrong on our side. Nothing was changed. Try again in a moment." and the typed values kept | one POST |
|
||||
|
||||
Scenario: Pending, from a successful request
|
||||
When POST /v1/auth/email/change answers 200 with pending_email "marta.alves@atelier-renew.example"
|
||||
Then the form closes and the Email field still shows "marta@renew.example"
|
||||
And the card shows "Waiting for verification", "We sent a link to marta.alves@atelier-renew.example. Until you open it, you keep logging in with marta@renew.example.", "Resend link" and "Cancel change"
|
||||
And a toast "Verification link sent" / "Check marta.alves@atelier-renew.example. The link works once, within 2 hours." appears
|
||||
And reloading /profile with pending_email set shows the same Pending card without a toast
|
||||
|
||||
Scenario Outline: Pending actions
|
||||
Given the Pending card is shown
|
||||
When <trigger>
|
||||
Then <outcome>
|
||||
|
||||
Examples:
|
||||
| trigger | outcome |
|
||||
| "Resend link" is activated, in flight | state "Pending: resending": "Sending…" and the button disabled |
|
||||
| the resend answers 200 | state "Pending: resent": toast "Link sent again" / "The previous link no longer works." |
|
||||
| the resend answers 429 with Retry-After 270 | state "Pending: resend rate limited (429)": "Resend in 4:30" disabled, "Too many requests. Try again in a few minutes."; the count runs down from the header, not the wall clock |
|
||||
| "Cancel change" is activated | DELETE /v1/auth/email/change with no confirmation step, then state "Cancelled": the Default state and toast "Email change cancelled" / "You keep logging in with marta@renew.example." |
|
||||
|
||||
Rule: The verification landing /account/email/confirm?c=CODE (SPA)
|
||||
|
||||
Background:
|
||||
Given the route is a session route inside AuthCard; without a session it shows the inline login as
|
||||
/register/validate does, then continues the confirmation
|
||||
And the AuthCard states are those of the board group "Verify new email"
|
||||
|
||||
Scenario Outline: Landing states
|
||||
Given the user opens /account/email/confirm<query>
|
||||
When <trigger>
|
||||
Then the state "<state>" shows <copy>
|
||||
|
||||
Examples:
|
||||
| query | trigger | state | copy |
|
||||
| ?c=abc123 | POST /v1/auth/email/confirm unresolved | Confirming | "Confirming your new email…" |
|
||||
| ?c=abc123 | the POST answers 200 {"email": "marta.alves@atelier-renew.example"} | Success | "Your email address has changed", "Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in.", "Go to profile"; toast "Email address changed" / "Your account now uses marta.alves@atelier-renew.example."; the session email is replaced |
|
||||
| ?c=abc123 | the POST answers 403 | Invalid or expired (403) | "This link doesn't work", "It may have expired (links last 2 hours), been cancelled or already been used.", "Request a new link from your profile.", "Go to profile" |
|
||||
| ?c=abc123 | the POST answers 409 "email_unavailable" | Address unavailable (409) | "We couldn't change your email address", "This address can't be used for your account. Go back to your profile and try a different one.", "Go to profile" |
|
||||
| ?c=abc123 | no session (401 from /auth/me) | No session (401) | "Log in to confirm your new email", "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.", "Log in and confirm" |
|
||||
| | no c parameter | Missing code | "This link is incomplete", "The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.", "Go to log in"; no request is sent |
|
||||
| ?c=abc123 | the POST answers 500 or fails | Server error | "Couldn't confirm the change", "Something went wrong on our side. Nothing was changed. Try again in a moment.", "Try again", which sends the POST again |
|
||||
|
||||
Scenario: Log in and confirm
|
||||
Given the "No session (401)" state is shown
|
||||
When the user logs in inline with valid credentials
|
||||
Then POST /v1/auth/email/confirm is sent once with the code and the Success state follows
|
||||
And the code is not written to the URL history or logs by the SPA
|
||||
|
||||
Scenario: A confirmation is not repeated by reload noise
|
||||
Given the Success state was reached
|
||||
When the user reloads the page
|
||||
Then the POST is sent again and the "Invalid or expired (403)" state is shown, because the code is single use
|
||||
|
||||
Rule: Mail templates
|
||||
|
||||
Scenario Outline: Each template renders from its data
|
||||
When the mailer renders "<template>" with <data>
|
||||
Then the HTML contains <contains> and no unreplaced {{ }} action
|
||||
And the subject is "<subject>"
|
||||
|
||||
Examples:
|
||||
| template | data | contains | subject |
|
||||
| verify-new-email | Host https://cms.example.com, Username marta, VerificationCode abc | the link https://cms.example.com/account/email/confirm?c=abc | Confirm your new RenewCMS email address |
|
||||
| email-change-notice | Host https://cms.example.com, Username marta, NewEmail new@renew.example | new@renew.example and the link https://cms.example.com/profile | Your RenewCMS email address change was requested |
|
||||
|
||||
Rule: Documentation and privacy
|
||||
|
||||
Scenario: The docs describe the feature
|
||||
Then docs/api.md documents the four endpoints, their bodies, statuses and the pending_email field of /auth/me
|
||||
And docs/privacy.md says users can change their email themselves (art. 16), lists the three new users
|
||||
columns in the stored-data table, and removes "Changing an email address currently needs a database edit"
|
||||
and the email half of "No self-service export or email change" from Gaps
|
||||
Reference in new issue
Block a user