feat: register T1 (pending, verify)
This commit is contained in:
1 parent
3c3f7c6194
commit
4e41d60142
29 files changed
+2162
-42
No files matched your search
@@ -0,0 +1,152 @@
|
||||
Feature: Pending registration screen
|
||||
|
||||
As a logged-in user who has not yet verified their account
|
||||
I want /register/pending to tell me where the verification link went, let me log out or cancel
|
||||
my registration, and route me to the right destination no matter which of those calls
|
||||
succeeds or fails
|
||||
So that I always know what to do next while waiting for the emailed link, and a device that
|
||||
verified elsewhere or a server error never strands me on the wrong screen
|
||||
|
||||
Background:
|
||||
Given /register/pending is a "session" route (meta.auth "session"), rendered as PendingPage
|
||||
inside AuthShell, per docs/design/routes.md; "session" only requires a logged-in account, so
|
||||
both a verified and an unverified account can land on it without being redirected away by
|
||||
the router guard
|
||||
And PendingPage only calls DELETE /v1/auth/register and POST /v1/auth/logout
|
||||
And RegisterView.vue, on a 200 from POST /v1/auth/register, writes the submitted email to
|
||||
sessionStorage under the key "renewcms:pendingEmail" before navigating to /register/pending;
|
||||
this is the only change this slice makes to RegisterView.vue
|
||||
And on mount, PendingPage reads "renewcms:pendingEmail" from sessionStorage once: if present,
|
||||
it renders the Default state with that email interpolated into the ResultPanel text; if
|
||||
absent (for example, the user arrived by logging in as an unverified account rather than by
|
||||
just registering), it renders the Email unknown state with the generic text
|
||||
And in both cases the ResultPanel has tone "success", icon "i-lucide-mail-check", title "Check
|
||||
your email", and two secondary actions, "Log out" and "Cancel registration" (color
|
||||
"neutral", variant "outline"); the note line ("Opened it on another device? That works too;
|
||||
you'll log in there first. Can't find it? Check your spam folder. If the link has expired,
|
||||
cancel your registration and start again.") is rendered through ResultPanel's default slot
|
||||
as a second paragraph below the description, per components.md's "Slot default for extras"
|
||||
And "Log out" calls POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and
|
||||
clears the jwt cookie; on its response, PendingPage calls session.clear() and navigates to
|
||||
/login, disabling both actions while the call is in flight
|
||||
And "Cancel registration" opens ConfirmDialog (built by this slice; components.md documents it
|
||||
but no file exists yet) via useOverlay().create(ConfirmDialog).open(), with title "Cancel
|
||||
your registration?", description "Your account will be deleted. You can register again with
|
||||
the same or a different email.", confirmLabel "Cancel registration", cancelLabel "Keep my
|
||||
account" (components.md's "Other uses" line pairs title/description/confirmLabel/cancelLabel
|
||||
in that order, so ConfirmDialog exposes a cancelLabel prop alongside confirmLabel even though
|
||||
the compact props line only names confirmLabel), pendingLabel "Cancelling…", and tone
|
||||
"danger"; its action prop performs the DELETE /v1/auth/register call
|
||||
And while the dialog's action is in flight (Cancelling), both buttons are disabled, the confirm
|
||||
button reads "Cancelling…", aria-busy is set, and the dialog cannot be dismissed, per
|
||||
ConfirmDialog's general contract in components.md
|
||||
And PendingPage's DELETE /v1/auth/register call passes { onForbidden: 'passthrough' } (a new
|
||||
RequestOptions field on useApi, added by this slice, mirroring the existing onUnauthorized
|
||||
field: type 'redirect' | 'passthrough', default 'redirect'; when 'passthrough' is given, the
|
||||
403 branch in useApi's request() skips the "if session.session?.verified === false, push to
|
||||
/register/pending" step entirely and always throws ApiError(403)) so PendingPage's own 403
|
||||
handling below runs instead of the global handler bouncing it back to /register/pending
|
||||
And on a 200 from DELETE /v1/auth/register, the action resolves, the dialog closes, the app
|
||||
navigates to /register, and a toast (color "success", icon "i-lucide-circle-check", title
|
||||
"Registration cancelled", description "You can create the owner account again.") is shown
|
||||
And on a 403 from DELETE /v1/auth/register (user.ErrAlreadyVerified, since the account was
|
||||
verified on another device while this tab still holds a stale unverified session), the
|
||||
action first awaits session.retry() so the session store's cached verified flag is refreshed
|
||||
to true — without this, the router guard's stale local state would immediately bounce a
|
||||
navigation to /home straight back to /register/pending — then the dialog closes, the app
|
||||
navigates to /home, and a toast (color "info", icon "i-lucide-info", title "Your account is
|
||||
already verified", description "There's nothing to cancel.") is shown; PendingPage does not
|
||||
render a ResultPanel for this case, since the ResultPanel drawn in this state on the design
|
||||
board is Verify's own Success panel, reused purely to depict the destination page behind the
|
||||
toast
|
||||
And on a 500 response or a request that never reaches the server, the action rejects, the
|
||||
dialog stays open, and it shows the failure inline (per ConfirmDialog's general contract:
|
||||
"the action runs inside the dialog, so errors show there instead of a toast"), re-enabling
|
||||
both buttons so the user can retry or choose "Keep my account"
|
||||
And a 401 from either call is handled entirely by useApi's existing global 401 handling
|
||||
(redirect to /login?redirect=...&reason=expired); PendingPage adds no special case for it
|
||||
|
||||
Scenario: Default — the email is known from a just-completed registration
|
||||
Given sessionStorage holds "renewcms:pendingEmail" set to "marta@renew.example"
|
||||
When the user navigates to /register/pending
|
||||
Then a ResultPanel is shown with tone "success" and icon "i-lucide-mail-check"
|
||||
And its title is "Check your email"
|
||||
And its text is "We sent a verification link to marta@renew.example. It expires in 2 hours."
|
||||
And its note reads "Opened it on another device? That works too; you'll log in there first.
|
||||
Can't find it? Check your spam folder. If the link has expired, cancel your registration and
|
||||
start again."
|
||||
And its actions are "Log out" and "Cancel registration", both secondary
|
||||
|
||||
Scenario: Email unknown — arrived by logging in as an unverified account
|
||||
Given sessionStorage holds no "renewcms:pendingEmail" entry
|
||||
When the user navigates to /register/pending
|
||||
Then a ResultPanel is shown with tone "success" and icon "i-lucide-mail-check"
|
||||
And its title is "Check your email"
|
||||
And its text is "We sent a verification link to the email you registered with. It expires in 2
|
||||
hours."
|
||||
And its note is the same as the Default state's note
|
||||
And its actions are "Log out" and "Cancel registration", both secondary
|
||||
|
||||
Scenario: Logging out
|
||||
Given the user is on /register/pending
|
||||
When the user activates "Log out"
|
||||
Then both actions are disabled while POST /v1/auth/logout is in flight
|
||||
When the response resolves 200
|
||||
Then the session store is cleared
|
||||
And the app navigates to /login
|
||||
|
||||
Scenario: Confirm cancel — the destructive dialog opens
|
||||
Given the user is on /register/pending
|
||||
When the user activates "Cancel registration"
|
||||
Then a ConfirmDialog opens with title "Cancel your registration?", description "Your account
|
||||
will be deleted. You can register again with the same or a different email.", a "Keep my
|
||||
account" cancel button, a "Cancel registration" confirm button in tone "danger", role
|
||||
"alertdialog", and focus starting on "Keep my account"
|
||||
And the underlying ResultPanel is still shown behind it, unchanged
|
||||
|
||||
Scenario: Cancelling — the DELETE is in flight and the dialog can't be dismissed
|
||||
Given the ConfirmDialog from "Confirm cancel" is open
|
||||
When the user activates "Cancel registration" inside the dialog
|
||||
Then both dialog buttons are disabled, the confirm button reads "Cancelling…", aria-busy is set
|
||||
on the dialog, and it cannot be dismissed by Escape or an overlay click
|
||||
And DELETE /v1/auth/register is sent with { onForbidden: 'passthrough' }
|
||||
|
||||
Scenario: Keep my account — the dialog is dismissed without deleting anything
|
||||
Given the ConfirmDialog from "Confirm cancel" is open
|
||||
When the user activates "Keep my account"
|
||||
Then the dialog closes, emitting close(false)
|
||||
And DELETE /v1/auth/register is never called
|
||||
And the ResultPanel from before the dialog opened is still shown, unchanged
|
||||
|
||||
Scenario: Cancelled — 200 navigates to /register with a toast
|
||||
Given the ConfirmDialog's action is in flight after the user confirmed
|
||||
When DELETE /v1/auth/register resolves 200
|
||||
Then the dialog closes
|
||||
And the app navigates to /register
|
||||
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
|
||||
cancelled", description "You can create the owner account again."
|
||||
|
||||
Scenario: Already verified — 403 refreshes the session and navigates to /home with a toast
|
||||
Given the ConfirmDialog's action is in flight after the user confirmed
|
||||
And the session store currently holds { verified: false }
|
||||
When DELETE /v1/auth/register resolves 403 with the apperr.AlreadyVerifiedErrorMessage body
|
||||
Then session.retry() is awaited before any navigation happens
|
||||
And once it resolves the session store holds { verified: true }
|
||||
And the dialog closes
|
||||
And the app navigates to /home
|
||||
And a toast is shown with color "info", icon "i-lucide-info", title "Your account is already
|
||||
verified", description "There's nothing to cancel."
|
||||
And no ResultPanel is rendered by PendingPage for this transition
|
||||
|
||||
Scenario Outline: Cancel failed — the dialog stays open and shows the failure inline
|
||||
Given the ConfirmDialog's action is in flight after the user confirmed
|
||||
When DELETE /v1/auth/register <failure>
|
||||
Then the dialog does not close
|
||||
And the failure is shown inline in the dialog, not as a toast
|
||||
And both dialog buttons are re-enabled so the user can retry "Cancel registration" or choose
|
||||
"Keep my account"
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| fails with a 500 response |
|
||||
| never reaches the server |
|
||||
@@ -0,0 +1,81 @@
|
||||
# QA: Pending registration screen
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi` (`DELETE /v1/auth/register`, `POST /v1/auth/logout`) and `useToast`/`useOverlay` per
|
||||
case; no real backend, no httptest server. Mount PendingPage inside AuthShell with a test router and
|
||||
a test Pinia session store. Seed/clear `sessionStorage["renewcms:pendingEmail"]` per case.
|
||||
|
||||
1. **Default state** — set `sessionStorage["renewcms:pendingEmail"] = "marta@renew.example"`; mount
|
||||
at /register/pending; assert a ResultPanel with tone "success", icon "i-lucide-mail-check", title
|
||||
"Check your email", text "We sent a verification link to marta@renew.example. It expires in 2
|
||||
hours.", the note paragraph rendered via the default slot, and two secondary actions "Log out"
|
||||
and "Cancel registration".
|
||||
2. **Email unknown state** — clear `sessionStorage["renewcms:pendingEmail"]`; mount at
|
||||
/register/pending; assert the same ResultPanel shape with text "We sent a verification link to
|
||||
the email you registered with. It expires in 2 hours." and the same note and actions.
|
||||
3. **Logging out** — mount with an unresolved `POST /v1/auth/logout` mock; click "Log out"; assert
|
||||
both actions are disabled; resolve the mock 200; assert the session store's `clear()` was called
|
||||
and the router's current path is /login.
|
||||
4. **Confirm cancel opens the dialog** — click "Cancel registration"; assert a ConfirmDialog renders
|
||||
with title "Cancel your registration?", description "Your account will be deleted. You can
|
||||
register again with the same or a different email.", buttons "Keep my account" and "Cancel
|
||||
registration" (tone danger), `role="alertdialog"`, and initial focus on "Keep my account".
|
||||
5. **Cancelling state** — with the dialog open, mock `DELETE /v1/auth/register` with an unresolved
|
||||
promise, click "Cancel registration" inside the dialog; assert both dialog buttons are disabled,
|
||||
the confirm button reads "Cancelling…", `aria-busy="true"` on the dialog, and the mocked call was
|
||||
made with `{ onForbidden: 'passthrough' }`.
|
||||
6. **Keep my account** — with the dialog open, click "Keep my account"; assert the dialog closes,
|
||||
`DELETE /v1/auth/register` was never called, and the underlying ResultPanel is unchanged.
|
||||
7. **Cancelled state** — confirm the dialog; mock `DELETE /v1/auth/register` resolving 200; assert
|
||||
the dialog closes, the router's current path is /register, and a toast was raised with color
|
||||
"success", icon "i-lucide-circle-check", title "Registration cancelled", description "You can
|
||||
create the owner account again."
|
||||
8. **Already verified state** — seed the session store with `{ verified: false }`, `status:'ready'`;
|
||||
confirm the dialog; mock `DELETE /v1/auth/register` resolving 403; mock the session store's
|
||||
`retry()` to resolve and set `session.session = { verified: true }`; assert `retry()` was awaited
|
||||
before the navigation; assert the router's current path is /home; assert a toast was raised with
|
||||
color "info", icon "i-lucide-info", title "Your account is already verified", description
|
||||
"There's nothing to cancel."; assert no ResultPanel assertions target this transition (Pending
|
||||
unmounts as the route changes).
|
||||
9. **Cancel failed state** — two sub-cases: (a) a 500 response, (b) a rejected fetch. In each,
|
||||
confirm the dialog, resolve/reject the mock accordingly, and assert the dialog stays open, an
|
||||
inline failure indicator is shown inside it (not a toast — assert no toast was raised), and both
|
||||
dialog buttons are re-enabled.
|
||||
10. **`useApi` onForbidden option (unit-level, alongside the existing suite in
|
||||
`api_client_global_error_handling`'s spec file)** — seed `session = { verified: false }`; mock
|
||||
fetch resolving 403; call `useApi().request('/v1/auth/register', { method: 'DELETE', onForbidden:
|
||||
'passthrough' })`; assert the promise rejects with `ApiError` status 403; assert the router's
|
||||
current route did not change (no redirect to /register/pending); assert the session store is
|
||||
unchanged. This regresses nothing in the existing onForbidden-absent (default 'redirect')
|
||||
scenarios already covered by `features/api_client_global_error_handling.feature`.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Pending". Run
|
||||
`task visual:check -- pending` for each state and compare against the board frame.
|
||||
|
||||
- [ ] Default state: icon tile, title, text with the interpolated email, note paragraph, "Log out"
|
||||
and "Cancel registration" secondary buttons — matches the board exactly.
|
||||
- [ ] Email unknown state: same layout with the generic text.
|
||||
- [ ] Confirm cancel state: ConfirmDialog layout, button order (Keep my account left, Cancel
|
||||
registration right, danger color), matches components.md's button-order rule.
|
||||
- [ ] Cancelling state: disabled buttons, "Cancelling…" label, spinner/aria-busy styling.
|
||||
- [ ] Cancelled state (transient, capture via visual test hook or a frozen mock): toast appearance
|
||||
over the /register board.
|
||||
- [ ] Already verified state: toast appearance over the /home board (per the design board's
|
||||
illustrative use of Verify's Success ResultPanel as the backdrop).
|
||||
- [ ] Keyboard/focus: focus starts on "Keep my account" in the dialog and Enter does not confirm;
|
||||
while cancelling, focus cannot leave the dialog; on the ResultPanel states, focus lands on the
|
||||
title on mount; toasts are announced without stealing focus.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real DELETE /v1/auth/register and POST /v1/auth/logout against a running server** — Vitest only
|
||||
mocks `useApi`; confirm the real 200/403 round-trips against `task dev` with a live SQLite file.
|
||||
The endpoint's own status/body/cookie contract is already covered by
|
||||
`features/cancel_pending_registration.qa.md`; this slice only re-verifies the screen consumes it
|
||||
correctly.
|
||||
- **Router-guard staleness fix in a real browser** — confirm that after the "Already verified" 403,
|
||||
a hard reload of /home does not bounce back to /register/pending, since Vitest's fake session store
|
||||
can't fully reproduce the router's `afterEach` re-check timing.
|
||||
@@ -15,13 +15,13 @@ Feature: Runtime settings are read once at startup
|
||||
CORS_ALLOWED_ORIGINS on every router build,
|
||||
api/handlers/auth.shouldCookieBeSecure reads ENVIRONMENT on every
|
||||
Login/Register/Logout call, internal/infrastructure/mailer.MailRepository.Send
|
||||
reads SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT on every call,
|
||||
and internal/infrastructure/persistence.ImageRepository.Create and
|
||||
.Delete read UPLOAD_DIR on every call
|
||||
reads SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT
|
||||
on every call, and internal/infrastructure/persistence.ImageRepository.Create
|
||||
and .Delete read UPLOAD_DIR on every call
|
||||
And this slice introduces internal/infrastructure/config.Settings, a
|
||||
plain struct with fields Environment, CorsAllowedOrigins ([]string),
|
||||
PublicURL, UploadDir, JWTSecret, DBFile, Port and an Smtp sub-struct
|
||||
(Email, Password, Host, Port)
|
||||
(Login, Sender, Password, Host, Port)
|
||||
And cmd/server/main.go's initEnvVariables still loads the .env file and
|
||||
applies the same defaults and required-var checks as before, then
|
||||
builds exactly one config.Settings value from the process environment,
|
||||
@@ -34,9 +34,9 @@ Feature: Runtime settings are read once at startup
|
||||
the upload dir, and the router's logging and CORS setup receive the
|
||||
environment string and the CORS origins list as parameters
|
||||
And after this slice, os.Getenv (and os.LookupEnv) for ENVIRONMENT,
|
||||
CORS_ALLOWED_ORIGINS, SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST, SMTP_PORT
|
||||
and UPLOAD_DIR appears only inside cmd/server/main.go and
|
||||
internal/infrastructure/config
|
||||
CORS_ALLOWED_ORIGINS, SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD,
|
||||
SMTP_HOST, SMTP_PORT and UPLOAD_DIR appears only inside
|
||||
cmd/server/main.go and internal/infrastructure/config
|
||||
And a component built from a config.Settings value keeps behaving
|
||||
according to the values it was built with even if the process
|
||||
environment changes afterwards, because it holds the captured values,
|
||||
@@ -109,8 +109,9 @@ Feature: Runtime settings are read once at startup
|
||||
Secure attribute set to true
|
||||
|
||||
Scenario: The mailer is constructed from injected SMTP settings, not the environment
|
||||
Given a config.Settings value with Smtp.Email "notify@example.com",
|
||||
Smtp.Host "smtp.example.com" and Smtp.Port 587
|
||||
Given a config.Settings value with Smtp.Login "smtp-login@example.com",
|
||||
Smtp.Sender "notify@example.com", Smtp.Host "smtp.example.com" and
|
||||
Smtp.Port 587
|
||||
And internal/infrastructure/mailer.NewMailRepository is called with that
|
||||
Smtp settings value and no SMTP_* environment variable set
|
||||
Then constructing the MailRepository does not read any SMTP_*
|
||||
|
||||
@@ -83,9 +83,10 @@ Package: `test` (black-box).
|
||||
constructor signature changes.
|
||||
|
||||
## Mailer
|
||||
16. Construct `mailer.NewMailRepository(config.SmtpSettings{Email:
|
||||
"notify@example.com", Host: "smtp.example.com", Port: 587, Password:
|
||||
"unused-in-test"})` with no `SMTP_*` environment variable set (do not
|
||||
16. Construct `mailer.NewMailRepository(config.SmtpSettings{Login:
|
||||
"smtp-login@example.com", Sender: "notify@example.com", Host:
|
||||
"smtp.example.com", Port: 587, Password: "unused-in-test"})` with no
|
||||
`SMTP_*` environment variable set (do not
|
||||
call `t.Setenv` for any `SMTP_*` name in this test). Assert construction
|
||||
does not panic and does not error — this is the only automated
|
||||
assertion available, since exercising `Send` requires a real SMTP dialog
|
||||
@@ -95,7 +96,8 @@ Package: `test` (black-box).
|
||||
|
||||
## Manual verification (not exercised by the Go test)
|
||||
- Real SMTP send: run `task dev` (or a built binary) with a real `.env`
|
||||
populated with SMTP_EMAIL/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT, trigger a
|
||||
populated with SMTP_LOGIN/SMTP_SENDER/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT,
|
||||
trigger a
|
||||
flow that calls `SendMailUseCase.SendMail`, and confirm the message
|
||||
actually leaves via the injected host/port/credentials, and that changing
|
||||
those env vars requires a process restart to take effect (there is no
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
Feature: Verify account screen
|
||||
|
||||
As a user who just clicked the verification link in their email
|
||||
I want /register/validate?c=CODE to submit that code automatically, land me on my dashboard once
|
||||
it's accepted, tell me plainly when it's wrong or expired, and let me log in inline if I opened
|
||||
the link somewhere I'm not logged in
|
||||
So that verifying my account is a single click from my inbox, whatever device or session state I
|
||||
open the link in
|
||||
|
||||
Background:
|
||||
Given /register/validate is a "public" route (meta.auth "public"), rendered as VerifyPage
|
||||
inside AuthShell, per docs/design/routes.md; the router's beforeEach never runs session.check
|
||||
for a public route, so VerifyPage starts with no assumption about the session
|
||||
And VerifyPage only calls POST /v1/auth/verify, DELETE /v1/auth/register, and POST
|
||||
/v1/auth/logout (and, inline, POST /v1/auth/login on a 401)
|
||||
And on mount, VerifyPage reads the "c" query parameter once: if it is absent or an empty
|
||||
string, no request is ever made and the Missing code state renders immediately; otherwise the
|
||||
Verifying state renders and POST /v1/auth/verify is called immediately with { code: c }
|
||||
And every call to POST /v1/auth/verify (the initial one and the post-login retry) passes
|
||||
{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' } (the same onForbidden
|
||||
RequestOptions field added to useApi by features/pending_screen.feature, plus the existing
|
||||
onUnauthorized field used the same way getMe() already uses it), so a 401 renders the inline
|
||||
log-in state instead of the global redirect to /login, and a 403 renders the Invalid-or-
|
||||
expired state instead of the global "unverified session" redirect
|
||||
And Verifying and No-session-retrying both render the same full-card spinner: a centered
|
||||
"i-lucide-loader-2" icon with a spin animation and the text "Verifying your account…",
|
||||
role="status", replacing AuthShell's whole card body with no title header shown
|
||||
And on a 200, VerifyPage shows a ResultPanel with tone "success", icon "i-lucide-shield-check",
|
||||
title "Your account is verified", text "Taking you to your dashboard…", and no actions, while
|
||||
concurrently awaiting session.retry() (needed so /home's route guard sees the now-verified
|
||||
session instead of whatever stale state this tab last cached) and an 800 ms timer; once both
|
||||
settle, the app navigates to /home and a toast (color "success", icon "i-lucide-circle-check",
|
||||
title "Your account is verified", description "Welcome to RenewCMS.") is shown
|
||||
And on a 403 (user.ErrInvalidVerificationCode or user.ErrVerificationCodeExpired), VerifyPage
|
||||
shows a ResultPanel with tone "error", icon "i-lucide-link-2-off", title "This verification
|
||||
link doesn't work", text "It may have expired (links last 2 hours) or already been used.",
|
||||
and a note (rendered via the default slot) "Cancel your registration and start over to get a
|
||||
new link.", with a primary action "Cancel registration and start over" and a secondary action
|
||||
"Log out"; unlike Pending's cancel flow, this button opens no ConfirmDialog (verify.md's state
|
||||
list has no confirm step for it) and calls DELETE /v1/auth/register directly, passing
|
||||
{ onForbidden: 'passthrough' } for the same reason Pending's own DELETE call does
|
||||
And "Cancel registration and start over" resolving 200 navigates to /register and shows the
|
||||
same toast Pending's own Cancelled state shows (color "success", icon "i-lucide-circle-check",
|
||||
title "Registration cancelled", description "You can create the owner account again."),
|
||||
reusing that copy since it is the same underlying event
|
||||
And "Cancel registration and start over" failing — with a 403 (already verified elsewhere, so
|
||||
there is nothing left to cancel), a 500, or a request that never reaches the server — shows an
|
||||
alert on the same ResultPanel screen via AuthShell's #notice slot, reusing Register's existing
|
||||
server-error pattern: color "error", icon "i-lucide-circle-x", title "Couldn't cancel
|
||||
registration", description "Something went wrong on the server. Try again in a moment.",
|
||||
variant "soft", not closable, role "alert"; the ResultPanel and its two actions stay exactly
|
||||
as they were so the user can try again or choose "Log out" instead; no redirect happens for
|
||||
the 403 case here, unlike Pending's own already-verified handling
|
||||
And "Log out" (on the Invalid-or-expired state) behaves exactly as it does on Pending: it calls
|
||||
POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and clears the jwt
|
||||
cookie, then VerifyPage calls session.clear() and navigates to /login, disabling both actions
|
||||
while the call is in flight
|
||||
And on a 401, VerifyPage replaces the spinner with an inline log-in form: AuthShell title "Log
|
||||
in to finish verifying", description "You opened this link somewhere you're not logged in.
|
||||
Log in here and we'll verify your account right after.", a Username field (placeholder "Your
|
||||
username") and a Password field (password input with a show/hide toggle), and a submit button
|
||||
"Log in and verify"; there is no "Forgot password?" hint and no registration-open footer,
|
||||
unlike LoginView
|
||||
And the inline form's submit is disabled until both fields are non-empty; on submit it calls
|
||||
POST /v1/auth/login with { username, password }
|
||||
And a 200 from that login call transitions to the No-session-retrying spinner and immediately
|
||||
retries POST /v1/auth/verify with the same code and the same passthrough options, which then
|
||||
follows the 200/403 handling above
|
||||
And a 403 (wrong credentials), 429, or 500/network failure from that inline login call reuses
|
||||
LoginView's existing alert copy for the same statuses (wrongCredentialsAlert, with LoginView's
|
||||
exact title/description; tooManyAttemptsAlert with the same retryAfter countdown behavior as
|
||||
LoginView; serverErrorAlert), shown via AuthShell's #notice slot, since verify.md specifies no
|
||||
separate copy for this embedded form and states.md's HTTP-status table assigns this copy
|
||||
generically to "Log in" contexts
|
||||
And while that inline login is submitting, both fields are disabled and the submit button reads
|
||||
"Logging in…" with a loading indicator, mirroring LoginView's submitting state
|
||||
|
||||
Scenario: Verifying — the code submits automatically on load
|
||||
When the user navigates to "/register/validate?c=abc123"
|
||||
Then the full-card spinner is shown with the text "Verifying your account…"
|
||||
And POST /v1/auth/verify is called once, immediately, with body {"code": "abc123"}
|
||||
|
||||
Scenario: Success — 200 refreshes the session and navigates to /home with a toast
|
||||
Given the user is on "/register/validate?c=abc123" in the Verifying state
|
||||
When POST /v1/auth/verify resolves 200
|
||||
Then a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "Your
|
||||
account is verified", text "Taking you to your dashboard…", and no actions
|
||||
And session.retry() is awaited
|
||||
And after 800 ms and once session.retry() has resolved, the app navigates to /home
|
||||
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Your account is
|
||||
verified", description "Welcome to RenewCMS."
|
||||
|
||||
Scenario: Invalid or expired — 403 offers a way to start over
|
||||
Given the user is on "/register/validate?c=abc123" in the Verifying state
|
||||
When POST /v1/auth/verify resolves 403
|
||||
Then a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This
|
||||
verification link doesn't work", text "It may have expired (links last 2 hours) or already
|
||||
been used.", and note "Cancel your registration and start over to get a new link."
|
||||
And its actions are a primary "Cancel registration and start over" and a secondary "Log out"
|
||||
|
||||
Scenario: Cancel registration and start over — success
|
||||
Given the ResultPanel from "Invalid or expired" is shown
|
||||
When the user activates "Cancel registration and start over"
|
||||
Then DELETE /v1/auth/register is sent directly, without any confirmation dialog, with
|
||||
{ onForbidden: 'passthrough' }
|
||||
When it resolves 200
|
||||
Then the app navigates to /register
|
||||
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
|
||||
cancelled", description "You can create the owner account again."
|
||||
|
||||
Scenario Outline: Cancel registration and start over — failure shows an alert, not a redirect
|
||||
Given the ResultPanel from "Invalid or expired" is shown
|
||||
When the user activates "Cancel registration and start over"
|
||||
And DELETE /v1/auth/register <failure>
|
||||
Then an alert is shown with color "error", icon "i-lucide-circle-x", title "Couldn't cancel
|
||||
registration", description "Something went wrong on the server. Try again in a moment."
|
||||
And the ResultPanel and its two actions ("Cancel registration and start over", "Log out") are
|
||||
still shown, unchanged, so the user can retry or log out instead
|
||||
And no navigation happens
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves 403 with the apperr.AlreadyVerifiedErrorMessage body |
|
||||
| fails with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Log out from the Invalid-or-expired state
|
||||
Given the ResultPanel from "Invalid or expired" is shown
|
||||
When the user activates "Log out"
|
||||
Then POST /v1/auth/logout is sent
|
||||
When it resolves 200
|
||||
Then the session store is cleared
|
||||
And the app navigates to /login
|
||||
|
||||
Scenario: No session — the inline log-in form replaces the spinner
|
||||
Given the user is on "/register/validate?c=abc123" in the Verifying state
|
||||
When POST /v1/auth/verify resolves 401
|
||||
Then the spinner is replaced by a form titled "Log in to finish verifying" with description
|
||||
"You opened this link somewhere you're not logged in. Log in here and we'll verify your
|
||||
account right after."
|
||||
And it has a Username field (placeholder "Your username") and a Password field with a show/hide
|
||||
toggle, and a submit button "Log in and verify"
|
||||
And there is no "Forgot password?" link and no registration-open footer
|
||||
|
||||
Scenario: No session — submit is disabled until both fields are filled
|
||||
Given the inline log-in form from "No session" is shown
|
||||
Then the submit button is disabled
|
||||
When the user types "marta" into Username and "limewash2026" into Password
|
||||
Then the submit button is enabled
|
||||
|
||||
Scenario: No session — successful login retries verification with the same code
|
||||
Given the inline log-in form from "No session" is shown on "/register/validate?c=abc123"
|
||||
When the user submits Username "marta" and Password "limewash2026"
|
||||
And POST /v1/auth/login resolves 200
|
||||
Then the full-card spinner is shown again with the text "Verifying your account…"
|
||||
And POST /v1/auth/verify is sent again, with body {"code": "abc123"} and the same passthrough
|
||||
options as the first attempt
|
||||
|
||||
Scenario: No session — wrong credentials during the inline login
|
||||
Given the inline log-in form from "No session" is shown
|
||||
When the user submits Username "marta" and Password "wrong-password"
|
||||
And POST /v1/auth/login resolves 403
|
||||
Then an error alert is shown with title "That username and password don't match" and
|
||||
description "Check both and try again. Passwords are case-sensitive."
|
||||
And the Password field is cleared and refocused
|
||||
And POST /v1/auth/verify is not retried
|
||||
|
||||
Scenario: No session — too many login attempts
|
||||
Given the inline log-in form from "No session" is shown
|
||||
When the user submits Username "marta" and Password "limewash2026"
|
||||
And POST /v1/auth/login resolves 429 with Retry-After 30
|
||||
Then an info alert is shown with title "Too many attempts" and description "For your security,
|
||||
wait a moment before trying again."
|
||||
And the submit button reads "Try again in 0:30" and is disabled until the countdown ends
|
||||
|
||||
Scenario Outline: No session — server or network error during the inline login
|
||||
Given the inline log-in form from "No session" is shown
|
||||
When the user submits Username "marta" and Password "limewash2026"
|
||||
And POST /v1/auth/login <failure>
|
||||
Then an error alert is shown with title "Couldn't reach RenewCMS" and description "Check your
|
||||
connection and try again."
|
||||
And the fields keep their values and are re-enabled
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| fails with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Missing code — no request is made
|
||||
When the user navigates to "/register/validate" with no "c" query parameter
|
||||
Then no request is made
|
||||
And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This link is
|
||||
incomplete", text "The verification code is missing. Open the link from your email again, or
|
||||
copy the full address into your browser."
|
||||
And its single action is a secondary "Go to log in"
|
||||
When the user activates "Go to log in"
|
||||
Then the app navigates to /login
|
||||
@@ -0,0 +1,94 @@
|
||||
# QA: Verify account screen
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi` (`POST /v1/auth/verify`, `POST /v1/auth/login`, `DELETE /v1/auth/register`,
|
||||
`POST /v1/auth/logout`) and `useToast` per case; no real backend, no httptest server. Mount
|
||||
VerifyPage inside AuthShell with a test router whose current route starts at the "c" query the case
|
||||
needs; use fake timers for the 800 ms success delay and the 429 countdown.
|
||||
|
||||
1. **Verifying state** — mount at "/register/validate?c=abc123"; assert the full-card spinner text
|
||||
"Verifying your account…" and that `POST /v1/auth/verify` was called exactly once with
|
||||
`{ code: 'abc123' }` and `{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' }`.
|
||||
2. **Success state** — mock `POST /v1/auth/verify` resolving 200; mock the session store's
|
||||
`retry()` to resolve; mount and let it settle; assert the ResultPanel (success, shield-check,
|
||||
"Your account is verified", "Taking you to your dashboard…", no actions); advance fake timers by
|
||||
800 ms; assert `retry()` was called; assert the router's current path is /home and a toast was
|
||||
raised with title "Your account is verified", description "Welcome to RenewCMS."
|
||||
3. **Invalid or expired state** — mock `POST /v1/auth/verify` resolving 403; assert the ResultPanel
|
||||
(error, link-2-off, "This verification link doesn't work", the text and note above) with actions
|
||||
"Cancel registration and start over" (primary) and "Log out" (secondary).
|
||||
4. **Cancel registration and start over — success** — from state 3, mock
|
||||
`DELETE /v1/auth/register` resolving 200; click the action; assert it was called with
|
||||
`{ onForbidden: 'passthrough' }` and that no ConfirmDialog was opened; assert the router's current
|
||||
path is /register and a toast was raised with title "Registration cancelled", description "You
|
||||
can create the owner account again."
|
||||
5. **Cancel registration and start over — failure shows an alert, not a redirect** — three
|
||||
sub-cases from state 3: (a) `DELETE /v1/auth/register` resolves 403 (already verified elsewhere),
|
||||
(b) resolves 500, (c) a rejected fetch. In each, click the action and assert an alert is shown
|
||||
with color "error", icon "i-lucide-circle-x", title "Couldn't cancel registration", description
|
||||
"Something went wrong on the server. Try again in a moment."; assert the ResultPanel and both of
|
||||
its actions ("Cancel registration and start over", "Log out") are still rendered, unchanged;
|
||||
assert the router's current path did not change (in particular, sub-case (a) must NOT navigate to
|
||||
/home — unlike Pending's own already-verified handling, this screen never redirects on this 403).
|
||||
6. **Log out from Invalid or expired** — from state 3, mock `POST /v1/auth/logout` resolving 200;
|
||||
click "Log out"; assert the session store's `clear()` was called and the router's current path is
|
||||
/login.
|
||||
7. **No session state** — mock `POST /v1/auth/verify` resolving 401; assert the form (title "Log in
|
||||
to finish verifying", description, Username placeholder "Your username", Password with show/hide,
|
||||
submit "Log in and verify") and assert no "Forgot password?" link and no registration footer are
|
||||
rendered.
|
||||
8. **No session — submit disabled until filled** — from state 7, assert the submit button is
|
||||
disabled; fill both fields; assert it becomes enabled.
|
||||
9. **No session — successful login retries** — from state 7, fill valid credentials; mock
|
||||
`POST /v1/auth/login` resolving 200; mock a second `POST /v1/auth/verify` call; submit; assert the
|
||||
spinner "Verifying your account…" reappears and the second `POST /v1/auth/verify` call was made
|
||||
with the same code and the same passthrough options.
|
||||
10. **No session — wrong credentials** — from state 7, mock `POST /v1/auth/login` resolving 403;
|
||||
submit "marta" / "wrong-password"; assert the error alert ("That username and password don't
|
||||
match", "Check both and try again. Passwords are case-sensitive."), the Password field cleared
|
||||
and focused, and that `POST /v1/auth/verify` was not called again.
|
||||
11. **No session — too many attempts** — from state 7, mock `POST /v1/auth/login` resolving 429 with
|
||||
`Retry-After: 30`; submit; assert the info alert ("Too many attempts", "For your security, wait a
|
||||
moment before trying again."), the submit button reading "Try again in 0:30" and disabled;
|
||||
advance fake timers 30s; assert it re-enables.
|
||||
12. **No session — server/network error** — two sub-cases (500, rejected fetch) from state 7; submit
|
||||
valid-looking credentials; assert the error alert ("Couldn't reach RenewCMS", "Check your
|
||||
connection and try again.") and that both fields keep their values and re-enable.
|
||||
13. **Missing code state** — mount at "/register/validate" (no "c"); assert no request was made
|
||||
(neither `POST /v1/auth/verify` nor `POST /v1/auth/login`); assert the ResultPanel (error,
|
||||
link-2-off, "This link is incomplete", the text above) with a single secondary action "Go to log
|
||||
in"; click it and assert the router's current path is /login.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Verify". Run
|
||||
`task visual:check -- verify` for each state and compare against the board frame.
|
||||
|
||||
- [ ] Verifying state: the full-card spinner (the only one in the product) matches the board's
|
||||
layout exactly, with no title header shown.
|
||||
- [ ] Success state: ResultPanel with no actions, transient toast over the /home board.
|
||||
- [ ] Invalid or expired state: ResultPanel with note and both actions, button order (primary
|
||||
"Cancel registration and start over" first per components.md's safe/primary-on-top rule for
|
||||
stacked mobile buttons).
|
||||
- [ ] Cancel-failed alert: the error alert renders above the ResultPanel via AuthShell's #notice
|
||||
slot, matching Register's own server-error alert styling exactly.
|
||||
- [ ] No session state: the trimmed-down log-in form (no forgot-password link, no footer) matches
|
||||
the board.
|
||||
- [ ] No session, retrying state: identical spinner to Verifying.
|
||||
- [ ] Missing code state: ResultPanel with a single secondary action.
|
||||
- [ ] Keyboard/focus: focus moves to the ResultPanel title on the Success, Invalid-or-expired, and
|
||||
Missing-code states; on No session, focus order is Username → Password → show/hide → submit;
|
||||
the spinner's `role="status"` text is announced once, not on every re-render; the cancel-failed
|
||||
alert is `role="alert"` and does not steal focus from the ResultPanel title.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real POST /v1/auth/verify and POST /v1/auth/login against a running server** — Vitest only mocks
|
||||
`useApi`; confirm the real 200/403/401 round-trips (including the actual bcrypt code check and
|
||||
2-hour expiry) against `task dev` with a live SQLite file. The endpoint's own contract is already
|
||||
covered by `features/account_verification.qa.md` and `features/login_screen.qa.md`; this slice
|
||||
only re-verifies the screen consumes it correctly.
|
||||
- **Real DELETE /v1/auth/register from this screen** — confirm the 200/403/500 round-trips against a
|
||||
live server; the endpoint's own contract is already covered by
|
||||
`features/cancel_pending_registration.qa.md`.
|
||||
Reference in new issue
Block a user