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