feat: register T1 (pending, verify)

This commit is contained in:
fsylvain committed 2026-09-26 04:56:48 +02:00
1 parent 3c3f7c6194
commit 4e41d60142
29 files changed
+2160 -40

No files matched your search

+3 -2
View File
@@ -1,9 +1,10 @@
PUBLIC_URL=http://localhost:5173
JWT_SECRET=change-me-to-a-long-random-string
SMTP_EMAIL=a@b.c
SMTP_PASSWORD=12341234
SMTP_HOST=mail.service.abc
SMTP_PORT=465
SMTP_LOGIN=a@b.c
SMTP_PASSWORD=12341234
SMTP_SENDER=a@b.c
ENVIRONMENT=development
PORT=8080
+2 -1
View File
@@ -32,7 +32,8 @@ var possibleEnvFileLocations = []string{".env", "../.env"}
var requiredEnvVars = []string{
"PUBLIC_URL",
"JWT_SECRET",
"SMTP_EMAIL",
"SMTP_LOGIN",
"SMTP_SENDER",
"SMTP_PASSWORD",
"SMTP_HOST",
"SMTP_PORT",
+2 -1
View File
@@ -12,7 +12,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------|
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` |
| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` |
| `SMTP_EMAIL` | Sender address and SMTP username. | `noreply@example.com` |
| `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` |
| `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` |
| `SMTP_PASSWORD` | SMTP password. | |
| `SMTP_HOST` | SMTP server host. | `smtp.example.com` |
| `SMTP_PORT` | SMTP server port. | `465` |
+2 -1
View File
@@ -47,7 +47,8 @@ PORT=8080
DB_FILE=/var/lib/renewcms/renewcms.db
UPLOAD_DIR=/var/lib/renewcms/uploads
JWT_SECRET=<openssl rand -hex 32>
SMTP_EMAIL=noreply@example.com
SMTP_LOGIN=noreply@example.com
SMTP_SENDER=noreply@example.com
SMTP_PASSWORD=...
SMTP_HOST=smtp.example.com
SMTP_PORT=465
+152
View File
@@ -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 |
+81
View File
@@ -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.
+10 -9
View File
@@ -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_*
+6 -4
View File
@@ -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
+197
View File
@@ -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
+94
View File
@@ -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`.
+4 -2
View File
@@ -8,7 +8,8 @@ import (
)
type SmtpSettings struct {
Email string
Login string
Sender string
Password string
Host string
Port int
@@ -61,7 +62,8 @@ func NewSettingsFromEnv() (Settings, error) {
DBFile: os.Getenv("DB_FILE"),
Port: os.Getenv("PORT"),
Smtp: SmtpSettings{
Email: os.Getenv("SMTP_EMAIL"),
Login: os.Getenv("SMTP_LOGIN"),
Sender: os.Getenv("SMTP_SENDER"),
Password: os.Getenv("SMTP_PASSWORD"),
Host: os.Getenv("SMTP_HOST"),
Port: smtpPort,
@@ -23,12 +23,13 @@ func NewMailRepository(smtp config.SmtpSettings) *MailRepository {
}
func (m MailRepository) Send(receiverAddress string, templateName string, data any) error {
from := m.smtp.Email
login := m.smtp.Login
sender := m.smtp.Sender
password := m.smtp.Password
smtpHost := m.smtp.Host
smtpPort := m.smtp.Port
d := gomail.NewDialer(smtpHost, smtpPort, from, password)
d := gomail.NewDialer(smtpHost, smtpPort, login, password)
tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html")
if err != nil {
@@ -45,11 +46,8 @@ func (m MailRepository) Send(receiverAddress string, templateName string, data a
msg := gomail.NewMessage()
msg.SetHeaders(map[string][]string{
"From": {"RenewCMS <" + from + ">"},
"From": {"RenewCMS <" + sender + ">"},
"To": {receiverAddress},
"MIME-version": {"1.0"},
"Content-Type": {"text/html"},
"charset": {"UTF-8"},
"Subject": {"RenewCMS | Action required"},
})
msg.SetBody("text/html", body.String())
+7 -3
View File
@@ -14,7 +14,8 @@ func setBaseSettingsEnv(t *testing.T) {
t.Setenv("JWT_SECRET", "test-secret")
t.Setenv("DB_FILE", "./data/renewcms.db")
t.Setenv("PORT", "8080")
t.Setenv("SMTP_EMAIL", "notify@example.com")
t.Setenv("SMTP_LOGIN", "smtp-login@example.com")
t.Setenv("SMTP_SENDER", "notify@example.com")
t.Setenv("SMTP_PASSWORD", "hunter2")
t.Setenv("SMTP_HOST", "smtp.example.com")
t.Setenv("SMTP_PORT", "465")
@@ -50,8 +51,11 @@ func TestNewSettingsFromEnvReadsAllFields(t *testing.T) {
if settings.Port != "8080" {
t.Errorf("expected Port %q, got %q", "8080", settings.Port)
}
if settings.Smtp.Email != "notify@example.com" {
t.Errorf("expected Smtp.Email %q, got %q", "notify@example.com", settings.Smtp.Email)
if settings.Smtp.Login != "smtp-login@example.com" {
t.Errorf("expected Smtp.Login %q, got %q", "smtp-login@example.com", settings.Smtp.Login)
}
if settings.Smtp.Sender != "notify@example.com" {
t.Errorf("expected Smtp.Sender %q, got %q", "notify@example.com", settings.Smtp.Sender)
}
if settings.Smtp.Password != "hunter2" {
t.Errorf("expected Smtp.Password %q, got %q", "hunter2", settings.Smtp.Password)
+2 -1
View File
@@ -230,7 +230,8 @@ func TestRuntimeSettingsInjectionUploadDirectory(t *testing.T) {
func TestRuntimeSettingsInjectionMailer(t *testing.T) {
smtp := config.SmtpSettings{
Email: "notify@example.com",
Login: "smtp-login@example.com",
Sender: "notify@example.com",
Host: "smtp.example.com",
Port: 587,
Password: "unused-in-test",
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
title: string
description: string
confirmLabel: string
cancelLabel: string
pendingLabel: string
tone: 'danger' | 'neutral'
action: () => Promise<void>
}>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const open = defineModel<boolean>('open', { default: true })
const pending = ref(false)
const error = ref('')
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
onMounted(async () => {
await nextTick()
cancelButton.value?.$el?.focus()
})
function settle(confirmed: boolean) {
if (settled) {
return
}
settled = true
open.value = false
emit('close', confirmed)
}
watch(open, (value) => {
if (!value) {
settle(false)
}
})
async function onConfirm() {
if (pending.value) {
return
}
pending.value = true
error.value = ''
try {
await props.action()
settle(true)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Something went wrong. Try again.'
} finally {
pending.value = false
}
}
function onCancel() {
if (pending.value) {
return
}
settle(false)
}
</script>
<template>
<UModal
v-model:open="open"
:title="title"
:description="description"
:dismissible="!pending"
:close="false"
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
:ui="{
content:
'max-sm:!inset-x-0 max-sm:!bottom-0 max-sm:!top-auto max-sm:!left-0 max-sm:!right-0 max-sm:!translate-x-0 max-sm:!translate-y-0 max-sm:!w-full max-sm:!max-w-none',
}"
>
<template v-if="error" #body>
<UAlert
color="error"
icon="i-lucide-circle-x"
:description="error"
variant="soft"
:close="false"
role="alert"
/>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
ref="cancelButton"
color="neutral"
variant="outline"
block
class="sm:w-auto"
:disabled="pending"
@click="onCancel"
>
{{ cancelLabel }}
</UButton>
<UButton
:color="confirmColor"
:loading="pending"
:disabled="pending"
block
class="sm:w-auto"
@click="onConfirm"
>
{{ confirmText }}
</UButton>
</div>
</template>
</UModal>
</template>
+3 -1
View File
@@ -6,6 +6,7 @@ interface ResultPanelAction {
color?: 'primary' | 'neutral'
variant?: 'solid' | 'soft' | 'outline' | 'ghost'
to?: string
disabled?: boolean
onClick?: () => void
}
@@ -46,6 +47,7 @@ onMounted(async () => {
</h1>
<p class="text-toned text-sm">{{ description }}</p>
</div>
<slot />
<div class="flex w-full flex-col gap-2">
<UButton
v-for="action in actions"
@@ -55,11 +57,11 @@ onMounted(async () => {
:color="action.color ?? 'primary'"
:variant="action.variant ?? 'solid'"
:to="action.to"
:disabled="action.disabled"
@click="action.onClick"
>
{{ action.label }}
</UButton>
</div>
<slot />
</div>
</template>
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ConfirmDialog from '../ConfirmDialog.vue'
afterEach(() => {
document.body.innerHTML = ''
})
function mountDialog(action: () => Promise<void>) {
return mount(ConfirmDialog, {
attachTo: document.body,
props: {
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',
pendingLabel: 'Cancelling…',
tone: 'danger',
action,
},
global: {
plugins: [uiPlugin],
},
})
}
function findButtonElement(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButtonElement(text)?.click()
await flushPromises()
}
describe('Confirm cancel — the destructive dialog opens', () => {
it('renders title, description, both buttons, role and starts focus on the cancel button', async () => {
const wrapper = mountDialog(() => new Promise(() => {}))
await flushPromises()
expect(document.body.textContent).toContain('Cancel your registration?')
expect(document.body.textContent).toContain(
'Your account will be deleted. You can register again with the same or a different email.',
)
const cancelButton = findButtonElement('Keep my account')
const confirmButton = findButtonElement('Cancel registration')
expect(cancelButton).toBeTruthy()
expect(confirmButton).toBeTruthy()
expect(document.querySelector('[role="alertdialog"]')).toBeTruthy()
expect(cancelButton).toBe(document.activeElement)
wrapper.unmount()
})
})
describe('Cancelling — the action is in flight and the dialog cannot be dismissed', () => {
it('disables both buttons, shows the pending label and sets aria-busy', async () => {
let resolveAction: (() => void) | null = null
const action = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveAction = resolve
}),
)
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(action).toHaveBeenCalledTimes(1)
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(true)
expect(findButtonElement('Cancelling…')).toBeTruthy()
expect((findButtonElement('Cancelling…') as HTMLButtonElement).disabled).toBe(true)
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
resolveAction?.()
await flushPromises()
wrapper.unmount()
})
})
describe('Keep my account — the dialog is dismissed without deleting anything', () => {
it('emits close(false) without ever calling the action', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Keep my account')
expect(action).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[false]])
wrapper.unmount()
})
})
describe('Cancelled — resolving the action closes the dialog', () => {
it('emits close(true) once the action resolves', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toEqual([[true]])
wrapper.unmount()
})
})
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
it('re-enables both buttons and shows the rejection message inline', async () => {
const action = vi.fn(() => Promise.reject(new Error("Couldn't cancel registration.")))
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toBeFalsy()
expect(document.body.textContent).toContain("Couldn't cancel registration.")
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(false)
expect((findButtonElement('Cancel registration') as HTMLButtonElement).disabled).toBe(false)
wrapper.unmount()
})
})
@@ -129,6 +129,26 @@ describe('useApi global 401/403/429/offline handling', () => {
},
)
it('A 403 with onForbidden passthrough never redirects even when the session is unverified', async () => {
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
await router.push('/login')
const pathBefore = router.currentRoute.value.fullPath
fetchMock.mockResolvedValue(mockResponse(403))
const call = useApi().request('/v1/auth/register', {
method: 'DELETE',
onForbidden: 'passthrough',
})
await expectApiError(call, { status: 403 })
await settle()
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
expect(session.session).toEqual({ verified: false })
})
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
+4 -2
View File
@@ -24,9 +24,11 @@ export interface MeResult {
}
export type OnUnauthorized = 'redirect' | 'passthrough'
export type OnForbidden = 'redirect' | 'passthrough'
export interface RequestOptions extends RequestInit {
onUnauthorized?: OnUnauthorized
onForbidden?: OnForbidden
}
export const isOffline = ref(false)
@@ -58,7 +60,7 @@ if (typeof window !== 'undefined') {
export function useApi() {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { onUnauthorized = 'redirect', ...init } = options
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
let response: Response
try {
@@ -80,7 +82,7 @@ export function useApi() {
if (response.status === 403) {
const session = useSessionStore()
if (session.session?.verified === false) {
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
router.push('/register/pending')
}
throw new ApiError(403)
+6
View File
@@ -0,0 +1,6 @@
export const registrationCancelledToast = {
color: 'success' as const,
icon: 'i-lucide-circle-check',
title: 'Registration cancelled',
description: 'You can create the owner account again.',
}
+18 -2
View File
@@ -1,3 +1,5 @@
const overlayBackground = 'bg-(--ui-color-neutral-950)/40'
export const renewUi = {
colors: {
primary: 'ink',
@@ -121,13 +123,27 @@ export const renewUi = {
modal: {
slots: {
content: 'rounded-none shadow-(--shadow-modal) divide-default',
overlay: 'bg-(--ui-color-neutral-950)/40',
overlay: overlayBackground,
},
variants: {
overlay: {
true: {
overlay: overlayBackground,
},
},
},
},
slideover: {
slots: {
content: 'rounded-none shadow-(--shadow-modal)',
overlay: 'bg-(--ui-color-neutral-950)/40',
overlay: overlayBackground,
},
variants: {
overlay: {
true: {
overlay: overlayBackground,
},
},
},
},
toast: {
+128 -2
View File
@@ -1,9 +1,135 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import AuthShell from '@/components/AuthShell.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { registrationCancelledToast } from '@/lib/registrationToasts'
import { useSessionStore } from '@/stores/session'
const router = useRouter()
const api = useApi()
const session = useSessionStore()
const overlay = useOverlay()
const toast = useToast()
const pendingEmail = ref<string | null>(null)
const loggingOut = ref(false)
const cancelOutcome = ref<'cancelled' | 'alreadyVerified' | null>(null)
onMounted(() => {
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
document.title = 'Check your email · RenewCMS'
})
const description = computed(() =>
pendingEmail.value
? `We sent a verification link to ${pendingEmail.value}. It expires in 2 hours.`
: 'We sent a verification link to the email you registered with. It expires in 2 hours.',
)
async function logOut() {
loggingOut.value = true
try {
await api.request('/v1/auth/logout', { method: 'POST' })
} finally {
loggingOut.value = false
}
session.clear()
await router.push('/login')
}
async function deleteRegistration() {
try {
await api.request('/v1/auth/register', {
method: 'DELETE',
onForbidden: 'passthrough',
})
cancelOutcome.value = 'cancelled'
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
await session.retry()
cancelOutcome.value = 'alreadyVerified'
return
}
throw new Error(
"Couldn't cancel registration, so it's still pending. Try again in a moment.",
)
}
}
async function cancelRegistration() {
cancelOutcome.value = null
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
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',
pendingLabel: 'Cancelling…',
tone: 'danger',
action: deleteRegistration,
},
})
.open()
if (!confirmed) {
return
}
if (cancelOutcome.value === 'cancelled') {
await router.push('/register')
toast.add(registrationCancelledToast)
return
}
if (cancelOutcome.value === 'alreadyVerified') {
await router.push('/home')
toast.add({
color: 'info',
icon: 'i-lucide-info',
title: 'Your account is already verified',
description: "There's nothing to cancel.",
})
}
}
const actions = computed(() => [
{
label: 'Log out',
color: 'neutral' as const,
variant: 'outline' as const,
disabled: loggingOut.value,
onClick: logOut,
},
{
label: 'Cancel registration',
color: 'neutral' as const,
variant: 'outline' as const,
disabled: loggingOut.value,
onClick: cancelRegistration,
},
])
</script>
<template>
<AuthShell title="Check your inbox">
<p>Pending verification placeholder.</p>
<AuthShell title="">
<ResultPanel
tone="success"
icon="i-lucide-mail-check"
title="Check your email"
:description="description"
:actions="actions"
>
<p class="text-toned text-sm">
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.
</p>
</ResultPanel>
</AuthShell>
</template>
+1
View File
@@ -71,6 +71,7 @@ async function onSubmit() {
password: password.value,
}),
})
sessionStorage.setItem('renewcms:pendingEmail', email.value)
await router.push('/register/pending')
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
+371 -2
View File
@@ -1,9 +1,378 @@
<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 ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { registrationCancelledToast } from '@/lib/registrationToasts'
import { useSessionStore } from '@/stores/session'
type VerifyState =
| 'verifying'
| 'noSessionRetrying'
| 'success'
| 'invalid'
| 'noSession'
| 'missing'
interface AlertState {
tone: 'error' | 'info'
icon: string
title: string
description: string
}
const wrongCredentialsAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "That username and password don't match",
description: 'Check both and try again. Passwords are case-sensitive.',
}
const tooManyAttemptsAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-timer',
title: 'Too many attempts',
description: 'For your security, wait a moment before trying again.',
}
const serverErrorAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't reach RenewCMS",
description: 'Check your connection and try again.',
}
const cancelFailedAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't cancel registration",
description: 'Something went wrong on the server. Try again in a moment.',
}
const VERIFICATION_SUCCESS_DELAY_MS = 800
const route = useRoute()
const router = useRouter()
const api = useApi()
const session = useSessionStore()
const toast = useToast()
const retryAfter = useRetryAfter()
const state = ref<VerifyState>('missing')
const invalidActionsDisabled = ref(false)
const cancelAlert = ref<AlertState | null>(null)
const username = ref('')
const password = ref('')
const passwordShown = ref(false)
const loginSubmitting = ref(false)
const loginAlert = ref<AlertState | null>(null)
const shellTitle = computed(() => (state.value === 'noSession' ? 'Log in to finish verifying' : ''))
const shellDescription = computed(() =>
state.value === 'noSession'
? "You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after."
: undefined,
)
const loginSubmitDisabled = computed(
() =>
username.value.length === 0 ||
password.value.length === 0 ||
loginSubmitting.value ||
retryAfter.active.value,
)
const loginSubmitLabel = computed(() => {
if (loginSubmitting.value) {
return 'Logging in…'
}
if (retryAfter.active.value) {
return `Try again in ${retryAfter.formatted.value}`
}
return 'Log in and verify'
})
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function focusPassword() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')?.focus()
}
async function performVerify(code: string) {
try {
await api.request('/v1/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
onUnauthorized: 'passthrough',
onForbidden: 'passthrough',
})
state.value = 'success'
await Promise.all([session.retry(), wait(VERIFICATION_SUCCESS_DELAY_MS)])
await router.push('/home')
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Your account is verified',
description: 'Welcome to RenewCMS.',
})
return
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
state.value = 'noSession'
return
}
state.value = 'invalid'
}
}
async function startVerifying(code: string) {
state.value = 'verifying'
await performVerify(code)
}
async function retryVerifying(code: string) {
state.value = 'noSessionRetrying'
await performVerify(code)
}
function handleLoginError(error: unknown) {
if (error instanceof ApiError && error.status === 403) {
loginAlert.value = wrongCredentialsAlert
password.value = ''
focusPassword()
return
}
if (error instanceof ApiError && error.status === 429) {
loginAlert.value = tooManyAttemptsAlert
retryAfter.start(error.retryAfter ?? 0)
return
}
loginAlert.value = serverErrorAlert
}
async function onLoginSubmit() {
if (loginSubmitDisabled.value) {
return
}
loginAlert.value = null
loginSubmitting.value = true
try {
await api.request('/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
})
const code = typeof route.query.c === 'string' ? route.query.c : ''
await retryVerifying(code)
} catch (error) {
handleLoginError(error)
} finally {
loginSubmitting.value = false
}
}
async function cancelAndStartOver() {
cancelAlert.value = null
invalidActionsDisabled.value = true
try {
await api.request('/v1/auth/register', {
method: 'DELETE',
onForbidden: 'passthrough',
})
await router.push('/register')
toast.add(registrationCancelledToast)
} catch {
cancelAlert.value = cancelFailedAlert
} finally {
invalidActionsDisabled.value = false
}
}
async function logOutFromInvalid() {
invalidActionsDisabled.value = true
try {
await api.request('/v1/auth/logout', { method: 'POST' })
} finally {
invalidActionsDisabled.value = false
}
session.clear()
await router.push('/login')
}
const invalidActions = computed(() => [
{
label: 'Cancel registration and start over',
color: 'primary' as const,
variant: 'solid' as const,
disabled: invalidActionsDisabled.value,
onClick: cancelAndStartOver,
},
{
label: 'Log out',
color: 'neutral' as const,
variant: 'outline' as const,
disabled: invalidActionsDisabled.value,
onClick: logOutFromInvalid,
},
])
function goToLogin() {
router.push('/login')
}
const missingCodeActions = [
{
label: 'Go to log in',
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
},
]
onMounted(() => {
const code = typeof route.query.c === 'string' ? route.query.c : ''
if (!code) {
state.value = 'missing'
return
}
startVerifying(code)
})
</script>
<template>
<AuthShell title="Verifying your account">
<p>Verify placeholder.</p>
<AuthShell :title="shellTitle" :description="shellDescription">
<template v-if="state === 'invalid' && cancelAlert" #notice>
<UAlert
:color="cancelAlert.tone"
:icon="cancelAlert.icon"
:title="cancelAlert.title"
:description="cancelAlert.description"
variant="soft"
:close="false"
role="alert"
/>
</template>
<template v-else-if="state === 'noSession' && loginAlert" #notice>
<UAlert
:color="loginAlert.tone"
:icon="loginAlert.icon"
:title="loginAlert.title"
:description="loginAlert.description"
variant="soft"
:close="false"
:role="loginAlert.tone === 'error' ? 'alert' : 'status'"
/>
</template>
<template #default="{ controlSize, controlClass }">
<div
v-if="state === 'verifying' || state === 'noSessionRetrying'"
role="status"
class="flex flex-col items-center gap-4 py-10"
>
<UIcon name="i-lucide-loader-2" class="h-8 w-8 animate-spin text-primary" />
<p class="text-toned text-sm">Verifying your account…</p>
</div>
<ResultPanel
v-else-if="state === 'success'"
tone="success"
icon="i-lucide-shield-check"
title="Your account is verified"
description="Taking you to your dashboard…"
:actions="[]"
/>
<ResultPanel
v-else-if="state === 'invalid'"
tone="error"
icon="i-lucide-link-2-off"
title="This verification link doesn't work"
description="It may have expired (links last 2 hours) or already been used."
:actions="invalidActions"
>
<p class="text-toned text-sm">
Cancel your registration and start over to get a new link.
</p>
</ResultPanel>
<ResultPanel
v-else-if="state === 'missing'"
tone="error"
icon="i-lucide-link-2-off"
title="This link is incomplete"
description="The verification code is missing. Open the link from your email again, or copy the full address into your browser."
:actions="missingCodeActions"
/>
<UForm
v-else-if="state === 'noSession'"
:state="{ username, password }"
class="flex flex-col gap-4"
@submit="onLoginSubmit"
>
<UFormField label="Username" name="username" :size="controlSize">
<UInput
v-model="username"
placeholder="Your username"
autocomplete="username"
:disabled="loginSubmitting"
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
/>
</UFormField>
<UFormField label="Password" name="password" :size="controlSize">
<UInput
v-model="password"
:type="passwordShown ? 'text' : 'password'"
autocomplete="current-password"
:disabled="loginSubmitting"
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
: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="loginSubmitting"
:disabled="loginSubmitDisabled"
>
{{ loginSubmitLabel }}
</UButton>
</UForm>
</template>
</AuthShell>
</template>
+299
View File
@@ -0,0 +1,299 @@
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 { useSessionStore } from '@/stores/session'
import PendingView from '../PendingView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
let overlayMock: ReturnType<typeof createOverlayMock>
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => overlayMock,
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountPending(
handlers: Record<string, () => Response | Promise<Response>> = {},
pendingEmail: string | null = 'marta@renew.example',
) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
toastAdd = vi.fn()
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
if (pendingEmail === null) {
sessionStorage.removeItem('renewcms:pendingEmail')
} else {
sessionStorage.setItem('renewcms:pendingEmail', pendingEmail)
}
await router.push('/register/pending')
const wrapper = mount(PendingView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return { wrapper, fetchMock }
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
return wrapper.findAll('button').find((button) => button.text() === label)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
sessionStorage.removeItem('renewcms:pendingEmail')
await router.push('/login')
})
describe('Default — the email is known from a just-completed registration', () => {
it('shows the ResultPanel with the interpolated email', async () => {
const { wrapper } = await mountPending({}, 'marta@renew.example')
expect(wrapper.text()).toContain('Check your email')
expect(wrapper.text()).toContain(
'We sent a verification link to marta@renew.example. It expires in 2 hours.',
)
expect(wrapper.text()).toContain(
"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.",
)
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(findButton(wrapper, 'Cancel registration')).toBeTruthy()
})
})
describe('Email unknown — arrived by logging in as an unverified account', () => {
it('shows the generic text with no email', async () => {
const { wrapper } = await mountPending({}, null)
expect(wrapper.text()).toContain('Check your email')
expect(wrapper.text()).toContain(
'We sent a verification link to the email you registered with. It expires in 2 hours.',
)
})
})
describe('Logging out', () => {
it('disables both actions while in flight, clears the session and navigates to /login', async () => {
let resolveLogout: (() => void) | null = null
const { wrapper } = await mountPending({
'POST /v1/auth/logout': () =>
new Promise((resolve) => {
resolveLogout = () => resolve(mockResponse(200))
}),
})
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
await findButton(wrapper, 'Log out')?.trigger('click')
await flushPromises()
expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
expect(
(findButton(wrapper, 'Cancel registration')?.element as HTMLButtonElement).disabled,
).toBe(true)
resolveLogout?.()
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
describe('Confirm cancel — the destructive dialog opens', () => {
it('opens ConfirmDialog with the expected props, leaving the ResultPanel shown', async () => {
const { wrapper } = await mountPending()
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
expect(overlayMock.create).toHaveBeenCalledTimes(1)
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Cancel your registration?')
expect(props.description).toBe(
'Your account will be deleted. You can register again with the same or a different email.',
)
expect(props.confirmLabel).toBe('Cancel registration')
expect(props.cancelLabel).toBe('Keep my account')
expect(props.pendingLabel).toBe('Cancelling…')
expect(props.tone).toBe('danger')
expect(typeof props.action).toBe('function')
expect(wrapper.text()).toContain('Check your email')
})
})
describe('Cancelling — the DELETE is in flight and the dialog can not be dismissed', () => {
it('sends DELETE /v1/auth/register from the dialog action', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
const { wrapper, fetchMock } = await mountPending({
'DELETE /v1/auth/register': deleteRegister,
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({ method: 'DELETE' }),
)
})
})
describe('Keep my account — the dialog is dismissed without deleting anything', () => {
it('never calls DELETE and keeps the ResultPanel unchanged', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': deleteRegister,
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
overlayMock.resolveOpen(false)
await flushPromises()
expect(deleteRegister).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(wrapper.text()).toContain('Check your email')
})
})
describe('Cancelled — 200 navigates to /register with a toast', () => {
it('navigates to /register and shows the cancelled toast', async () => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': () => mockResponse(200),
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
overlayMock.resolveOpen(true)
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Registration cancelled',
description: 'You can create the owner account again.',
})
})
})
describe('Already verified — 403 refreshes the session and navigates to /home with a toast', () => {
it('awaits session.retry, navigates to /home and shows the info toast', async () => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': () => mockResponse(403),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
})
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
const pushSpy = vi.spyOn(router, 'push')
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(session.session).toEqual({ verified: true })
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
overlayMock.resolveOpen(true)
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'info',
icon: 'i-lucide-info',
title: 'Your account is already verified',
description: "There's nothing to cancel.",
})
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
})
})
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('rejects the action instead of navigating for $name', async ({ handler }) => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': handler,
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await expect(props.action()).rejects.toThrow()
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(toastAdd).not.toHaveBeenCalled()
})
})
@@ -296,4 +296,19 @@ describe('A successful registration goes straight to the pending page', () => {
}),
)
})
it('writes the submitted email to sessionStorage before navigating', async () => {
sessionStorage.removeItem('renewcms:pendingEmail')
const { wrapper } = await mountRegister({
'POST /v1/auth/register': () => mockResponse(200),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: false }),
})
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe('marta@renew.example')
})
})
+377
View File
@@ -0,0 +1,377 @@
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 { useSessionStore } from '@/stores/session'
import VerifyView from '../VerifyView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountVerify(
path: string,
handlers: Record<string, () => Response | Promise<Response>> = {},
beforeMount: () => void = () => {},
) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
await router.push(path)
beforeMount()
const wrapper = mount(VerifyView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return { wrapper, fetchMock }
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
return wrapper.findAll('button').find((button) => button.text() === label)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.useRealTimers()
await router.push('/login')
})
describe('Verifying — the code submits automatically on load', () => {
it('shows the full-card spinner and calls verify once with the code', async () => {
const verify = vi.fn(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
})
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(verify).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ code: 'abc123' }) }),
)
})
})
describe('Success — 200 refreshes the session and navigates to /home with a toast', () => {
it('shows the success panel, then navigates after 800ms once session.retry resolves', async () => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(200),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
})
expect(wrapper.text()).toContain('Your account is verified')
expect(wrapper.text()).toContain('Taking you to your dashboard…')
expect(router.currentRoute.value.path).toBe('/register/validate')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Your account is verified',
description: 'Welcome to RenewCMS.',
})
const session = useSessionStore()
expect(session.session).toEqual({ verified: true })
})
})
describe('Invalid or expired — 403 offers a way to start over', () => {
it('shows the error panel with the note and both actions', async () => {
const pushSpy = vi.spyOn(router, 'push')
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{ 'POST /v1/auth/verify': () => mockResponse(403) },
() => {
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
},
)
expect(wrapper.text()).toContain("This verification link doesn't work")
expect(wrapper.text()).toContain(
'It may have expired (links last 2 hours) or already been used.',
)
expect(wrapper.text()).toContain(
'Cancel your registration and start over to get a new link.',
)
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
})
})
describe('Cancel registration and start over — success', () => {
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': deleteRegister,
})
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({ method: 'DELETE' }),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Registration cancelled',
description: 'You can create the owner account again.',
})
})
})
describe('Cancel registration and start over — failure shows an alert, not a redirect', () => {
it.each([
{ name: 'a 403 (already verified elsewhere)', handler: () => mockResponse(403) },
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the failure alert and keeps the panel for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': handler,
})
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't cancel registration")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect(wrapper.text()).toContain("This verification link doesn't work")
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(toastAdd).not.toHaveBeenCalled()
})
})
describe('Log out from the Invalid-or-expired state', () => {
it('clears the session and navigates to /login', async () => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'POST /v1/auth/logout': () => mockResponse(200),
})
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
await findButton(wrapper, 'Log out')?.trigger('click')
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
describe('No session — the inline log-in form replaces the spinner', () => {
it('shows the log-in form with the right copy and no forgot-password or footer', async () => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
})
expect(wrapper.find('h1').text()).toBe('Log in to finish verifying')
expect(wrapper.text()).toContain(
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
)
expect(wrapper.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(wrapper.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
expect(wrapper.text()).not.toContain('Forgot password?')
expect(wrapper.text()).not.toContain('First time here?')
expect(router.currentRoute.value.path).toBe('/register/validate')
})
})
describe('No session — submit is disabled until both fields are filled', () => {
it('enables the submit button once both fields have a value', async () => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
})
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
true,
)
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
})
})
describe('No session — successful login retries verification with the same code', () => {
it('shows the spinner again and resends verify with the same code and passthrough options', async () => {
const verify = vi.fn()
verify.mockResolvedValueOnce(mockResponse(401))
verify.mockImplementationOnce(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(200),
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ code: 'abc123' }) }),
)
expect(verify).toHaveBeenCalledTimes(2)
})
})
describe('No session — wrong credentials during the inline login', () => {
it('shows the mismatch alert, clears and refocuses the password, and never retries verify', async () => {
const verify = vi.fn(() => mockResponse(401))
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(403),
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('wrong-password')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain("That username and password don't match")
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
expect(verify).toHaveBeenCalledTimes(1)
})
})
describe('No session — too many login attempts', () => {
it('shows the info alert with a live countdown on the submit button', async () => {
vi.useFakeTimers()
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '30' }),
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Too many attempts')
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:30')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
await vi.advanceTimersByTimeAsync(30000)
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
})
})
describe('No session — server or network error during the inline login', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the network/server alert and keeps the fields for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': handler,
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
).toBe('limewash2026')
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
).toBe(false)
})
})
describe('Missing code — no request is made', () => {
it('renders the incomplete-link panel and navigates to /login', async () => {
const { wrapper, fetchMock } = await mountVerify('/register/validate')
expect(fetchMock).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('This link is incomplete')
expect(wrapper.text()).toContain(
'The verification code is missing. Open the link from your email again, or copy the full address into your browser.',
)
const action = findButton(wrapper, 'Go to log in')
expect(action).toBeTruthy()
await action?.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
+1 -1
View File
@@ -302,7 +302,7 @@ async function captureApp(
})
const page = await context.newPage()
await page.goto(`${origin}${setup.path}`)
await page.waitForLoadState('networkidle')
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)])
await setup.steps?.(page)
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(settleMs)
+104
View File
@@ -63,6 +63,23 @@ function submitRegister(username: string, email: string, password: string) {
}
}
async function setPendingEmail(page: Page, email: string) {
await page.evaluate((value) => sessionStorage.setItem('renewcms:pendingEmail', value), email)
await page.reload()
await page.waitForLoadState('networkidle')
}
async function openCancelDialog(page: Page) {
await page.getByRole('button', { name: 'Cancel registration', exact: true }).click()
}
async function confirmCancelDialog(page: Page) {
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Cancel registration', exact: true })
.click()
}
export const visualStates: Record<string, Record<string, VisualState>> = {
'log-in': {
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
@@ -127,4 +144,91 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
},
pending: {
Default: {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
},
},
'Email unknown': {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
},
'Confirm cancel': {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
},
},
Cancelling: {
path: '/register/pending',
api: {
'GET /v1/auth/me': { status: 200, body: { is_verified: false } },
'DELETE /v1/auth/register': { status: 200, hang: true },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
},
},
Cancelled: {
path: '/register/pending',
api: {
'GET /v1/auth/me': { status: 200, body: { is_verified: false } },
'DELETE /v1/auth/register': { status: 200, body: { message: 'ok' } },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
await page.waitForURL('**/register')
},
},
'Already verified (403)': {
path: '/register/pending',
api: {
'DELETE /v1/auth/register': { status: 403 },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
await page.waitForURL('**/home')
},
},
},
verify: {
Verifying: {
path: '/register/validate?c=abc123',
api: { 'POST /v1/auth/verify': { status: 200, hang: true } },
},
Success: {
path: '/register/validate?c=abc123',
api: {
'POST /v1/auth/verify': { status: 200, body: { message: 'ok' } },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
},
},
'Invalid or expired (403)': {
path: '/register/validate?c=abc123',
api: { 'POST /v1/auth/verify': { status: 403 } },
},
'No session (401)': {
path: '/register/validate?c=abc123',
api: { 'POST /v1/auth/verify': { status: 401 } },
},
'No session, retrying': {
path: '/register/validate?c=abc123',
api: { 'POST /v1/auth/verify': { status: 200, hang: true } },
},
'Missing code': {
path: '/register/validate',
},
},
}