diff --git a/.env.example b/.env.example index ca453ac..e4ad931 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,10 @@ PUBLIC_URL=http://localhost:5173 JWT_SECRET=change-me-to-a-long-random-string -SMTP_EMAIL=a@b.c -SMTP_PASSWORD=12341234 SMTP_HOST=mail.service.abc SMTP_PORT=465 +SMTP_LOGIN=a@b.c +SMTP_PASSWORD=12341234 +SMTP_SENDER=a@b.c ENVIRONMENT=development PORT=8080 diff --git a/cmd/server/main.go b/cmd/server/main.go index 3bed089..431d6b4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -32,7 +32,8 @@ var possibleEnvFileLocations = []string{".env", "../.env"} var requiredEnvVars = []string{ "PUBLIC_URL", "JWT_SECRET", - "SMTP_EMAIL", + "SMTP_LOGIN", + "SMTP_SENDER", "SMTP_PASSWORD", "SMTP_HOST", "SMTP_PORT", diff --git a/docs/configuration.md b/docs/configuration.md index 44bdfcb..ba11c75 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,7 +12,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required |-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------| | `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` | | `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` | -| `SMTP_EMAIL` | Sender address and SMTP username. | `noreply@example.com` | +| `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` | +| `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` | | `SMTP_PASSWORD` | SMTP password. | | | `SMTP_HOST` | SMTP server host. | `smtp.example.com` | | `SMTP_PORT` | SMTP server port. | `465` | diff --git a/docs/deployment.md b/docs/deployment.md index 20f6e2d..0e3d863 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -47,7 +47,8 @@ PORT=8080 DB_FILE=/var/lib/renewcms/renewcms.db UPLOAD_DIR=/var/lib/renewcms/uploads JWT_SECRET= -SMTP_EMAIL=noreply@example.com +SMTP_LOGIN=noreply@example.com +SMTP_SENDER=noreply@example.com SMTP_PASSWORD=... SMTP_HOST=smtp.example.com SMTP_PORT=465 diff --git a/features/pending_screen.feature b/features/pending_screen.feature new file mode 100644 index 0000000..ef75aac --- /dev/null +++ b/features/pending_screen.feature @@ -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 + 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 | diff --git a/features/pending_screen.qa.md b/features/pending_screen.qa.md new file mode 100644 index 0000000..e560a9e --- /dev/null +++ b/features/pending_screen.qa.md @@ -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. diff --git a/features/runtime_settings_injection.feature b/features/runtime_settings_injection.feature index ea305f3..a1dec5b 100644 --- a/features/runtime_settings_injection.feature +++ b/features/runtime_settings_injection.feature @@ -15,13 +15,13 @@ Feature: Runtime settings are read once at startup CORS_ALLOWED_ORIGINS on every router build, api/handlers/auth.shouldCookieBeSecure reads ENVIRONMENT on every Login/Register/Logout call, internal/infrastructure/mailer.MailRepository.Send - reads SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT on every call, - and internal/infrastructure/persistence.ImageRepository.Create and - .Delete read UPLOAD_DIR on every call + reads SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT + on every call, and internal/infrastructure/persistence.ImageRepository.Create + and .Delete read UPLOAD_DIR on every call And this slice introduces internal/infrastructure/config.Settings, a plain struct with fields Environment, CorsAllowedOrigins ([]string), PublicURL, UploadDir, JWTSecret, DBFile, Port and an Smtp sub-struct - (Email, Password, Host, Port) + (Login, Sender, Password, Host, Port) And cmd/server/main.go's initEnvVariables still loads the .env file and applies the same defaults and required-var checks as before, then builds exactly one config.Settings value from the process environment, @@ -34,9 +34,9 @@ Feature: Runtime settings are read once at startup the upload dir, and the router's logging and CORS setup receive the environment string and the CORS origins list as parameters And after this slice, os.Getenv (and os.LookupEnv) for ENVIRONMENT, - CORS_ALLOWED_ORIGINS, SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST, SMTP_PORT - and UPLOAD_DIR appears only inside cmd/server/main.go and - internal/infrastructure/config + CORS_ALLOWED_ORIGINS, SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD, + SMTP_HOST, SMTP_PORT and UPLOAD_DIR appears only inside + cmd/server/main.go and internal/infrastructure/config And a component built from a config.Settings value keeps behaving according to the values it was built with even if the process environment changes afterwards, because it holds the captured values, @@ -109,8 +109,9 @@ Feature: Runtime settings are read once at startup Secure attribute set to true Scenario: The mailer is constructed from injected SMTP settings, not the environment - Given a config.Settings value with Smtp.Email "notify@example.com", - Smtp.Host "smtp.example.com" and Smtp.Port 587 + Given a config.Settings value with Smtp.Login "smtp-login@example.com", + Smtp.Sender "notify@example.com", Smtp.Host "smtp.example.com" and + Smtp.Port 587 And internal/infrastructure/mailer.NewMailRepository is called with that Smtp settings value and no SMTP_* environment variable set Then constructing the MailRepository does not read any SMTP_* diff --git a/features/runtime_settings_injection.qa.md b/features/runtime_settings_injection.qa.md index d52447f..3c45362 100644 --- a/features/runtime_settings_injection.qa.md +++ b/features/runtime_settings_injection.qa.md @@ -83,9 +83,10 @@ Package: `test` (black-box). constructor signature changes. ## Mailer -16. Construct `mailer.NewMailRepository(config.SmtpSettings{Email: - "notify@example.com", Host: "smtp.example.com", Port: 587, Password: - "unused-in-test"})` with no `SMTP_*` environment variable set (do not +16. Construct `mailer.NewMailRepository(config.SmtpSettings{Login: + "smtp-login@example.com", Sender: "notify@example.com", Host: + "smtp.example.com", Port: 587, Password: "unused-in-test"})` with no + `SMTP_*` environment variable set (do not call `t.Setenv` for any `SMTP_*` name in this test). Assert construction does not panic and does not error — this is the only automated assertion available, since exercising `Send` requires a real SMTP dialog @@ -95,7 +96,8 @@ Package: `test` (black-box). ## Manual verification (not exercised by the Go test) - Real SMTP send: run `task dev` (or a built binary) with a real `.env` - populated with SMTP_EMAIL/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT, trigger a + populated with SMTP_LOGIN/SMTP_SENDER/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT, + trigger a flow that calls `SendMailUseCase.SendMail`, and confirm the message actually leaves via the injected host/port/credentials, and that changing those env vars requires a process restart to take effect (there is no diff --git a/features/verify_screen.feature b/features/verify_screen.feature new file mode 100644 index 0000000..c24b0bf --- /dev/null +++ b/features/verify_screen.feature @@ -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 + 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 + 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 diff --git a/features/verify_screen.qa.md b/features/verify_screen.qa.md new file mode 100644 index 0000000..7aa1d12 --- /dev/null +++ b/features/verify_screen.qa.md @@ -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`. diff --git a/internal/infrastructure/config/settings.go b/internal/infrastructure/config/settings.go index 20a4e97..ba401a3 100644 --- a/internal/infrastructure/config/settings.go +++ b/internal/infrastructure/config/settings.go @@ -8,7 +8,8 @@ import ( ) type SmtpSettings struct { - Email string + Login string + Sender string Password string Host string Port int @@ -61,7 +62,8 @@ func NewSettingsFromEnv() (Settings, error) { DBFile: os.Getenv("DB_FILE"), Port: os.Getenv("PORT"), Smtp: SmtpSettings{ - Email: os.Getenv("SMTP_EMAIL"), + Login: os.Getenv("SMTP_LOGIN"), + Sender: os.Getenv("SMTP_SENDER"), Password: os.Getenv("SMTP_PASSWORD"), Host: os.Getenv("SMTP_HOST"), Port: smtpPort, diff --git a/internal/infrastructure/mailer/mailRepository.go b/internal/infrastructure/mailer/mailRepository.go index 6538ee4..891b0a0 100644 --- a/internal/infrastructure/mailer/mailRepository.go +++ b/internal/infrastructure/mailer/mailRepository.go @@ -23,12 +23,13 @@ func NewMailRepository(smtp config.SmtpSettings) *MailRepository { } func (m MailRepository) Send(receiverAddress string, templateName string, data any) error { - from := m.smtp.Email + login := m.smtp.Login + sender := m.smtp.Sender password := m.smtp.Password smtpHost := m.smtp.Host smtpPort := m.smtp.Port - d := gomail.NewDialer(smtpHost, smtpPort, from, password) + d := gomail.NewDialer(smtpHost, smtpPort, login, password) tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html") if err != nil { @@ -45,12 +46,9 @@ func (m MailRepository) Send(receiverAddress string, templateName string, data a msg := gomail.NewMessage() msg.SetHeaders(map[string][]string{ - "From": {"RenewCMS <" + from + ">"}, - "To": {receiverAddress}, - "MIME-version": {"1.0"}, - "Content-Type": {"text/html"}, - "charset": {"UTF-8"}, - "Subject": {"RenewCMS | Action required"}, + "From": {"RenewCMS <" + sender + ">"}, + "To": {receiverAddress}, + "Subject": {"RenewCMS | Action required"}, }) msg.SetBody("text/html", body.String()) diff --git a/test/runtime_settings_from_env_test.go b/test/runtime_settings_from_env_test.go index 9fd7204..1b4976f 100644 --- a/test/runtime_settings_from_env_test.go +++ b/test/runtime_settings_from_env_test.go @@ -14,7 +14,8 @@ func setBaseSettingsEnv(t *testing.T) { t.Setenv("JWT_SECRET", "test-secret") t.Setenv("DB_FILE", "./data/renewcms.db") t.Setenv("PORT", "8080") - t.Setenv("SMTP_EMAIL", "notify@example.com") + t.Setenv("SMTP_LOGIN", "smtp-login@example.com") + t.Setenv("SMTP_SENDER", "notify@example.com") t.Setenv("SMTP_PASSWORD", "hunter2") t.Setenv("SMTP_HOST", "smtp.example.com") t.Setenv("SMTP_PORT", "465") @@ -50,8 +51,11 @@ func TestNewSettingsFromEnvReadsAllFields(t *testing.T) { if settings.Port != "8080" { t.Errorf("expected Port %q, got %q", "8080", settings.Port) } - if settings.Smtp.Email != "notify@example.com" { - t.Errorf("expected Smtp.Email %q, got %q", "notify@example.com", settings.Smtp.Email) + if settings.Smtp.Login != "smtp-login@example.com" { + t.Errorf("expected Smtp.Login %q, got %q", "smtp-login@example.com", settings.Smtp.Login) + } + if settings.Smtp.Sender != "notify@example.com" { + t.Errorf("expected Smtp.Sender %q, got %q", "notify@example.com", settings.Smtp.Sender) } if settings.Smtp.Password != "hunter2" { t.Errorf("expected Smtp.Password %q, got %q", "hunter2", settings.Smtp.Password) diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index 8908651..d6bca17 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -230,7 +230,8 @@ func TestRuntimeSettingsInjectionUploadDirectory(t *testing.T) { func TestRuntimeSettingsInjectionMailer(t *testing.T) { smtp := config.SmtpSettings{ - Email: "notify@example.com", + Login: "smtp-login@example.com", + Sender: "notify@example.com", Host: "smtp.example.com", Port: 587, Password: "unused-in-test", diff --git a/web/src/components/ConfirmDialog.vue b/web/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..c61ea52 --- /dev/null +++ b/web/src/components/ConfirmDialog.vue @@ -0,0 +1,118 @@ + + + diff --git a/web/src/components/ResultPanel.vue b/web/src/components/ResultPanel.vue index 2647a7f..546773c 100644 --- a/web/src/components/ResultPanel.vue +++ b/web/src/components/ResultPanel.vue @@ -6,6 +6,7 @@ interface ResultPanelAction { color?: 'primary' | 'neutral' variant?: 'solid' | 'soft' | 'outline' | 'ghost' to?: string + disabled?: boolean onClick?: () => void } @@ -46,6 +47,7 @@ onMounted(async () => {

