feat: invitation accept screen and api

This commit is contained in:
fsylvain committed 2026-09-29 09:21:53 +02:00
1 parent 310cfd528e
commit 55525c06d6
23 files changed
+2043 -11

No files matched your search

+4
View File
@@ -38,6 +38,8 @@ type UseCases struct {
ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase
@@ -98,6 +100,8 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
+102
View File
@@ -0,0 +1,102 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/jwtauth/v5"
)
type AcceptInviteHandler struct {
Token *jwtauth.JWTAuth
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
IsProduction bool
}
type AcceptInviteRequest struct {
Code string `json:"code" validate:"required"`
Username string `json:"username" validate:"required,min=3,max=20"`
Password string `json:"password" validate:"required,min=8"`
}
type inviteEmailResponse struct {
Email string `json:"email"`
}
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *jwtauth.JWTAuth, isProduction bool) *AcceptInviteHandler {
return &AcceptInviteHandler{
Token: token,
GetInviteUseCase: getInvite,
AcceptInviteUseCase: acceptInvite,
IsProduction: isProduction,
}
}
func (h *AcceptInviteHandler) GetInvite(w http.ResponseWriter, r *http.Request) {
found, err := h.GetInviteUseCase.GetInvite(r.URL.Query().Get("c"))
if err != nil {
writeAcceptInviteError(w, err)
return
}
body, err := json.Marshal(inviteEmailResponse{Email: found.Email})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func (h *AcceptInviteHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
var request AcceptInviteRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
createdUser, err := h.AcceptInviteUseCase.AcceptInvite(useCases.AcceptInviteCommand{
Code: request.Code,
Username: request.Username,
Password: request.Password,
})
if err != nil {
writeAcceptInviteError(w, err)
return
}
if err := setJwtCookie(w, h.Token, h.IsProduction, createdUser); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
message, _ := json.Marshal(map[string]any{"message": "Invite accepted! HTTPonly jwt cookie created"})
_, _ = w.Write(message)
}
func writeAcceptInviteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, invite.ErrInviteInvalid):
http.Error(w, apperr.InviteInvalidErrorMessage, http.StatusNotFound)
case errors.Is(err, invite.ErrInviteExpired):
http.Error(w, apperr.InviteExpiredErrorMessage, http.StatusGone)
case errors.Is(err, user.ErrUsernameTaken):
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+8 -4
View File
@@ -52,22 +52,26 @@ func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
})
}
func (h *LoginHandler) SetJwtCookie(w *http.ResponseWriter, u user.User) error {
_, tokenString, err := h.Token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error {
_, tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
if err != nil {
return err
}
http.SetCookie(*w, &http.Cookie{
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Value: tokenString,
Expires: time.Now().Add(24 * time.Hour),
Secure: h.IsProduction,
Secure: isProduction,
HttpOnly: true,
Path: "/",
})
return nil
}
func (h *LoginHandler) SetJwtCookie(w *http.ResponseWriter, u user.User) error {
return setJwtCookie(*w, h.Token, h.IsProduction, u)
}
func (h *LoginHandler) Login(w http.ResponseWriter, r *http.Request) {
var credentials LoginCredentials
+3
View File
@@ -19,3 +19,6 @@ const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
const EmailHasAccountErrorMessage = "This address already has an account."
const PendingInviteErrorMessage = "There's already a pending invite for this address."
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired."
const UsernameTakenErrorMessage = "That username is taken. Try another."
+7
View File
@@ -62,6 +62,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
meHandler := auth.NewMeHandler(container.GetUserUseCase)
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
r.Post("/register", loginHandler.Register)
r.Get("/registration", loginHandler.RegistrationStatus)
@@ -77,6 +78,12 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
})
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Get("/invite", acceptInviteHandler.GetInvite)
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
+3 -3
View File
@@ -27,9 +27,9 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login` or `POST /v1/auth/password-reset` within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the two routes above. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for `POST /v1/auth/login` and `POST /v1/auth/password-reset` rate limiting. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
+158
View File
@@ -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
+39
View File
@@ -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).
+105
View File
@@ -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
+26
View File
@@ -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`.
+2
View File
@@ -9,6 +9,8 @@ var ErrEmailHasAccount = errors.New("email already has an account")
var ErrPendingInvite = errors.New("pending invite already exists")
var ErrInviteNotFound = errors.New("invite not found")
var ErrInviteEmailNotSent = errors.New("invite email not sent")
var ErrInviteInvalid = errors.New("invite invalid")
var ErrInviteExpired = errors.New("invite expired")
const Lifetime = 3 * 24 * time.Hour
@@ -1,7 +1,10 @@
package invite
import "time"
type Repository interface {
Create(invite Invite) (Invite, error)
GetAll() []Invite
Delete(id uint32) error
MarkAccepted(id uint32, acceptedAt time.Time) error
}
+1
View File
@@ -14,6 +14,7 @@ var ErrAlreadyVerified = errors.New("user already verified")
var ErrInvalidPasswordResetCode = errors.New("invalid password reset code")
var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
var ErrInvalidDisplayName = errors.New("invalid display name")
var ErrUsernameTaken = errors.New("username taken")
type User struct {
ID uint32 `json:"id"`
@@ -4,6 +4,7 @@ import (
domainInvite "RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"time"
"gorm.io/gorm"
)
@@ -43,4 +44,9 @@ func (i *InviteRepository) Delete(id uint32) error {
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
}
func (i *InviteRepository) MarkAccepted(id uint32, acceptedAt time.Time) error {
result := i.db.Model(&entity.Invite{}).Where("id = ?", id).Update("accepted_at", acceptedAt)
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
}
var _ domainInvite.Repository = &InviteRepository{}
@@ -33,6 +33,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
Password: user.Password,
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
Email: user.Email,
IsVerified: user.IsVerified,
VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
VerificationExpiration: user.VerificationExpiration,
})
@@ -0,0 +1,87 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"errors"
"strings"
"time"
)
type AcceptInviteUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
hasher hash.Hasher
}
type AcceptInviteCommand struct {
Code string
Username string
Password string
}
func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher) *AcceptInviteUseCase {
return &AcceptInviteUseCase{userRepository, inviteRepository, hasher}
}
func (a *AcceptInviteUseCase) AcceptInvite(command AcceptInviteCommand) (user.User, error) {
now := time.Now()
pending, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now)
if err != nil {
return user.User{}, err
}
if a.emailHasAccount(pending.Email) {
return user.User{}, invite.ErrEmailHasAccount
}
if err := a.ensureUsernameFree(command.Username); err != nil {
return user.User{}, err
}
hashedPassword, err := a.hasher.Hash(command.Password)
if err != nil {
return user.User{}, err
}
createdUser, err := a.userRepository.Create(user.User{
Username: command.Username,
Password: hashedPassword,
Email: pending.Email,
IsVerified: true,
})
if err != nil {
return user.User{}, err
}
if err := a.inviteRepository.MarkAccepted(pending.ID, now); err != nil {
if deleteErr := a.userRepository.Delete(createdUser.ID); deleteErr != nil {
return user.User{}, errors.Join(err, deleteErr)
}
return user.User{}, err
}
return createdUser, nil
}
func (a *AcceptInviteUseCase) emailHasAccount(email string) bool {
for _, existingUser := range a.userRepository.GetAll() {
if strings.EqualFold(existingUser.Email, email) {
return true
}
}
return false
}
func (a *AcceptInviteUseCase) ensureUsernameFree(username string) error {
_, err := a.userRepository.GetByUsername(username)
if err == nil {
return user.ErrUsernameTaken
}
if errors.Is(err, user.ErrUserNotFound) {
return nil
}
return err
}
@@ -0,0 +1,41 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"time"
)
type GetInviteUseCase struct {
inviteRepository invite.Repository
hasher hash.Hasher
}
func NewGetInviteUseCase(inviteRepository invite.Repository, hasher hash.Hasher) *GetInviteUseCase {
return &GetInviteUseCase{inviteRepository, hasher}
}
func (g *GetInviteUseCase) GetInvite(code string) (invite.Invite, error) {
return findUsableInvite(g.inviteRepository, g.hasher, code, time.Now())
}
func findUsableInvite(inviteRepository invite.Repository, hasher hash.Hasher, code string, now time.Time) (invite.Invite, error) {
if code == "" {
return invite.Invite{}, invite.ErrInviteInvalid
}
for _, candidate := range inviteRepository.GetAll() {
if candidate.AcceptedAt != nil {
continue
}
if hasher.Compare(candidate.HashedCode, code) != nil {
continue
}
if candidate.StatusAt(now) == invite.StatusExpired {
return invite.Invite{}, invite.ErrInviteExpired
}
return candidate, nil
}
return invite.Invite{}, invite.ErrInviteInvalid
}
+608
View File
@@ -0,0 +1,608 @@
package test
import (
"RenewCMS/api/handlers/auth"
userhandler "RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const (
acceptCode = "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
acceptEmail = "lea.martin@renew.example"
acceptPassword = "limewash2026"
acceptedInviteMsg = `{"message":"Invite accepted! HTTPonly jwt cookie created"}`
inviteInvalidBody = apperr.InviteInvalidErrorMessage + "\n"
inviteExpiredBody = apperr.InviteExpiredErrorMessage + "\n"
usernameTakenBody = apperr.UsernameTakenErrorMessage + "\n"
emailHasAccountBody = apperr.EmailHasAccountErrorMessage + "\n"
)
type acceptInviteFixture struct {
users *mockUserRepo
invites *inviteFakeRepo
mail *mockMailRepo
hasher *stubHasher
router *chi.Mux
token *jwtauth.JWTAuth
}
func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture {
t.Helper()
validator.InitValidator()
f := &acceptInviteFixture{
users: inviteInviterRepo(""),
invites: newInviteFakeRepo(),
mail: newMockMailRepo(),
hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
}
getUser := useCases.NewGetUserUseCase(f.users)
acceptHandler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher),
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser)
listHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
)
registerHandler := auth.NewLoginHandler(
f.token,
useCases.NewCreateUserUseCase(f.users, f.hasher, f.mail, invitePublicURL),
useCases.NewAuthenticateUserUseCase(f.users, f.hasher),
true,
)
f.router = chi.NewRouter()
f.router.Route("/v1/auth", func(r chi.Router) {
r.Post("/register", registerHandler.Register)
r.Get("/registration", registerHandler.RegistrationStatus)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(nil))
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
r.Get("/invite", acceptHandler.GetInvite)
r.Post("/invite/accept", acceptHandler.AcceptInvite)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me)
})
})
f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", listHandler.ListUsers)
})
return f
}
func (f *acceptInviteFixture) seedWithCode(base invite.Invite, code string) invite.Invite {
base.HashedCode = "hashed:" + code
return f.invites.seed(base)
}
func (f *acceptInviteFixture) seedPending() invite.Invite {
return f.seedWithCode(pendingInvite(acceptEmail), acceptCode)
}
func (f *acceptInviteFixture) do(method, target, body string, cookie *http.Cookie, remoteAddr string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
req.RemoteAddr = remoteAddr + ":4321"
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
return rec
}
func (f *acceptInviteFixture) get(target string) *httptest.ResponseRecorder {
return f.do(http.MethodGet, target, "", nil, "203.0.113.1")
}
func (f *acceptInviteFixture) accept(body string) *httptest.ResponseRecorder {
return f.do(http.MethodPost, "/v1/auth/invite/accept", body, nil, "203.0.113.1")
}
func acceptBody(code, username, password string) string {
return `{"code": "` + code + `", "username": "` + username + `", "password": "` + password + `"}`
}
func jwtCookieOf(rec *httptest.ResponseRecorder) *http.Cookie {
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == "jwt" {
return cookie
}
}
return nil
}
func (f *acceptInviteFixture) userNamed(username string) (user.User, bool) {
for _, u := range f.users.users {
if u.Username == username {
return u, true
}
}
return user.User{}, false
}
func assertNoAcceptSideEffects(t *testing.T, f *acceptInviteFixture, rec *httptest.ResponseRecorder, usersBefore int) {
t.Helper()
if len(f.users.users) != usersBefore {
t.Errorf("no user may be created, have %d want %d", len(f.users.users), usersBefore)
}
if jwtCookieOf(rec) != nil {
t.Errorf("no jwt cookie may be set")
}
for _, stored := range f.invites.invites {
if stored.AcceptedAt != nil {
t.Errorf("no invite may be accepted, got %+v", stored)
}
}
}
func TestAcceptInvitationHttp(t *testing.T) {
t.Run("The invited email is read from a valid code", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
stored := f.seedPending()
rec := f.get("/v1/auth/invite?c=" + acceptCode)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeKeys(t, rec.Body.Bytes())
assertExactKeys(t, decoded, "email")
if decodedString(t, decoded, "email") != acceptEmail {
t.Errorf("unexpected body %s", rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Errorf("expected no-store, got %q", rec.Header().Get("Cache-Control"))
}
if f.invites.invites[stored.ID] != stored {
t.Errorf("invite must be unchanged")
}
})
t.Run("A link that does not work is reported without saying why", func(t *testing.T) {
cases := []struct {
name, query string
status int
body string
}{
{"unknown code", "?c=not-a-known-code", http.StatusNotFound, inviteInvalidBody},
{"accepted code", "?c=accepted-code", http.StatusNotFound, inviteInvalidBody},
{"accepted and expired code", "?c=accepted-expired-code", http.StatusNotFound, inviteInvalidBody},
{"empty code", "?c=", http.StatusNotFound, inviteInvalidBody},
{"no query", "", http.StatusNotFound, inviteInvalidBody},
{"expired code", "?c=expired-code", http.StatusGone, inviteExpiredBody},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedWithCode(pendingInvite(acceptEmail), acceptCode)
acceptedAt := time.Now().Add(-time.Hour)
accepted := pendingInvite("sam@renew.example")
accepted.AcceptedAt = &acceptedAt
f.seedWithCode(accepted, "accepted-code")
acceptedExpired := expiredInvite("old@renew.example")
acceptedExpired.AcceptedAt = &acceptedAt
f.seedWithCode(acceptedExpired, "accepted-expired-code")
f.seedWithCode(expiredInvite("p.huber@gmail.com"), "expired-code")
rec := f.get("/v1/auth/invite" + tc.query)
if rec.Code != tc.status || rec.Body.String() != tc.body {
t.Errorf("expected %d %q, got %d %q", tc.status, tc.body, rec.Code, rec.Body.String())
}
})
}
})
t.Run("Accepting creates a verified user, logs them in and closes the invite", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
stored := f.seedWithCode(pendingInvite("Lea.Martin@Renew.example"), acceptCode)
stored.Email = "lea.martin@renew.example"
f.invites.invites[stored.ID] = stored
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
if rec.Code != http.StatusOK || rec.Body.String() != acceptedInviteMsg {
t.Fatalf("expected 200 message, got %d: %s", rec.Code, rec.Body.String())
}
created, ok := f.userNamed("lea")
if !ok {
t.Fatalf("user lea must exist")
}
if created.Email != acceptEmail || !created.IsVerified || created.VerificationCode != "" || created.DisplayName != "" {
t.Errorf("unexpected user %+v", created)
}
if created.Password == acceptPassword || created.Password != "hashed:"+acceptPassword {
t.Errorf("password must be stored hashed, got %q", created.Password)
}
if got := f.invites.invites[stored.ID]; got.AcceptedAt == nil || got.StatusAt(time.Now()) != invite.StatusAccepted {
t.Errorf("invite must be accepted, got %+v", got)
}
if len(f.mail.SentEmails) != 0 {
t.Errorf("no email may be sent")
}
cookie := jwtCookieOf(rec)
if cookie == nil || !cookie.HttpOnly || !cookie.Secure || cookie.Path != "/" {
t.Fatalf("expected an HttpOnly Secure jwt cookie on /, got %+v", cookie)
}
if lifetime := time.Until(cookie.Expires); lifetime < 23*time.Hour || lifetime > 24*time.Hour+time.Minute {
t.Errorf("expected a 24 hour cookie, got %v", lifetime)
}
decoded, err := f.token.Decode(cookie.Value)
if err != nil {
t.Fatalf("cookie does not decode: %v", err)
}
claims, _ := decoded.AsMap(context.Background())
if claims["user_id"] != float64(created.ID) || claims["token_version"] != float64(created.TokenVersion) {
t.Errorf("unexpected claims %v", claims)
}
})
t.Run("The new account works like any other account", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
cookie := jwtCookieOf(f.accept(acceptBody(acceptCode, "lea", acceptPassword)))
me := f.do(http.MethodGet, "/v1/auth/me", "", cookie, "203.0.113.1")
list := f.do(http.MethodGet, "/v1/user/", "", cookie, "203.0.113.1")
if me.Code != http.StatusOK || list.Code != http.StatusOK {
t.Fatalf("expected 200 and 200, got %d and %d", me.Code, list.Code)
}
items := decodeList(t, list.Body.Bytes())
var leaStatus string
for _, item := range items {
if decodedString(t, item, "kind") == "invite" {
t.Errorf("no invite may be listed: %v", item)
}
if decodedString(t, item, "kind") == "user" && decodedString(t, item, "username") == "lea" {
leaStatus = decodedString(t, item, "status")
}
}
if leaStatus != "accepted" {
t.Errorf("expected lea listed as accepted, got %q", leaStatus)
}
})
t.Run("An accepted code cannot be used again", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
f.accept(acceptBody(acceptCode, "lea", acceptPassword))
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "lea2", acceptPassword))
if rec.Code != http.StatusNotFound || rec.Body.String() != inviteInvalidBody {
t.Errorf("expected 404, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.users.users) != usersBefore || jwtCookieOf(rec) != nil {
t.Errorf("nothing may be created")
}
if _, exists := f.userNamed("lea2"); exists {
t.Errorf("lea2 must not exist")
}
if got := f.get("/v1/auth/invite?c=" + acceptCode); got.Code != http.StatusNotFound {
t.Errorf("expected 404 on GET, got %d", got.Code)
}
})
t.Run("A code that cannot be used creates nothing", func(t *testing.T) {
cases := []struct {
name string
seed func(f *acceptInviteFixture)
code string
status int
body string
}{
{"no invite has that code", func(f *acceptInviteFixture) { f.seedPending() }, "unknown-code", http.StatusNotFound, inviteInvalidBody},
{"an invite that expired an hour ago", func(f *acceptInviteFixture) { f.seedWithCode(expiredInvite(acceptEmail), acceptCode) }, acceptCode, http.StatusGone, inviteExpiredBody},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
tc.seed(f)
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(tc.code, "lea", acceptPassword))
if rec.Code != tc.status || rec.Body.String() != tc.body {
t.Errorf("expected %d %q, got %d %q", tc.status, tc.body, rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
})
}
})
t.Run("A taken username is refused and the invite stays usable", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "marta", acceptPassword))
if rec.Code != http.StatusConflict || rec.Body.String() != usernameTakenBody {
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
if retry := f.accept(acceptBody(acceptCode, "lea", acceptPassword)); retry.Code != http.StatusOK {
t.Errorf("expected 200 on retry, got %d: %s", retry.Code, retry.Body.String())
}
})
t.Run("An address that got an account in the meantime is refused", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
f.users.users[8] = user.User{ID: 8, Username: "leam", Email: "Lea.Martin@Renew.example"}
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
if rec.Code != http.StatusConflict || rec.Body.String() != emailHasAccountBody {
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
})
t.Run("An invalid body is rejected before the code is looked up", func(t *testing.T) {
bodies := []string{
acceptBody(acceptCode, "le", acceptPassword),
acceptBody(acceptCode, "abcdefghijklmnopqrstu", acceptPassword),
acceptBody(acceptCode, "lea", "lime"),
`{"code": "` + acceptCode + `", "password": "limewash2026"}`,
`{"username": "lea", "password": "limewash2026"}`,
`not json`,
}
for _, body := range bodies {
t.Run(body, func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
usersBefore := len(f.users.users)
rec := f.accept(body)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
if f.hasher.compareCalls != 0 {
t.Errorf("the code must not be looked up")
}
})
}
})
t.Run("An existing jwt cookie is ignored and replaced", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
martaCookie := displayNameCookie(t, f.token, 7, 0)
rec := f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody(acceptCode, "lea", acceptPassword), martaCookie, "203.0.113.1")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
created, _ := f.userNamed("lea")
cookie := jwtCookieOf(rec)
decoded, err := f.token.Decode(cookie.Value)
if err != nil {
t.Fatalf("cookie does not decode: %v", err)
}
claims, _ := decoded.AsMap(context.Background())
if claims["user_id"] != float64(created.ID) || created.ID == 7 {
t.Errorf("cookie must carry the new user's id, got %v", claims["user_id"])
}
})
t.Run("A failure after the user is created leaves the invite pending and no user behind", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
stored := f.seedPending()
f.invites.fail.failNext("MarkAccepted")
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
if f.invites.invites[stored.ID] != stored {
t.Errorf("invite must be unchanged")
}
})
t.Run("The accept endpoints are rate-limited", func(t *testing.T) {
f := newAcceptInviteFixture(t, 2)
ip := "203.0.113.30"
for range 2 {
f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody("nope", "lea", acceptPassword), nil, ip)
}
rec := f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody("nope", "lea", acceptPassword), nil, ip)
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || rec.Header().Get("Retry-After") == "" {
t.Errorf("expected 429 with Retry-After, got %d: %q", rec.Code, rec.Body.String())
}
if other := f.do(http.MethodGet, "/v1/auth/registration", "", nil, ip); other.Code == http.StatusTooManyRequests {
t.Errorf("registration status must not be limited")
}
})
t.Run("Registration stays closed for everyone else", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
acceptedAt := time.Now()
accepted := pendingInvite("sam@renew.example")
accepted.AcceptedAt = &acceptedAt
f.seedWithCode(accepted, "accepted-code")
rec := f.do(http.MethodPost, "/v1/auth/register", `{"username":"sam","password":"hunter2example12","email":"sam@renew.example"}`, nil, "203.0.113.1")
status := f.do(http.MethodGet, "/v1/auth/registration", "", nil, "203.0.113.1")
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.RegistrationClosedErrorMessage+"\n" {
t.Errorf("expected 403, got %d: %q", rec.Code, rec.Body.String())
}
if strings.ReplaceAll(status.Body.String(), " ", "") != `{"open":false}` {
t.Errorf("expected registration closed, got %s", status.Body.String())
}
})
t.Run("The invite response never leaks the code or other invite data", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
rec := f.get("/v1/auth/invite?c=" + acceptCode)
decoded := decodeKeys(t, rec.Body.Bytes())
for _, forbidden := range []string{"code", "hashed_code", "invited_by", "expires_at", "id", "accepted_at"} {
if _, present := decoded[forbidden]; present {
t.Errorf("forbidden key %q in body", forbidden)
}
if strings.Contains(rec.Body.String(), `"`+forbidden+`"`) {
t.Errorf("forbidden name %q in raw body %s", forbidden, rec.Body.String())
}
}
if strings.Contains(rec.Body.String(), acceptCode) {
t.Errorf("raw code leaked in body %s", rec.Body.String())
}
})
}
func TestAcceptInviteUseCase(t *testing.T) {
newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) {
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
return useCases.NewAcceptInviteUseCase(users, invites, hasher), users, invites, hasher
}
command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}
t.Run("unknown code is invalid", func(t *testing.T) {
uc, _, _, _ := newUseCase()
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
t.Run("empty code is invalid without hashing", func(t *testing.T) {
uc, _, invites, hasher := newUseCase()
invites.seed(pendingInvite(acceptEmail))
if _, err := uc.AcceptInvite(useCases.AcceptInviteCommand{Username: "lea", Password: acceptPassword}); !errors.Is(err, invite.ErrInviteInvalid) || hasher.compareCalls != 0 {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
t.Run("expired code is expired", func(t *testing.T) {
uc, _, invites, _ := newUseCase()
stored := expiredInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteExpired) {
t.Errorf("expected ErrInviteExpired, got %v", err)
}
})
t.Run("taken username", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
_, err := uc.AcceptInvite(useCases.AcceptInviteCommand{Code: acceptCode, Username: "marta", Password: acceptPassword})
if !errors.Is(err, user.ErrUsernameTaken) || len(users.users) != 1 {
t.Errorf("expected ErrUsernameTaken, got %v", err)
}
})
t.Run("username lookup failure is returned", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
users.fail.failNext("GetByUsername")
if _, err := uc.AcceptInvite(command); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
})
t.Run("email that has an account", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
users.users[8] = user.User{ID: 8, Username: "leam", Email: "LEA.martin@renew.example"}
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrEmailHasAccount) {
t.Errorf("expected ErrEmailHasAccount, got %v", err)
}
})
t.Run("hash failure creates nothing", func(t *testing.T) {
uc, users, invites, hasher := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
hasher.hashErr = errors.New("hash boom")
if _, err := uc.AcceptInvite(command); err == nil || len(users.users) != 1 {
t.Errorf("expected failure without a user, got %v", err)
}
})
t.Run("user creation failure leaves the invite pending", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
seeded := invites.seed(stored)
users.fail.failNext("Create")
if _, err := uc.AcceptInvite(command); !errors.Is(err, errRepositoryFailure) || invites.invites[seeded.ID].AcceptedAt != nil {
t.Errorf("expected repository failure and a pending invite, got %v", err)
}
})
t.Run("rollback failure is joined with the accept failure", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
invites.fail.failNextWith("MarkAccepted", errors.New("mark down"))
users.fail.failNextWith("Delete", errors.New("delete down"))
_, err := uc.AcceptInvite(command)
if err == nil || !strings.Contains(err.Error(), "mark down") || !strings.Contains(err.Error(), "delete down") {
t.Errorf("unexpected error %v", err)
}
})
}
func TestGetInviteUseCase(t *testing.T) {
t.Run("returns the usable invite", func(t *testing.T) {
invites, hasher := newInviteFakeRepo(), &stubHasher{}
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
got, err := useCases.NewGetInviteUseCase(invites, hasher).GetInvite(acceptCode)
if err != nil || got.Email != acceptEmail {
t.Errorf("unexpected result %+v, %v", got, err)
}
})
}
+13
View File
@@ -75,6 +75,19 @@ func (m *inviteFakeRepo) Delete(id uint32) error {
return nil
}
func (m *inviteFakeRepo) MarkAccepted(id uint32, at time.Time) error {
if err := m.fail.trigger("MarkAccepted"); err != nil {
return err
}
stored, ok := m.invites[id]
if !ok {
return invite.ErrInviteNotFound
}
stored.AcceptedAt = &at
m.invites[id] = stored
return nil
}
var _ invite.Repository = &inviteFakeRepo{}
func inviteAt(email string, createdAt time.Time, expiresAt time.Time) invite.Invite {
+6 -4
View File
@@ -46,6 +46,11 @@ const router = createRouter({
component: () => import('@/views/ResetConfirmView.vue'),
meta: { auth: 'public' },
},
{
path: '/invite/accept',
component: () => import('@/views/AcceptInviteView.vue'),
meta: { auth: 'public' },
},
{
path: '/home',
component: () => import('@/views/HomeView.vue'),
@@ -153,10 +158,7 @@ router.afterEach((to) => {
: Promise.resolve()
settled.then(() => {
const destination = resolveAuthRedirect(
router.currentRoute.value,
session,
)
const destination = resolveAuthRedirect(router.currentRoute.value, session)
if (destination !== true) {
router.replace(destination)
}
+372
View File
@@ -0,0 +1,372 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from '@nuxt/ui/composables'
import AuthShell from '@/components/AuthShell.vue'
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
import { useSessionStore } from '@/stores/session'
type InviteState = 'checking' | 'form' | 'invalid' | 'expired' | 'success'
const EMAIL_HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
const USERNAME_TAKEN_MESSAGE = 'That username is taken. Try another.'
const USERNAME_RULE_MESSAGE = 'Use 3 to 20 characters.'
const route = useRoute()
const router = useRouter()
const api = useApi()
const session = useSessionStore()
const toast = useToast()
const code = typeof route.query.c === 'string' ? route.query.c : ''
const state = ref<InviteState>(code ? 'checking' : 'invalid')
const inviteEmail = ref('')
const username = ref('')
const password = ref('')
const usernameTouched = ref(false)
const passwordTouched = ref(false)
const usernameTaken = ref(false)
const submitting = ref(false)
const passwordShown = ref(false)
const serverErrorAlert = ref(false)
const usernameValid = computed(() => username.value.length >= 3 && username.value.length <= 20)
const passwordValid = computed(() => password.value.length >= 8)
const usernameError = computed(() => {
if (usernameTaken.value) {
return USERNAME_TAKEN_MESSAGE
}
return usernameTouched.value && !usernameValid.value ? USERNAME_RULE_MESSAGE : ''
})
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
const formValid = computed(() => usernameValid.value && passwordValid.value)
const showForm = computed(() => state.value === 'form')
const pageTitle = computed(() => (showForm.value ? 'Accept your invite' : ''))
const pageDescription = computed(() =>
showForm.value
? 'Choose a username and password to finish setting up your account.'
: undefined,
)
function goToLogin() {
router.push('/login')
}
const invalidActions = [{ label: 'Go to log in', onClick: goToLogin }]
const expiredActions = [
{
label: 'Go to log in',
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
},
]
function onUsernameInput() {
usernameTaken.value = false
}
async function focusUsername() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('input[autocomplete="username"]')?.focus()
}
function applyDeadLinkStatus(error: unknown): boolean {
if (!(error instanceof ApiError)) {
return false
}
if (error.status === 404) {
state.value = 'invalid'
return true
}
if (error.status === 410) {
state.value = 'expired'
return true
}
return false
}
async function loadInvite() {
try {
const invite = await api.request<{ email: string }>(
`/v1/auth/invite?c=${encodeURIComponent(code)}`,
{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' },
)
inviteEmail.value = invite.email
state.value = 'form'
} catch (error) {
applyDeadLinkStatus(error)
}
}
async function handleSuccess() {
state.value = 'success'
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Welcome to RenewCMS',
description: `You're logged in as ${username.value}.`,
})
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
await router.push('/home')
}
function handleFailure(error: unknown) {
if (applyDeadLinkStatus(error)) {
return
}
if (error instanceof ApiError && error.status === 409) {
if (error.body === EMAIL_HAS_ACCOUNT_MESSAGE) {
state.value = 'invalid'
return
}
usernameTaken.value = true
focusUsername()
return
}
serverErrorAlert.value = true
}
async function onSubmit() {
usernameTouched.value = true
passwordTouched.value = true
if (!formValid.value) {
return
}
serverErrorAlert.value = false
submitting.value = true
try {
await api.request('/v1/auth/invite/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
username: username.value,
password: password.value,
}),
onUnauthorized: 'passthrough',
onForbidden: 'passthrough',
})
} catch (error) {
handleFailure(error)
submitting.value = false
return
}
submitting.value = false
await handleSuccess()
}
onMounted(() => {
if (code) {
loadInvite()
}
})
</script>
<template>
<AuthShell :title="pageTitle" :description="pageDescription">
<template v-if="serverErrorAlert && showForm" #notice>
<UAlert
color="error"
icon="i-lucide-circle-x"
title="Couldn't create the account"
description="Something went wrong on the server. Try again in a moment."
variant="soft"
:close="false"
role="alert"
/>
</template>
<template #default="{ controlSize, controlClass, inputClass }">
<div
v-if="state === 'checking'"
role="status"
class="flex flex-col items-center gap-4 py-10 text-center"
>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
<p class="text-xl/7 font-semibold text-highlighted">Checking your invite…</p>
<p class="text-base text-muted">This only takes a moment.</p>
</div>
<ResultPanel
v-else-if="state === 'invalid'"
tone="error"
icon="i-lucide-link-2-off"
title="This invite link doesn't work"
description="It may be incomplete, or it may already have been used. If you already set up your account, log in instead."
:actions="invalidActions"
>
<p class="text-sm text-pretty text-muted">
Open the link from your email again, or copy the full address into your browser.
</p>
</ResultPanel>
<ResultPanel
v-else-if="state === 'expired'"
tone="error"
icon="i-lucide-clock"
title="This invite has expired"
description="Invites work once, for 3 days. Ask the person who invited you to send a new one."
:actions="expiredActions"
/>
<ResultPanel
v-else-if="state === 'success'"
tone="success"
icon="i-lucide-circle-check"
title="Your account is ready"
description="Taking you to your dashboard…"
:actions="[]"
/>
<UForm
v-else
:state="{ email: inviteEmail, username, password }"
class="flex flex-col gap-4.5"
@submit="onSubmit"
>
<UFormField
label="Email"
name="email"
help="From your invitation."
:size="controlSize"
>
<UInput
:model-value="inviteEmail"
placeholder="you@example.com"
autocomplete="email"
readonly
:disabled="submitting"
variant="outline"
color="primary"
:size="controlSize"
:ui="{
base: `${inputClass} bg-elevated text-dimmed ring-(--ui-border)`,
}"
class="w-full"
/>
</UFormField>
<UFormField
label="Username"
name="username"
:error="usernameError || undefined"
:size="controlSize"
>
<template v-if="usernameError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<template v-else #help>
Letters and numbers, 3 to 20 characters. You'll use it to log in.
</template>
<template #hint>
<span
:class="[
'font-mono text-xs',
usernameError ? 'text-error' : 'text-muted',
]"
>
{{ username.length }} / 20
</span>
</template>
<UInput
v-model="username"
placeholder="e.g. lea"
autocomplete="username"
:disabled="submitting"
variant="outline"
color="primary"
:highlight="!!usernameError"
:size="controlSize"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
@update:model-value="onUsernameInput"
@blur="usernameTouched = true"
/>
</UFormField>
<UFormField label="Password" name="password" :size="controlSize">
<template #help>
<PasswordStrengthHint
:rules="passwordRules"
:show-errors="passwordTouched"
/>
</template>
<UInput
v-model="password"
:type="passwordShown ? 'text' : 'password'"
autocomplete="new-password"
:disabled="submitting"
variant="outline"
color="primary"
:size="controlSize"
:ui="{
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
@blur="passwordTouched = true"
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
</template>
</UInput>
</UFormField>
<UButton
type="submit"
block
:size="controlSize"
:ui="{ base: controlClass }"
:loading="submitting"
:disabled="submitting"
>
{{ submitting ? 'Creating account…' : 'Accept invite' }}
</UButton>
</UForm>
</template>
<template v-if="showForm" #footer>
<span class="flex flex-wrap items-center gap-1.5">
Already have an account?
<RouterLink
to="/login"
class="font-medium text-highlighted underline underline-offset-3"
>
Log in
</RouterLink>
</span>
</template>
</AuthShell>
</template>
@@ -0,0 +1,370 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import AcceptInviteView from '../AcceptInviteView.vue'
type Handler = () => Response | Promise<Response>
function mockResponse(status: number, body: unknown = null, text = '') {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
text: async () => text,
} as unknown as Response
}
const invalidText =
'It may be incomplete, or it may already have been used. If you already set up your account, log in instead.'
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountAccept(path: string, handlers: Record<string, Handler> = {}) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${url}`
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
global.fetch = fetchMock as unknown as typeof fetch
await router.push(path)
const wrapper = mount(AcceptInviteView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return { wrapper, fetchMock }
}
type Wrapper = Awaited<ReturnType<typeof mountAccept>>['wrapper']
const validInvite: Record<string, Handler> = {
'GET /v1/auth/invite?c=abc123': () => mockResponse(200, { email: 'lea.martin@renew.example' }),
}
function withAccept(accept: Handler): Record<string, Handler> {
return { ...validInvite, 'POST /v1/auth/invite/accept': accept }
}
function usernameInput(wrapper: Wrapper) {
return wrapper.find('input[placeholder="e.g. lea"]')
}
function emailInput(wrapper: Wrapper) {
return wrapper.find('input[placeholder="you@example.com"]')
}
function passwordInput(wrapper: Wrapper) {
return wrapper.find('input[autocomplete="new-password"]')
}
async function fill(wrapper: Wrapper, username: string, password: string) {
await usernameInput(wrapper).setValue(username)
await passwordInput(wrapper).setValue(password)
}
async function submit(wrapper: Wrapper) {
await wrapper.find('form').trigger('submit')
await flushPromises()
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.useRealTimers()
await router.push('/login')
})
describe('Checking the link', () => {
it('shows a full-card spinner while the invite is looked up', async () => {
const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=abc123', {
'GET /v1/auth/invite?c=abc123': () => new Promise(() => {}),
})
expect(wrapper.text()).toContain('Checking your invite…')
expect(wrapper.find('form').exists()).toBe(false)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})
describe('Default', () => {
it('renders the form with the invited email read-only', async () => {
const { wrapper } = await mountAccept('/invite/accept?c=abc123', validInvite)
expect(wrapper.find('h1').text()).toBe('Accept your invite')
expect(wrapper.text()).toContain(
'Choose a username and password to finish setting up your account.',
)
const email = emailInput(wrapper).element as HTMLInputElement
expect(email.value).toBe('lea.martin@renew.example')
expect(email.readOnly).toBe(true)
expect(wrapper.text()).toContain('From your invitation.')
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('')
expect(wrapper.text()).toContain('0 / 20')
expect(wrapper.text()).toContain(
"Letters and numbers, 3 to 20 characters. You'll use it to log in.",
)
expect(passwordInput(wrapper).attributes('type')).toBe('password')
expect(wrapper.text()).toContain('At least 8 characters')
expect(wrapper.find('button[type="submit"]').text()).toBe('Accept invite')
expect(wrapper.text()).toContain('Already have an account?')
expect(wrapper.find('a[href="/login"]').text()).toBe('Log in')
})
it('shows the same form to a logged-in visitor without redirecting', async () => {
const { wrapper } = await mountAccept('/invite/accept?c=abc123', validInvite)
expect(router.currentRoute.value.path).toBe('/invite/accept')
expect(wrapper.find('form').exists()).toBe(true)
})
})
describe('Validation errors', () => {
it('shows the errors and never calls the accept endpoint', async () => {
const accept = vi.fn(() => mockResponse(200))
const { wrapper } = await mountAccept('/invite/accept?c=abc123', withAccept(accept))
await usernameInput(wrapper).setValue('ma')
await usernameInput(wrapper).trigger('blur')
await passwordInput(wrapper).setValue('lime')
await passwordInput(wrapper).trigger('blur')
await submit(wrapper)
expect(accept).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('2 / 20')
expect(wrapper.text()).toContain('Use 3 to 20 characters.')
const item = wrapper.findAll('li').find((li) => li.text().includes('At least 8 characters'))
expect(item?.find('.text-error').exists()).toBe(true)
})
})
describe('Filled, valid', () => {
it('shows the counter and a met checklist item without errors', async () => {
const { wrapper } = await mountAccept('/invite/accept?c=abc123', validInvite)
await fill(wrapper, 'lea', 'limewash2026')
await wrapper.find('button[aria-label="Show password"]').trigger('click')
expect(wrapper.text()).toContain('3 / 20')
expect(passwordInput(wrapper).attributes('type')).toBe('text')
expect(wrapper.text()).not.toContain('Use 3 to 20 characters.')
const item = wrapper.findAll('li').find((li) => li.text().includes('At least 8 characters'))
expect(item?.find('.text-success').exists()).toBe(true)
})
})
describe('Submitting', () => {
it('sends the code, username and password and disables the form', async () => {
const accept = vi.fn(() => new Promise<Response>(() => {}))
const { wrapper, fetchMock } = await mountAccept(
'/invite/accept?c=abc123',
withAccept(accept),
)
await fill(wrapper, 'lea', 'limewash2026')
await submit(wrapper)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/invite/accept',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
code: 'abc123',
username: 'lea',
password: 'limewash2026',
}),
}),
)
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Creating account…')
expect(button.attributes('disabled')).toBeDefined()
expect(usernameInput(wrapper).attributes('disabled')).toBeDefined()
expect(passwordInput(wrapper).attributes('disabled')).toBeDefined()
})
})
describe('Username taken (409)', () => {
it('keeps the values, focuses the username and shows the error', async () => {
const { wrapper } = await mountAccept(
'/invite/accept?c=abc123',
withAccept(() => mockResponse(409, null, 'That username is taken. Try another.')),
)
await fill(wrapper, 'marta', 'limewash2026')
await submit(wrapper)
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('marta')
expect(wrapper.text()).toContain('5 / 20')
expect(wrapper.text()).toContain('That username is taken. Try another.')
expect(document.activeElement).toBe(usernameInput(wrapper).element)
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect(passwordInput(wrapper).attributes('disabled')).toBeUndefined()
expect(wrapper.find('button[type="submit"]').text()).toBe('Accept invite')
})
})
describe('The link does not work', () => {
async function expectInvalidPanel(wrapper: Wrapper) {
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain("This invite link doesn't work")
expect(wrapper.text()).toContain(invalidText)
expect(wrapper.text()).toContain(
'Open the link from your email again, or copy the full address into your browser.',
)
const buttons = wrapper.find('[role="alert"]').findAll('button')
expect(buttons.map((button) => button.text())).toEqual(['Go to log in'])
await buttons[0]!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
}
it('renders it without any request when there is no c parameter', async () => {
const { wrapper, fetchMock } = await mountAccept('/invite/accept')
expect(fetchMock).not.toHaveBeenCalled()
await expectInvalidPanel(wrapper)
})
it('renders it without any request when c is empty', async () => {
const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=')
expect(fetchMock).not.toHaveBeenCalled()
await expectInvalidPanel(wrapper)
})
it('renders it when the lookup answers 404', async () => {
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
'GET /v1/auth/invite?c=abc123': () => mockResponse(404),
})
await expectInvalidPanel(wrapper)
})
it('replaces the form when the submit answers 404', async () => {
const { wrapper } = await mountAccept(
'/invite/accept?c=abc123',
withAccept(() => mockResponse(404)),
)
await fill(wrapper, 'lea', 'limewash2026')
await submit(wrapper)
await expectInvalidPanel(wrapper)
})
it('replaces the form when the submit answers the email-has-account 409', async () => {
const { wrapper } = await mountAccept(
'/invite/accept?c=abc123',
withAccept(() => mockResponse(409, null, 'This address already has an account.')),
)
await fill(wrapper, 'lea', 'limewash2026')
await submit(wrapper)
await expectInvalidPanel(wrapper)
})
})
describe('Expired link (410)', () => {
async function expectExpiredPanel(wrapper: Wrapper) {
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('This invite has expired')
expect(wrapper.text()).toContain(
'Invites work once, for 3 days. Ask the person who invited you to send a new one.',
)
const buttons = wrapper.find('[role="alert"]').findAll('button')
expect(buttons.map((button) => button.text())).toEqual(['Go to log in'])
await buttons[0]!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
}
it('renders it when the lookup answers 410', async () => {
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
'GET /v1/auth/invite?c=abc123': () => mockResponse(410),
})
await expectExpiredPanel(wrapper)
})
it('replaces the form when the submit answers 410', async () => {
const { wrapper } = await mountAccept(
'/invite/accept?c=abc123',
withAccept(() => mockResponse(410)),
)
await fill(wrapper, 'lea', 'limewash2026')
await submit(wrapper)
await expectExpiredPanel(wrapper)
})
})
describe('Server error', () => {
it.each([
{ name: 'resolves 500', handler: () => mockResponse(500) },
{ name: 'resolves 429', handler: () => mockResponse(429) },
{
name: 'never reaches the server',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the alert and keeps the form when the submit $name', async ({ handler }) => {
const { wrapper } = await mountAccept('/invite/accept?c=abc123', withAccept(handler))
await fill(wrapper, 'lea', 'limewash2026')
await submit(wrapper)
expect(wrapper.text()).toContain("Couldn't create the account")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe(
'lea.martin@renew.example',
)
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('lea')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect(usernameInput(wrapper).attributes('disabled')).toBeUndefined()
expect(wrapper.find('button[type="submit"]').text()).toBe('Accept invite')
})
})
describe('Success', () => {
it('shows the panel and toast, then navigates to /home after 800 ms', async () => {
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
...withAccept(() => mockResponse(200, { message: 'ok' })),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
})
vi.useFakeTimers()
await fill(wrapper, 'lea', 'limewash2026')
await submit(wrapper)
expect(wrapper.text()).toContain('Your account is ready')
expect(wrapper.text()).toContain('Taking you to your dashboard…')
expect(wrapper.find('form').exists()).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Welcome to RenewCMS',
description: "You're logged in as lea.",
})
expect(router.currentRoute.value.path).toBe('/invite/accept')
await vi.advanceTimersByTimeAsync(800)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
})
+78
View File
@@ -640,6 +640,33 @@ async function clickBaseUrlCopy(page: Page) {
await page.getByRole('button', { name: 'Copy', exact: true }).first().click()
}
const acceptInvitePath = '/invite/accept?c=abc123'
const acceptInviteEmail = 'lea.martin@renew.example'
function acceptInviteWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: acceptInvitePath,
api: {
'GET /v1/auth/invite': { status: 200, body: { email: acceptInviteEmail } },
'POST /v1/auth/invite/accept': response,
},
}
}
async function fillAcceptInvite(page: Page, username: string, password: string) {
await page.getByPlaceholder('e.g. lea').fill(username)
await page.getByPlaceholder('e.g. lea').blur()
await page.locator('input[autocomplete="new-password"]').fill(password)
await page.locator('input[autocomplete="new-password"]').blur()
}
function submitAcceptInvite(username: string, password: string) {
return async (page: Page) => {
await fillAcceptInvite(page, username, password)
await page.locator('input[autocomplete="new-password"]').press('Enter')
}
}
export const visualStates: Record<string, Record<string, VisualState>> = {
users: {
Default: { path: '/users', api: usersApi() },
@@ -1401,4 +1428,55 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitResetConfirm('limewash2026', 'limewash2026'),
},
},
'accept-invite': {
'Checking the link': {
path: acceptInvitePath,
api: { 'GET /v1/auth/invite': { status: 200, hang: true } },
},
Default: acceptInviteWith({ status: 200 }),
'Validation errors': {
...acceptInviteWith({ status: 200 }),
steps: async (page) => {
await fillAcceptInvite(page, 'ma', 'lime')
await page.locator('input[autocomplete="new-password"]').press('Enter')
},
},
'Filled, valid': {
...acceptInviteWith({ status: 200 }),
steps: async (page) => {
await fillAcceptInvite(page, 'lea', 'limewash2026')
await page.getByRole('button', { name: 'Show password' }).click()
},
},
Submitting: {
...acceptInviteWith({ status: 200, hang: true }),
steps: submitAcceptInvite('lea', 'limewash2026'),
},
'Username taken (409)': {
...acceptInviteWith({ status: 409, body: 'That username is taken. Try another.' }),
steps: submitAcceptInvite('marta', 'limewash2026'),
},
'Invalid or used link (404)': {
path: acceptInvitePath,
api: { 'GET /v1/auth/invite': { status: 404 } },
},
'Expired link (410)': {
path: acceptInvitePath,
api: { 'GET /v1/auth/invite': { status: 410 } },
},
'Server error': {
...acceptInviteWith({ status: 500 }),
steps: submitAcceptInvite('lea', 'limewash2026'),
},
Success: {
...acceptInviteWith({ status: 200, body: { message: 'ok' } }),
paused: true,
api: {
'GET /v1/auth/invite': { status: 200, body: { email: acceptInviteEmail } },
'POST /v1/auth/invite/accept': { status: 200, body: { message: 'ok' } },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
},
steps: submitAcceptInvite('lea', 'limewash2026'),
},
},
}