feat: invitation accept screen and api
This commit is contained in:
1 parent
310cfd528e
commit
55525c06d6
23 files changed
+2043
-11
No files matched your search
@@ -0,0 +1,158 @@
|
||||
Feature: An invited person accepts their invitation and gets a verified account
|
||||
|
||||
As a person who received an invitation email
|
||||
I want to open the link, see which address it was sent to, choose a username and password,
|
||||
and land logged in
|
||||
So that I get an account with exactly the same access as everyone else, while registration
|
||||
stays closed to anyone who was not invited
|
||||
|
||||
Background:
|
||||
Given GET /v1/auth/invite and POST /v1/auth/invite/accept are mounted in NewAuthRouter and are
|
||||
public: no jwt cookie is required and an existing jwt cookie is ignored
|
||||
And both are rate-limited per client IP with the auth rate limiter (AUTH_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
AUTH_RATE_LIMIT_WINDOW_SECONDS), counted independently of /login, /password-reset and
|
||||
POST /v1/user/invite
|
||||
And an invite is looked up by comparing the raw code against the stored hashed code with the
|
||||
same hash.Hasher used to create it, and the raw code is never stored
|
||||
And an invite is usable only when accepted_at is empty and expires_at is in the future
|
||||
And a code that matches no invite, matches an accepted invite, or is missing or empty answers
|
||||
404 with body apperr.InviteInvalidErrorMessage "This invite link doesn't work."
|
||||
And a code that matches an unaccepted invite whose expires_at is in the past answers 410 with
|
||||
body apperr.InviteExpiredErrorMessage "This invite has expired."
|
||||
And the POST body has the validate tags code required, username required,min=3,max=20 and
|
||||
password required,min=8, checked with validator.Validate.Struct before any lookup, and a
|
||||
failure answers 400 with the validator's text; an unparseable body answers 400 with
|
||||
apperr.BodyErrorMessage
|
||||
And error responses are plain text from http.Error, success responses are JSON
|
||||
And accepting creates a user with the invite's email (already lower case), the chosen username,
|
||||
the hashed password, is_verified true, an empty verification code, and an empty display name
|
||||
And the created user has the same access as any other verified user: no roles, no ownership
|
||||
And the jwt cookie is set exactly as registration sets it: HttpOnly, path "/", 24 hours, with
|
||||
claims user_id and token_version, and Secure in production
|
||||
|
||||
Scenario: The invited email is read from a valid code
|
||||
Given a pending invite for "lea.martin@renew.example" whose raw code is "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
|
||||
When the client sends GET /v1/auth/invite?c=3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42
|
||||
Then the response status is 200
|
||||
And the response body is a JSON object with exactly the key email, with value "lea.martin@renew.example"
|
||||
And the response has Cache-Control "no-store"
|
||||
And the invite is unchanged
|
||||
|
||||
Scenario Outline: A link that does not work is reported without saying why
|
||||
Given a pending invite for "lea.martin@renew.example", an accepted invite for "sam@renew.example"
|
||||
and an expired invite for "p.huber@gmail.com", each with its own raw code
|
||||
When the client sends GET /v1/auth/invite<query>
|
||||
Then the response status is <status>
|
||||
And the response body is "<body>"
|
||||
|
||||
Examples:
|
||||
| query | status | body |
|
||||
| ?c=not-a-known-code | 404 | This invite link doesn't work. |
|
||||
| ?c=<accepted invite code> | 404 | This invite link doesn't work. |
|
||||
| ?c= | 404 | This invite link doesn't work. |
|
||||
| | 404 | This invite link doesn't work. |
|
||||
| ?c=<expired invite code> | 410 | This invite has expired. |
|
||||
|
||||
Scenario: Accepting creates a verified user, logs them in and closes the invite
|
||||
Given a verified user "marta" and a pending invite for "Lea.Martin@Renew.example" created at
|
||||
invitation time, with raw code "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
|
||||
When the client sends POST /v1/auth/invite/accept with body
|
||||
{"code": "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42", "username": "lea", "password": "limewash2026"}
|
||||
Then the response status is 200
|
||||
And the response body is a JSON object {"message": "Invite accepted! HTTPonly jwt cookie created"}
|
||||
And a jwt cookie is set, and decoding it gives the new user's id and token_version
|
||||
And the user repository holds a user "lea" with email "lea.martin@renew.example", is_verified
|
||||
true, an empty verification_code and a stored password that is a hash, not "limewash2026"
|
||||
And the invite now has accepted_at set and its status is "accepted"
|
||||
And no email was sent
|
||||
|
||||
Scenario: The new account works like any other account
|
||||
Given the invite from the previous scenario was accepted and the jwt cookie was kept
|
||||
When the client sends GET /v1/auth/me and GET /v1/user/ with that cookie
|
||||
Then both responses are 200
|
||||
And the GET /v1/user/ list shows user "lea" with status "accepted" and no invite for
|
||||
"lea.martin@renew.example"
|
||||
|
||||
Scenario: An accepted code cannot be used again
|
||||
Given an invite that was just accepted by "lea"
|
||||
When the client sends POST /v1/auth/invite/accept with the same code, username "lea2" and
|
||||
password "limewash2026"
|
||||
Then the response status is 404
|
||||
And the response body is "This invite link doesn't work."
|
||||
And no user "lea2" exists and no jwt cookie is set
|
||||
When the client sends GET /v1/auth/invite with the same code
|
||||
Then the response status is 404
|
||||
|
||||
Scenario Outline: A code that cannot be used creates nothing
|
||||
Given <state>
|
||||
When the client sends POST /v1/auth/invite/accept with a body using <code> and username "lea" and password "limewash2026"
|
||||
Then the response status is <status>
|
||||
And the response body is "<body>"
|
||||
And no user was created, no jwt cookie is set and no invite changed
|
||||
|
||||
Examples:
|
||||
| state | code | status | body |
|
||||
| no invite has that code | an unknown one | 404 | This invite link doesn't work. |
|
||||
| an invite that expired an hour ago | its code | 410 | This invite has expired. |
|
||||
|
||||
Scenario: A taken username is refused and the invite stays usable
|
||||
Given a verified user "marta" and a pending invite for "lea.martin@renew.example"
|
||||
When the client sends POST /v1/auth/invite/accept with username "marta" and password "limewash2026"
|
||||
Then the response status is 409
|
||||
And the response body is "That username is taken. Try another."
|
||||
And no jwt cookie is set and the invite is still pending
|
||||
When the client resends with username "lea"
|
||||
Then the response status is 200
|
||||
|
||||
Scenario: An address that got an account in the meantime is refused
|
||||
Given a pending invite for "lea.martin@renew.example" and an unverified user "leam" with email "Lea.Martin@Renew.example"
|
||||
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
|
||||
Then the response status is 409
|
||||
And the response body is exactly apperr.EmailHasAccountErrorMessage
|
||||
And no user "lea" was created, no jwt cookie is set and the invite is still pending
|
||||
|
||||
Scenario Outline: An invalid body is rejected before the code is looked up
|
||||
Given a pending invite with a known code
|
||||
When the client sends POST /v1/auth/invite/accept with body <body>
|
||||
Then the response status is 400
|
||||
And no user was created and the invite is still pending
|
||||
|
||||
Examples:
|
||||
| body |
|
||||
| {"code": "<code>", "username": "le", "password": "limewash2026"} |
|
||||
| {"code": "<code>", "username": "abcdefghijklmnopqrstu", "password": "limewash2026"} |
|
||||
| {"code": "<code>", "username": "lea", "password": "lime"} |
|
||||
| {"code": "<code>", "password": "limewash2026"} |
|
||||
| {"username": "lea", "password": "limewash2026"} |
|
||||
| not json |
|
||||
|
||||
Scenario: An existing jwt cookie is ignored and replaced
|
||||
Given a valid jwt cookie for verified user "marta" and a pending invite for "lea.martin@renew.example"
|
||||
When the client sends POST /v1/auth/invite/accept with that cookie and username "lea", password "limewash2026"
|
||||
Then the response status is 200
|
||||
And the Set-Cookie jwt carries the new user's id, not marta's
|
||||
|
||||
Scenario: A failure after the user is created leaves the invite pending and no user behind
|
||||
Given a pending invite and the invite repository set to fail marking it accepted
|
||||
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
|
||||
Then the response status is 500
|
||||
And no user "lea" exists, no jwt cookie is set and the invite is still pending
|
||||
|
||||
Scenario: The accept endpoints are rate-limited
|
||||
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 2 for a window covering the whole test
|
||||
When the client at IP "203.0.113.30" sends 2 requests to POST /v1/auth/invite/accept and then a 3rd
|
||||
Then the 3rd response is 429 with body apperr.TooManyRequestsErrorMessage and a Retry-After header
|
||||
And GET /v1/auth/registration from the same client is not rejected with 429
|
||||
|
||||
Scenario: Registration stays closed for everyone else
|
||||
Given a verified user and an accepted invite
|
||||
When the client sends POST /v1/auth/register with username "sam", password "hunter2example12",
|
||||
email "sam@renew.example"
|
||||
Then the response status is 403
|
||||
And the response body is exactly apperr.RegistrationClosedErrorMessage
|
||||
And GET /v1/auth/registration still reports {"open": false}
|
||||
|
||||
Scenario: The invite response never leaks the code or other invite data
|
||||
Given a pending invite
|
||||
When the client sends GET /v1/auth/invite with its code
|
||||
Then the body contains no key named code, hashed_code, invited_by, expires_at, id or accepted_at
|
||||
@@ -0,0 +1,39 @@
|
||||
# QA procedure: accept invitation (API)
|
||||
|
||||
Package `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo`, `mockInviteRepo` (extend it with an accept method and a `failSwitch` entry for it; type names stay unique in the package).
|
||||
|
||||
## Setup shared by every case
|
||||
1. `validator.InitValidator()` once.
|
||||
2. Use cases: the accept use case (constructor takes only domain interfaces: user repo, invite repo, hasher; final names are the coder's), a get-invite use case for GET, plus `CreateUser`, `GetUser`, `ListUsersAndInvites` for the regression cases.
|
||||
3. Handler built with `NewHandler(...)` over the use cases, mounted on a fresh `chi.NewRouter()` mirroring `NewAuthRouter`: `GET /invite` and `POST /invite/accept` inside a group with `TrustedProxyRealIP(nil)` + `NewRateLimiter(...)`; `/register`, `/registration` and (with `jwtauth.Verifier`, `Authenticator`, `RequireCurrentToken`) `/me` for regression. `jwtauth.New("HS256", []byte("test-secret"), nil)`. `httptest` only.
|
||||
4. Seed invites via `mockInviteRepo` with `HashedCode == "hashed:"+rawCode` (stubHasher); times relative to `time.Now()`, 5 s tolerance.
|
||||
|
||||
## GET /v1/auth/invite
|
||||
5. Valid code -> 200; decode `map[string]json.RawMessage`: keys exactly `email`; value the stored lower-case email; `Cache-Control: no-store`; invite unchanged. Raw body contains none of `code`, `hashed_code`, `invited_by`, `expires_at`, `id`, `accepted_at`.
|
||||
6. Table: unknown code, accepted invite code, `?c=`, no query -> 404 with body exactly `apperr.InviteInvalidErrorMessage`; expired code -> 410 with `apperr.InviteExpiredErrorMessage`.
|
||||
|
||||
## POST /v1/auth/invite/accept
|
||||
7. Happy path: body `{code, username:"lea", password:"limewash2026"}` -> 200, `{"message":…}`. `Set-Cookie` `jwt` HttpOnly, Path "/", decode via `jwtauth` to get `user_id` and `token_version` of the created user. `userRepo`: user "lea", `Email=="lea.martin@renew.example"`, `IsVerified`, empty `VerificationCode`, password `"hashed:limewash2026"`. Invite `AcceptedAt` not nil. `SentEmails` empty.
|
||||
8. Follow-up with the cookie: `GET /me` -> 200; list use case shows user "lea" and no invite for the address.
|
||||
9. Repeat POST with the same code -> 404 `InviteInvalid`, no user "lea2", no `Set-Cookie`; GET same code -> 404.
|
||||
10. Table for 404 (unknown code) and 410 (expired an hour ago): exact bodies, no user, no cookie, invites unchanged.
|
||||
11. Username "marta" taken -> 409, body `apperr.UsernameTakenErrorMessage` ("That username is taken. Try another."), no cookie, invite still pending; retry with "lea" -> 200.
|
||||
12. Unverified user with email `Lea.Martin@Renew.example` present -> 409, body `apperr.EmailHasAccountErrorMessage`, no user created, invite pending, no cookie.
|
||||
13. 400 table (username 2 chars, 21 chars, password 4 chars, no username, no code, `not json`): status 400, user repo and invite unchanged. Code lookup is not reached (assert the invite repo saw no accept call).
|
||||
14. Cookie of verified user 7 sent with a valid accept -> 200 and the new cookie's `user_id` is the new user's.
|
||||
15. Invite repo accept method set to fail (`failSwitch`) -> 500, no user "lea" remains (user repo `Delete` was called), invite pending, no cookie.
|
||||
16. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.30:1234`, three POSTs -> third is 429 with body `apperr.TooManyRequestsErrorMessage` and `Retry-After`; `GET /registration` afterwards is not 429.
|
||||
|
||||
## Regression
|
||||
17. Verified user present: `POST /register` -> 403 body `apperr.RegistrationClosedErrorMessage`, no user created; `GET /registration` -> `{"open":false}` after an invite was accepted. Empty user repo -> `POST /register` still 200 with a `verification` mail.
|
||||
|
||||
## Use case level (no HTTP)
|
||||
18. Accept use case: unknown or empty code -> `errors.Is(err, invite.ErrInviteNotFound)`; expired -> an expired sentinel; taken username -> a username-taken sentinel; email owned -> `invite.ErrEmailHasAccount` (sentinel names are the coder's). Domain sentinels asserted with `errors.Is`.
|
||||
|
||||
## Open questions (no design state, not invented)
|
||||
- GET /v1/auth/invite failing on load with a 500 or a network error: the design has no state for it.
|
||||
- 429 on POST /v1/auth/invite/accept: the SPA shows the design's server-error alert ("Couldn't create the account").
|
||||
|
||||
## Manual verification
|
||||
- Fresh SQLite file: invite acceptance persists across restart, `accepted_at` stored, unique username constraint holds; send a real invitation and open its link end to end (real SMTP, real embedded SPA).
|
||||
- Concurrency: two simultaneous accepts of one code create one user (not exercisable with the in-memory fakes).
|
||||
@@ -0,0 +1,105 @@
|
||||
Feature: Accept invite screen
|
||||
|
||||
As a person who opened the link in the invitation email
|
||||
I want /invite/accept?c=CODE to show me the invited address and let me choose a username and
|
||||
password
|
||||
So that I finish setting up my account and arrive logged in
|
||||
|
||||
Background:
|
||||
Given /invite/accept is a public route (meta.auth "public") rendered as AcceptInvitePage inside
|
||||
AuthShell, per docs/design/routes.md
|
||||
And on mount AcceptInvitePage reads the "c" query parameter once: if it is absent or empty no
|
||||
request is made and the "Invalid or used link (404)" state renders
|
||||
And otherwise it calls GET /v1/auth/invite?c=CODE and shows "Checking the link" until it resolves
|
||||
And the Username and Password rules are Register's, including PasswordStrengthHint, with no
|
||||
confirm field; validation runs on blur and on submit and nothing is sent when it fails
|
||||
And a submit that passes calls POST /v1/auth/invite/accept with { code: c, username, password }
|
||||
And a logged-in visitor sees the same screen: the route stays public and nothing redirects
|
||||
And the board is docs/design/source/RenewCMS Screens Auth.dc.html, group "Accept invite"; the
|
||||
board frame decides layout and sample data (lea.martin@renew.example, "lea", "limewash2026",
|
||||
"marta")
|
||||
|
||||
Scenario: Checking the link
|
||||
When the user opens /invite/accept?c=abc123 and GET /v1/auth/invite has not resolved
|
||||
Then a full-card spinner reads "Checking your invite…"
|
||||
|
||||
Scenario: Default
|
||||
Given GET /v1/auth/invite?c=abc123 resolves 200 with {"email": "lea.martin@renew.example"}
|
||||
Then the heading is "Accept your invite"
|
||||
And the description is "Choose a username and password to finish setting up your account."
|
||||
And the Email field has placeholder "you@example.com", shows "lea.martin@renew.example", is
|
||||
read-only, with help "From your invitation."
|
||||
And the Username field has placeholder "e.g. lea", counter "0 / 20" and help "Letters and numbers,
|
||||
3 to 20 characters. You'll use it to log in."
|
||||
And the Password field is a password input with show/hide and checklist "At least 8 characters" unmet
|
||||
And the submit button reads "Accept invite"
|
||||
And the footer reads "Already have an account?" with a "Log in" link to /login
|
||||
|
||||
Scenario: Validation errors
|
||||
Given the form is shown, Username is "ma" and Password is "lime"
|
||||
When the user blurs both fields and submits
|
||||
Then POST /v1/auth/invite/accept is never called
|
||||
And the Username shows counter "2 / 20" in error and the error "Use 3 to 20 characters."
|
||||
And the checklist item "At least 8 characters" is in its error state
|
||||
|
||||
Scenario: Filled, valid
|
||||
Given Username is "lea" and Password is "limewash2026" with the password shown
|
||||
Then the counter reads "3 / 20", the checklist item "At least 8 characters" is met and no error shows
|
||||
|
||||
Scenario: Submitting
|
||||
When the user submits valid values and POST /v1/auth/invite/accept has not resolved
|
||||
Then the fields and button are disabled
|
||||
And the submit button reads "Creating account…" and is loading
|
||||
|
||||
Scenario: Username taken (409)
|
||||
When POST /v1/auth/invite/accept resolves 409 with body "That username is taken. Try another."
|
||||
Then the Username keeps "marta", shows counter "5 / 20", is focused and shows the error
|
||||
"That username is taken. Try another."
|
||||
And the Password keeps its value, the form re-enables and the button reads "Accept invite"
|
||||
|
||||
Scenario Outline: The link does not work
|
||||
When <trigger>
|
||||
Then no form is shown
|
||||
And a ResultPanel (tone error, icon i-lucide-link-2-off) reads "This invite link doesn't work"
|
||||
with text "It may be incomplete, or it may already have been used. If you already set up your
|
||||
account, log in instead." and note "Open the link from your email again, or copy the full
|
||||
address into your browser."
|
||||
And its only action is a primary "Go to log in" that navigates to /login
|
||||
|
||||
Examples:
|
||||
| trigger |
|
||||
| the user opens /invite/accept with no c parameter (no request is made) |
|
||||
| GET /v1/auth/invite?c=abc123 resolves 404 |
|
||||
| the submit resolves 404, replacing the form |
|
||||
| the submit resolves 409 with apperr.EmailHasAccountErrorMessage |
|
||||
|
||||
Scenario Outline: Expired link (410)
|
||||
When <trigger>
|
||||
Then no form is shown
|
||||
And a ResultPanel (tone error, icon i-lucide-clock) reads "This invite has expired" with text
|
||||
"Invites work once, for 3 days. Ask the person who invited you to send a new one."
|
||||
And its only action is a secondary "Go to log in" that navigates to /login
|
||||
|
||||
Examples:
|
||||
| trigger |
|
||||
| GET /v1/auth/invite?c=abc123 resolves 410 |
|
||||
| the submit resolves 410, replacing the form |
|
||||
|
||||
Scenario Outline: Server error
|
||||
When the submit <failure>
|
||||
Then an error Alert (icon i-lucide-circle-x) reads "Couldn't create the account" with "Something
|
||||
went wrong on the server. Try again in a moment." above the heading
|
||||
And the form is still shown with Email, Username "lea" and Password "limewash2026" kept, re-enabled
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves 500 |
|
||||
| resolves 429 |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Success
|
||||
When POST /v1/auth/invite/accept resolves 200 with the jwt cookie
|
||||
Then a ResultPanel (tone success, icon i-lucide-circle-check) reads "Your account is ready" with
|
||||
text "Taking you to your dashboard…"
|
||||
And a Toast (icon i-lucide-circle-check) reads "Welcome to RenewCMS" with "You're logged in as lea."
|
||||
And after 800 ms the app navigates to /home with no verification step
|
||||
@@ -0,0 +1,26 @@
|
||||
# QA: Accept invite screen
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
Mock `useApi` for GET `/v1/auth/invite` and POST `/v1/auth/invite/accept` per case; mount AcceptInvitePage inside AuthShell with a test router at `/invite/accept?c=abc123`. Use fake timers for the 800 ms redirect only; never freeze the date.
|
||||
1. Checking the link: GET unresolved -> "Checking your invite…" spinner.
|
||||
2. Default: GET 200 `{email:"lea.martin@renew.example"}` -> heading, description, read-only Email with help, Username "0 / 20" and its help, Password checklist unmet, button "Accept invite", footer "Log in" -> /login.
|
||||
3. Validation errors: Username "ma", Password "lime", blur + submit -> no POST, "Use 3 to 20 characters.", counter "2 / 20" error, checklist error.
|
||||
4. Filled, valid: "lea" / "limewash2026" shown -> "3 / 20", checklist met.
|
||||
5. Submitting: POST unresolved -> fields disabled, "Creating account…" loading.
|
||||
6. Username taken: POST 409 body "That username is taken. Try another." -> value "marta" kept, "5 / 20", focus on Username, error copy, password kept, form re-enabled.
|
||||
7. Link does not work (table): no `c` (no request at all), GET 404, POST 404, POST 409 with `This address already has an account.` -> ResultPanel link-2-off with the title, text, note and one primary "Go to log in"; click navigates to /login.
|
||||
8. Expired (table): GET 410, POST 410 -> ResultPanel clock, title, text, one secondary "Go to log in".
|
||||
9. Server error (table): POST 500, POST 429, rejected fetch -> Alert "Couldn't create the account" + description, form kept.
|
||||
10. Success: POST 200 -> ResultPanel "Your account is ready", Toast "Welcome to RenewCMS" / "You're logged in as lea."; advance 800 ms -> router path is /home.
|
||||
11. Logged-in visitor: session store holds a verified user; mounting shows the Default state, no redirect.
|
||||
|
||||
## Open questions (no design state, not invented)
|
||||
- GET /v1/auth/invite failing on load with a 500 or a network error: the design has no state for it.
|
||||
|
||||
## Visual (`task visual:check -- accept-invite`, desktop and mobile, light and dark; hard gate 10 %)
|
||||
Sample data from the board frame: email `lea.martin@renew.example`; username `lea` (taken state: `marta`); password `limewash2026`; validation state username `ma`, password `lime`. States: Checking the link, Default, Validation errors, Filled valid, Submitting, Username taken (409), Invalid or used link (404), Expired link (410), Server error, Success. `skip: clock-sensitive`: none (expiry is driven by the 410 status, not the clock). Layout placement comes from the board frame markup, not from components.md prose.
|
||||
|
||||
## Manual checks (not covered by screenshots)
|
||||
- Keyboard: Tab order Username -> Password -> show/hide -> Accept invite -> Log in; Username is focused after a 409; the ResultPanel title takes focus on 404/410.
|
||||
- Password show/hide and hover states; the 800 ms hand-off; the Toast disappearing.
|
||||
- Real e2e: open the emailed link against `task dev`.
|
||||
Reference in new issue
Block a user