{{ description }}

+
{ :color="action.color ?? 'primary'" :variant="action.variant ?? 'solid'" :to="action.to" + :disabled="action.disabled" @click="action.onClick" > {{ action.label }}
- diff --git a/web/src/components/__tests__/ConfirmDialog.spec.ts b/web/src/components/__tests__/ConfirmDialog.spec.ts new file mode 100644 index 0000000..8728a07 --- /dev/null +++ b/web/src/components/__tests__/ConfirmDialog.spec.ts @@ -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) { + 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((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() + }) +}) diff --git a/web/src/composables/__tests__/useApi.spec.ts b/web/src/composables/__tests__/useApi.spec.ts index 898f7e3..34309a2 100644 --- a/web/src/composables/__tests__/useApi.spec.ts +++ b/web/src/composables/__tests__/useApi.spec.ts @@ -129,6 +129,26 @@ describe('useApi global 401/403/429/offline handling', () => { }, ) + it('A 403 with onForbidden passthrough never redirects even when the session is unverified', async () => { + const session = useSessionStore() + session.session = { verified: false } + session.status = 'ready' + await router.push('/login') + const pathBefore = router.currentRoute.value.fullPath + fetchMock.mockResolvedValue(mockResponse(403)) + + const call = useApi().request('/v1/auth/register', { + method: 'DELETE', + onForbidden: 'passthrough', + }) + + await expectApiError(call, { status: 403 }) + await settle() + + expect(router.currentRoute.value.fullPath).toBe(pathBefore) + expect(session.session).toEqual({ verified: false }) + }) + it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => { fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' })) diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts index 4e54af1..4035ca6 100644 --- a/web/src/composables/useApi.ts +++ b/web/src/composables/useApi.ts @@ -24,9 +24,11 @@ export interface MeResult { } export type OnUnauthorized = 'redirect' | 'passthrough' +export type OnForbidden = 'redirect' | 'passthrough' export interface RequestOptions extends RequestInit { onUnauthorized?: OnUnauthorized + onForbidden?: OnForbidden } export const isOffline = ref(false) @@ -58,7 +60,7 @@ if (typeof window !== 'undefined') { export function useApi() { async function request(path: string, options: RequestOptions = {}): Promise { - const { onUnauthorized = 'redirect', ...init } = options + const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options let response: Response try { @@ -80,7 +82,7 @@ export function useApi() { if (response.status === 403) { const session = useSessionStore() - if (session.session?.verified === false) { + if (onForbidden !== 'passthrough' && session.session?.verified === false) { router.push('/register/pending') } throw new ApiError(403) diff --git a/web/src/lib/registrationToasts.ts b/web/src/lib/registrationToasts.ts new file mode 100644 index 0000000..f1bcb70 --- /dev/null +++ b/web/src/lib/registrationToasts.ts @@ -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.', +} diff --git a/web/src/theme/ui.config.ts b/web/src/theme/ui.config.ts index 8320e1b..8e386cc 100644 --- a/web/src/theme/ui.config.ts +++ b/web/src/theme/ui.config.ts @@ -1,3 +1,5 @@ +const overlayBackground = 'bg-(--ui-color-neutral-950)/40' + export const renewUi = { colors: { primary: 'ink', @@ -121,13 +123,27 @@ export const renewUi = { modal: { slots: { content: 'rounded-none shadow-(--shadow-modal) divide-default', - overlay: 'bg-(--ui-color-neutral-950)/40', + overlay: overlayBackground, + }, + variants: { + overlay: { + true: { + overlay: overlayBackground, + }, + }, }, }, slideover: { slots: { content: 'rounded-none shadow-(--shadow-modal)', - overlay: 'bg-(--ui-color-neutral-950)/40', + overlay: overlayBackground, + }, + variants: { + overlay: { + true: { + overlay: overlayBackground, + }, + }, }, }, toast: { diff --git a/web/src/views/PendingView.vue b/web/src/views/PendingView.vue index b987db6..6ed3d6c 100644 --- a/web/src/views/PendingView.vue +++ b/web/src/views/PendingView.vue @@ -1,9 +1,135 @@ diff --git a/web/src/views/RegisterView.vue b/web/src/views/RegisterView.vue index cc30867..76248d4 100644 --- a/web/src/views/RegisterView.vue +++ b/web/src/views/RegisterView.vue @@ -71,6 +71,7 @@ async function onSubmit() { password: password.value, }), }) + sessionStorage.setItem('renewcms:pendingEmail', email.value) await router.push('/register/pending') } catch (error) { if (error instanceof ApiError && error.status === 403) { diff --git a/web/src/views/VerifyView.vue b/web/src/views/VerifyView.vue index 30c8884..926be9f 100644 --- a/web/src/views/VerifyView.vue +++ b/web/src/views/VerifyView.vue @@ -1,9 +1,378 @@ diff --git a/web/src/views/__tests__/PendingView.spec.ts b/web/src/views/__tests__/PendingView.spec.ts new file mode 100644 index 0000000..58d7069 --- /dev/null +++ b/web/src/views/__tests__/PendingView.spec.ts @@ -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 = {}) { + 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 Response | Promise>) { + 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 | null = null + let resolveOpen: ((value: unknown) => void) | null = null + const create = vi.fn((_component: unknown, options: { props: Record }) => { + props = options.props + return { + open: () => + new Promise((resolve) => { + resolveOpen = resolve + }), + } + }) + return { + create, + getProps: () => props, + resolveOpen: (value: unknown) => resolveOpen?.(value), + } +} + +let overlayMock: ReturnType +let toastAdd: ReturnType + +vi.mock('@nuxt/ui/composables', () => ({ + useOverlay: () => overlayMock, + useToast: () => ({ add: toastAdd }), +})) + +let currentWrapper: Awaited> | null = null + +async function mountPending( + handlers: Record Response | Promise> = {}, + 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>, 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 + 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 } + 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 } + 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 } + 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 } + await expect(props.action()).rejects.toThrow() + + expect(router.currentRoute.value.path).toBe('/register/pending') + expect(toastAdd).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/views/__tests__/RegisterView.spec.ts b/web/src/views/__tests__/RegisterView.spec.ts index 15bbb2f..3f5b1bd 100644 --- a/web/src/views/__tests__/RegisterView.spec.ts +++ b/web/src/views/__tests__/RegisterView.spec.ts @@ -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') + }) }) diff --git a/web/src/views/__tests__/VerifyView.spec.ts b/web/src/views/__tests__/VerifyView.spec.ts new file mode 100644 index 0000000..1b06556 --- /dev/null +++ b/web/src/views/__tests__/VerifyView.spec.ts @@ -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 = {}) { + 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 Response | Promise>) { + 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 + +vi.mock('@nuxt/ui/composables', () => ({ + useToast: () => ({ add: toastAdd }), +})) + +let currentWrapper: Awaited> | null = null + +async function mountVerify( + path: string, + handlers: Record Response | Promise> = {}, + 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>, 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')) + }) +}) diff --git a/web/visual/check.ts b/web/visual/check.ts index 1b0efb4..74ba12c 100644 --- a/web/visual/check.ts +++ b/web/visual/check.ts @@ -302,7 +302,7 @@ async function captureApp( }) const page = await context.newPage() await page.goto(`${origin}${setup.path}`) - await page.waitForLoadState('networkidle') + await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)]) await setup.steps?.(page) await page.evaluate(() => document.fonts.ready) await page.waitForTimeout(settleMs) diff --git a/web/visual/states.ts b/web/visual/states.ts index 644fa7d..b8520f2 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -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> = { 'log-in': { Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } }, @@ -127,4 +144,91 @@ export const visualStates: Record> = { 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', + }, + }, }