fix: visuals reference matching overhaul and better workflow
This commit is contained in:
1 parent
930b836241
commit
d493a181e1
25 files changed
+460
-240
No files matched your search
@@ -74,8 +74,11 @@ Feature: Log in screen
|
||||
"For your security, wait a moment before trying again."
|
||||
And the Username field still shows "marta" and the Password field still shows "limewash"
|
||||
And the submit button reads "Try again in 0:42" and is disabled
|
||||
And the Username and Password fields are disabled while the countdown runs, as the board draws
|
||||
them
|
||||
When 42 seconds elapse
|
||||
Then the submit button re-enables and reads "Log in"
|
||||
And the Username and Password fields are editable again
|
||||
|
||||
Scenario: Session expired — redirected from a 401 elsewhere
|
||||
Given the user is redirected to "/login?redirect=/article/12/edit&reason=expired"
|
||||
|
||||
@@ -28,8 +28,9 @@ route starts at the path each case needs.
|
||||
`POST /v1/auth/login` resolving 429 with a `Retry-After: 42` header; submit; assert an info alert
|
||||
with title "Too many attempts" and description "For your security, wait a moment before trying
|
||||
again."; assert Username still reads "marta" and Password still reads "limewash"; assert the
|
||||
submit button reads "Try again in 0:42" and is disabled; advance fake timers by 42 seconds;
|
||||
assert the button re-enables and reads "Log in".
|
||||
submit button reads "Try again in 0:42" and is disabled; assert both fields are disabled;
|
||||
advance fake timers by 42 seconds; assert the button re-enables and reads "Log in" and both fields
|
||||
are editable again.
|
||||
7. **Session expired state** — mount at
|
||||
"/login?redirect=/article/12/edit&reason=expired"; assert an info alert with title "Your session
|
||||
expired" and description "Log in again to pick up where you left off. Unsaved changes are kept on
|
||||
@@ -71,12 +72,12 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in".
|
||||
and mobile, light and dark.
|
||||
- [ ] Wrong credentials state: error alert (circle-x icon, `role="alert"`) above the heading,
|
||||
cleared/focused Password field, desktop and mobile, light and dark.
|
||||
- [ ] Too many attempts state: info alert (timer icon), disabled button with the live "Try again in
|
||||
m:ss" countdown text, desktop and mobile, light and dark.
|
||||
- [ ] Session expired state: info alert (clock icon), focused Password field, desktop and mobile,
|
||||
light and dark.
|
||||
- [ ] Password changed state: success alert (circle-check icon, dismissible close button per
|
||||
components.md), desktop and mobile, light and dark.
|
||||
- [ ] Too many attempts state: info alert (timer icon), disabled fields and a disabled button with
|
||||
the live "Try again in m:ss" countdown text, desktop and mobile, light and dark.
|
||||
- [ ] Session expired state: info alert (clock icon, no close button), focused Password field,
|
||||
desktop and mobile, light and dark.
|
||||
- [ ] Password changed state: success alert (circle-check icon, no close button, as the board draws
|
||||
it), desktop and mobile, light and dark.
|
||||
- [ ] Server or network error state: error alert (circle-x icon, no close button), fields retain
|
||||
their values, desktop and mobile, light and dark.
|
||||
- [ ] Keyboard/focus: Tab order is Username → Password → show/hide toggle → "Forgot password?" →
|
||||
|
||||
@@ -53,12 +53,12 @@ Feature: Pending registration screen
|
||||
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
|
||||
navigation to /home straight back to /register/pending — then the dialog closes,
|
||||
PendingPage replaces its panel with a ResultPanel (tone "success", icon
|
||||
"i-lucide-shield-check", title "You're verified", text "Taking you to your dashboard…", no
|
||||
actions) and shows a toast (color "info", icon "i-lucide-info", title "Your account is already
|
||||
verified", description "There's nothing to cancel.") over it, exactly as the design board
|
||||
draws this state, then navigates to /home after the same 800 ms Verify's Success uses
|
||||
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
|
||||
@@ -126,17 +126,18 @@ Feature: Pending registration screen
|
||||
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
|
||||
Scenario: Already verified — 403 shows the verified panel and toast, then navigates to /home
|
||||
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
|
||||
And a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "You're
|
||||
verified", text "Taking you to your dashboard…", and no actions
|
||||
And a toast is shown over it with color "info", icon "i-lucide-info", title "Your account is
|
||||
already verified", description "There's nothing to cancel."
|
||||
And after 800 ms the app navigates to /home
|
||||
|
||||
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
|
||||
|
||||
@@ -34,10 +34,10 @@ a test Pinia session store. Seed/clear `sessionStorage["renewcms:pendingEmail"]`
|
||||
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).
|
||||
before the navigation; assert the "You're verified" ResultPanel ("Taking you to your
|
||||
dashboard…", no actions) is shown and a toast was raised with color "info", icon "i-lucide-info",
|
||||
title "Your account is already verified", description "There's nothing to cancel." while the
|
||||
path is not yet /home; then assert the router reaches /home after 800 ms with no second toast.
|
||||
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
|
||||
@@ -63,8 +63,8 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Pending".
|
||||
- [ ] 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).
|
||||
- [ ] Already verified state: the "You're verified" ResultPanel with the info toast over it,
|
||||
captured before the 800 ms redirect (the visual setup freezes timers with `paused: true`).
|
||||
- [ ] 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.
|
||||
|
||||
@@ -23,14 +23,16 @@ Feature: Verify account screen
|
||||
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…",
|
||||
"i-lucide-loader-2" icon with a spin animation, the title "Verifying your account…" and the
|
||||
line "This only takes a moment.", as the design board's AuthCard spinner draws it,
|
||||
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
|
||||
a toast (color "success", icon "i-lucide-circle-check", title "Your account is verified",
|
||||
description "Welcome to RenewCMS.") is shown immediately over that panel, as the design board
|
||||
draws it, 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 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.",
|
||||
@@ -61,8 +63,10 @@ Feature: Verify account screen
|
||||
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 the inline form's submit button is enabled, as the design board draws it; on submit,
|
||||
LoginView's own validation runs first (Username under 3 characters shows "Use at least 3
|
||||
characters.", an empty Password shows "Enter your password.", and nothing is sent), and a
|
||||
valid form 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
|
||||
@@ -85,10 +89,10 @@ Feature: Verify account screen
|
||||
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 a toast is shown over it with color "success", icon "i-lucide-circle-check", title "Your
|
||||
account is verified", description "Welcome to RenewCMS."
|
||||
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
|
||||
@@ -142,11 +146,12 @@ Feature: Verify account screen
|
||||
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
|
||||
Scenario: No session — empty fields are caught on submit, as on Log in
|
||||
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
|
||||
When the user submits with both fields empty
|
||||
Then Username shows "Use at least 3 characters." and Password shows "Enter your password."
|
||||
And POST /v1/auth/login is not called
|
||||
|
||||
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"
|
||||
@@ -171,7 +176,8 @@ Feature: Verify account screen
|
||||
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
|
||||
And the submit button reads "Try again in 0:30" and it and both fields are disabled until the
|
||||
countdown ends, mirroring LoginView
|
||||
|
||||
Scenario Outline: No session — server or network error during the inline login
|
||||
Given the inline log-in form from "No session" is shown
|
||||
|
||||
@@ -7,14 +7,15 @@ Mock `useApi` (`POST /v1/auth/verify`, `POST /v1/auth/login`, `DELETE /v1/auth/r
|
||||
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
|
||||
1. **Verifying state** — mount at "/register/validate?c=abc123"; assert the full-card spinner title
|
||||
"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."
|
||||
"Your account is verified", "Taking you to your dashboard…", no actions); assert a toast was
|
||||
already raised with title "Your account is verified", description "Welcome to RenewCMS." while
|
||||
the path is still /register/validate; advance by 800 ms; assert `retry()` was called and the
|
||||
router's current path is /home, with no second toast.
|
||||
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).
|
||||
@@ -38,8 +39,9 @@ needs; use fake timers for the 800 ms success delay and the 429 countdown.
|
||||
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.
|
||||
8. **No session — empty fields are caught on submit** — from state 7, assert the submit button is
|
||||
enabled; submit with both fields empty; assert "Use at least 3 characters." and "Enter your
|
||||
password." are shown and `POST /v1/auth/login` was not called.
|
||||
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
|
||||
@@ -67,7 +69,8 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Verify". R
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] Success state: ResultPanel with no actions and the success toast over it, captured before
|
||||
the 800 ms redirect (the visual setup freezes timers with `paused: true`).
|
||||
- [ ] 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).
|
||||
|
||||
@@ -155,6 +155,11 @@
|
||||
--ui-info: var(--ui-color-info-700);
|
||||
--ui-warning: var(--ui-color-warning-700);
|
||||
--ui-error: var(--ui-color-error-700);
|
||||
--rc-success-soft: #eaefeb;
|
||||
--rc-info-soft: #ebeef0;
|
||||
--rc-warning-soft: #f0ede6;
|
||||
--rc-error-soft: #f3eae9;
|
||||
--rc-overlay: rgb(10 10 10 / 0.4);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -179,6 +184,11 @@
|
||||
--ui-info: var(--ui-color-info-300);
|
||||
--ui-warning: var(--ui-color-warning-300);
|
||||
--ui-error: var(--ui-color-error-300);
|
||||
--rc-success-soft: #202420;
|
||||
--rc-info-soft: #202325;
|
||||
--rc-warning-soft: #26221c;
|
||||
--rc-error-soft: #28201e;
|
||||
--rc-overlay: rgb(0 0 0 / 0.6);
|
||||
--shadow-popover: 0 4px 16px rgb(0 0 0 / 0.5);
|
||||
--shadow-modal: 0 16px 48px rgb(0 0 0 / 0.6);
|
||||
}
|
||||
|
||||
@@ -33,29 +33,38 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
|
||||
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-sm'))
|
||||
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
|
||||
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
|
||||
|
||||
const mainClass = computed(() => [
|
||||
'flex min-h-screen flex-col items-center',
|
||||
belowSm.value ? 'bg-default' : 'bg-muted pt-[12vh]',
|
||||
'flex min-h-screen flex-col items-center gap-5',
|
||||
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
|
||||
])
|
||||
|
||||
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
|
||||
|
||||
const cardUi = computed(() => ({
|
||||
root: belowSm.value ? 'ring-0' : '',
|
||||
container: belowSm.value ? 'pt-2 px-5 pb-0' : 'p-10',
|
||||
header: 'mb-6',
|
||||
title: 'text-xl font-semibold leading-7',
|
||||
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
|
||||
container: belowSm.value
|
||||
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
|
||||
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
|
||||
header: props.title ? 'mb-6' : 'mb-0',
|
||||
title: 'text-xl/7 font-semibold',
|
||||
description: 'mt-1.5 text-base text-muted',
|
||||
}))
|
||||
|
||||
const footerClass = computed(() => [
|
||||
'mt-4 flex w-full items-center justify-between',
|
||||
belowSm.value ? 'pt-0 px-5 pb-6' : 'p-0',
|
||||
'flex w-full flex-wrap items-center justify-between gap-3 text-sm text-muted',
|
||||
belowSm.value ? 'px-5 pb-6' : 'p-0',
|
||||
cardClass.value,
|
||||
])
|
||||
|
||||
const colorModeSelectUi = { trailing: 'hidden' }
|
||||
const colorModeSelectUi = {
|
||||
base: 'h-7 ps-7 pe-2 text-sm text-toned',
|
||||
leading: 'ps-2',
|
||||
leadingIcon: 'size-3.5 text-toned',
|
||||
trailing: 'hidden',
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
@@ -96,16 +105,20 @@ watch(
|
||||
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
|
||||
</template>
|
||||
|
||||
<div v-if="slots.notice" class="mb-4">
|
||||
<slot name="notice" />
|
||||
</div>
|
||||
<slot v-if="slots.notice" name="notice" />
|
||||
|
||||
<slot :control-size="controlSize" :control-class="controlClass" />
|
||||
<slot
|
||||
:control-size="controlSize"
|
||||
:control-class="controlClass"
|
||||
:input-class="inputClass"
|
||||
/>
|
||||
</UPageCard>
|
||||
|
||||
<footer :class="footerClass">
|
||||
<footer v-if="!belowSm || $slots.footer" :class="footerClass">
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
<slot name="footer" />
|
||||
<UColorModeSelect size="sm" class="ms-auto" :ui="colorModeSelectUi" />
|
||||
</span>
|
||||
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
|
||||
</footer>
|
||||
</main>
|
||||
</template>
|
||||
@@ -76,7 +76,12 @@ function onCancel() {
|
||||
: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',
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
|
||||
title: 'text-lg max-sm:text-[1.0625rem]/6',
|
||||
description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
|
||||
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
|
||||
footer: 'p-6 sm:px-6 max-sm:p-5',
|
||||
}"
|
||||
>
|
||||
<template v-if="error" #body>
|
||||
@@ -96,7 +101,7 @@ function onCancel() {
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
class="sm:w-auto"
|
||||
class="h-11 justify-center text-[0.9375rem] text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
|
||||
:disabled="pending"
|
||||
@click="onCancel"
|
||||
>
|
||||
@@ -107,7 +112,7 @@ function onCancel() {
|
||||
:loading="pending"
|
||||
:disabled="pending"
|
||||
block
|
||||
class="sm:w-auto"
|
||||
class="h-11 justify-center text-[0.9375rem] sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
|
||||
@@ -11,16 +11,23 @@ const props = defineProps<{
|
||||
showErrors: boolean
|
||||
}>()
|
||||
|
||||
function itemClass(rule: PasswordRule) {
|
||||
function iconClass(rule: PasswordRule) {
|
||||
if (rule.met) {
|
||||
return 'text-success'
|
||||
}
|
||||
return props.showErrors ? 'text-error' : 'text-toned'
|
||||
return props.showErrors ? 'text-error' : 'text-muted'
|
||||
}
|
||||
|
||||
function textClass(rule: PasswordRule) {
|
||||
if (rule.met) {
|
||||
return 'text-default'
|
||||
}
|
||||
return props.showErrors ? 'text-error' : 'text-muted'
|
||||
}
|
||||
|
||||
function itemIcon(rule: PasswordRule) {
|
||||
if (rule.met) {
|
||||
return 'i-lucide-circle-check'
|
||||
return 'i-lucide-check'
|
||||
}
|
||||
return props.showErrors ? 'i-lucide-circle-alert' : 'i-lucide-circle'
|
||||
}
|
||||
@@ -28,21 +35,18 @@ function itemIcon(rule: PasswordRule) {
|
||||
const items = computed(() =>
|
||||
props.rules.map((rule) => ({
|
||||
...rule,
|
||||
classes: itemClass(rule),
|
||||
iconClass: iconClass(rule),
|
||||
textClass: textClass(rule),
|
||||
icon: itemIcon(rule),
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
:class="['flex items-center gap-1.5 text-sm', item.classes]"
|
||||
>
|
||||
<UIcon :name="item.icon" class="shrink-0" />
|
||||
{{ item.label }}
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
|
||||
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
|
||||
<span :class="item.textClass">{{ item.label }}</span>
|
||||
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -21,9 +21,9 @@ const props = defineProps<{
|
||||
const role = computed(() => (props.tone === 'error' ? 'alert' : 'status'))
|
||||
|
||||
const toneTileClass: Record<'success' | 'error' | 'info', string> = {
|
||||
success: 'bg-success/10 text-success',
|
||||
error: 'bg-error/10 text-error',
|
||||
info: 'bg-info/10 text-info',
|
||||
success: 'bg-(--rc-success-soft) text-success',
|
||||
error: 'bg-(--rc-error-soft) text-error',
|
||||
info: 'bg-(--rc-info-soft) text-info',
|
||||
}
|
||||
|
||||
const tileClass = computed(() => toneTileClass[props.tone])
|
||||
@@ -38,24 +38,31 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div :role="role" class="flex flex-col gap-4">
|
||||
<div :class="['flex h-11 w-11 items-center justify-center', tileClass]">
|
||||
<UIcon :name="icon" class="h-6 w-6" />
|
||||
<div :class="['flex size-11 items-center justify-center', tileClass]">
|
||||
<UIcon :name="icon" class="size-5.5" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h1 ref="titleEl" tabindex="-1" class="text-xl font-semibold leading-7 outline-none">
|
||||
<h1
|
||||
ref="titleEl"
|
||||
tabindex="-1"
|
||||
class="text-xl/7 font-semibold text-highlighted outline-none"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="text-toned text-sm">{{ description }}</p>
|
||||
</div>
|
||||
<p class="text-base text-pretty text-toned">{{ description }}</p>
|
||||
<slot />
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
</div>
|
||||
<div v-if="actions.length" class="flex w-full flex-col gap-2">
|
||||
<UButton
|
||||
v-for="action in actions"
|
||||
:key="action.label"
|
||||
block
|
||||
size="lg"
|
||||
:color="action.color ?? 'primary'"
|
||||
:variant="action.variant ?? 'solid'"
|
||||
:class="[
|
||||
'h-11 justify-center text-base sm:h-10',
|
||||
action.variant === 'outline' ? 'text-highlighted' : '',
|
||||
]"
|
||||
:to="action.to"
|
||||
:disabled="action.disabled"
|
||||
@click="action.onClick"
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("UPageCard's title is the page h1, and its description uses UPageCard's
|
||||
})
|
||||
|
||||
describe('The page sits on the muted background from sm up, and the card keeps its border', () => {
|
||||
it('uses the muted background token, keeps the card ring, capped width and 12vh top offset', () => {
|
||||
it('uses the muted background token, keeps the card border, capped width and 12vh top offset', () => {
|
||||
stubMatchMedia(false)
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: { title: 'Log in', description: 'Welcome back.' },
|
||||
@@ -130,7 +130,7 @@ describe('The page sits on the muted background from sm up, and the card keeps i
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
expect(card.classes()).toContain('max-w-[400px]')
|
||||
expect(card.classes().some((c) => c === 'ring-0')).toBe(false)
|
||||
expect(card.classes()).toContain('border')
|
||||
|
||||
const container = wrapper.find('[data-slot="container"]')
|
||||
expect(container.classes()).not.toContain('p-5')
|
||||
|
||||
@@ -55,57 +55,6 @@ afterEach(async () => {
|
||||
resetAuthShellNavigation()
|
||||
})
|
||||
|
||||
describe('Card padding and control heights at sm and up', () => {
|
||||
it('pads the card 40px on every side and sizes controls at 40px, 14px text', async () => {
|
||||
stubMatchMedia(false)
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const container = wrapper.find('[data-slot="container"]')
|
||||
expect(container.classes()).toContain('p-10')
|
||||
expect(container.classes()).not.toContain('pt-2')
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
|
||||
for (const el of [usernameInput, passwordInput, submitButton]) {
|
||||
expect(el.classes()).toContain('!h-10')
|
||||
expect(el.classes()).toContain('!text-sm')
|
||||
}
|
||||
|
||||
const title = wrapper.find('[data-slot="title"]')
|
||||
expect(title.classes()).toContain('text-xl')
|
||||
expect(title.classes()).toContain('font-semibold')
|
||||
expect(title.classes()).toContain('leading-7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Card padding and control heights below sm', () => {
|
||||
it('splits card/footer padding and sizes controls at 44px, 16px text', async () => {
|
||||
stubMatchMedia(true)
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const container = wrapper.find('[data-slot="container"]')
|
||||
expect(container.classes()).toContain('pt-2')
|
||||
expect(container.classes()).toContain('px-5')
|
||||
expect(container.classes()).toContain('pb-0')
|
||||
|
||||
const footer = wrapper.find('footer')
|
||||
expect(footer.classes()).toContain('pt-0')
|
||||
expect(footer.classes()).toContain('px-5')
|
||||
expect(footer.classes()).toContain('pb-6')
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
|
||||
for (const el of [usernameInput, passwordInput, submitButton]) {
|
||||
expect(el.classes()).toContain('!h-11')
|
||||
expect(el.classes()).toContain('!text-base')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('"Forgot password?" is underlined and muted', () => {
|
||||
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
@@ -151,6 +100,7 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
|
||||
|
||||
describe('The theme select is right-aligned in the footer with no chevron', () => {
|
||||
it('renders as the last child, showing only the mode icon and label', async () => {
|
||||
stubMatchMedia(false)
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const footer = wrapper.find('footer')
|
||||
@@ -201,7 +151,9 @@ describe('The alert renders inside the card, under the description', () => {
|
||||
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
|
||||
expect(card.element.contains(usernameInput.element)).toBe(true)
|
||||
|
||||
expect(alert.classes().some((c) => /^bg-(info|success)\/10$/.test(c))).toBe(true)
|
||||
expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
|
||||
true,
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const VERIFIED_REDIRECT_DELAY_MS = 800
|
||||
|
||||
export function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const overlayBackground = 'bg-(--ui-color-neutral-950)/40'
|
||||
const overlayBackground = 'bg-(--rc-overlay)'
|
||||
|
||||
export const renewUi = {
|
||||
colors: {
|
||||
@@ -37,7 +37,7 @@ export const renewUi = {
|
||||
},
|
||||
button: {
|
||||
slots: {
|
||||
base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard)',
|
||||
base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard) disabled:opacity-40 aria-disabled:opacity-40',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
@@ -45,20 +45,61 @@ export const renewUi = {
|
||||
leadingIcon: 'size-4',
|
||||
trailingIcon: 'size-4',
|
||||
},
|
||||
xl: {
|
||||
leadingIcon: 'size-4',
|
||||
trailingIcon: 'size-4',
|
||||
},
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
loading: true,
|
||||
class: {
|
||||
base: 'disabled:opacity-100 aria-disabled:opacity-100',
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
},
|
||||
input: {
|
||||
formField: {
|
||||
slots: {
|
||||
base: 'rounded-none ring-(--ui-border-accented) placeholder:text-muted',
|
||||
labelWrapper: 'gap-2',
|
||||
label: 'text-highlighted',
|
||||
error: 'mt-1.5 text-xs/[18px]',
|
||||
help: 'mt-1.5 text-xs/[18px]',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
xl: {
|
||||
root: 'text-sm',
|
||||
},
|
||||
},
|
||||
orientation: {
|
||||
vertical: {
|
||||
container: 'mt-1.5',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
input: {
|
||||
slots: {
|
||||
base: 'rounded-none ring-(--ui-border-accented) placeholder:text-muted focus-visible:outline-none disabled:bg-elevated disabled:text-dimmed disabled:ring-(--ui-border) disabled:opacity-100',
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
highlight: true,
|
||||
class: 'ring-2',
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
size: {
|
||||
md: {
|
||||
base: 'text-base md:text-sm',
|
||||
base: 'ps-2.75 text-base',
|
||||
},
|
||||
xl: {
|
||||
base: 'ps-2.75 text-lg',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -164,19 +205,56 @@ export const renewUi = {
|
||||
},
|
||||
toast: {
|
||||
slots: {
|
||||
root: 'rounded-none shadow-(--shadow-popover)',
|
||||
progress: 'rounded-none',
|
||||
root: 'rounded-none shadow-(--shadow-popover) ring-0 border border-default gap-2.5 px-3.5 py-3',
|
||||
title: 'text-sm font-semibold max-sm:text-base/5',
|
||||
description: 'text-sm text-muted max-sm:text-base/5',
|
||||
icon: 'mt-0.5 size-4 max-sm:mt-0.75',
|
||||
progress: 'hidden',
|
||||
},
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal: {
|
||||
root: 'items-start',
|
||||
},
|
||||
},
|
||||
title: {
|
||||
true: {
|
||||
description: 'mt-0.5',
|
||||
},
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
color: ['primary', 'secondary', 'success', 'info', 'warning', 'neutral'],
|
||||
class: { close: 'hidden' },
|
||||
},
|
||||
],
|
||||
},
|
||||
toaster: {
|
||||
slots: {
|
||||
viewport: 'z-(--z-toast)',
|
||||
viewport: 'z-(--z-toast) sm:right-6 sm:bottom-6 sm:w-89',
|
||||
},
|
||||
},
|
||||
alert: {
|
||||
slots: {
|
||||
root: 'rounded-none',
|
||||
root: 'rounded-none gap-2.5 px-3.5 py-3',
|
||||
icon: 'mt-0.5 size-4',
|
||||
title: 'text-sm font-semibold',
|
||||
description: 'text-sm text-pretty text-toned opacity-100',
|
||||
},
|
||||
variants: {
|
||||
title: {
|
||||
true: {
|
||||
description: 'mt-0.5',
|
||||
},
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{ color: 'success', variant: 'soft', class: { root: 'bg-(--rc-success-soft)' } },
|
||||
{ color: 'info', variant: 'soft', class: { root: 'bg-(--rc-info-soft)' } },
|
||||
{ color: 'warning', variant: 'soft', class: { root: 'bg-(--rc-warning-soft)' } },
|
||||
{ color: 'error', variant: 'soft', class: { root: 'bg-(--rc-error-soft)' } },
|
||||
],
|
||||
},
|
||||
card: {
|
||||
slots: {
|
||||
|
||||
@@ -226,13 +226,7 @@ onMounted(loadArticles)
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't load your articles"
|
||||
description="Check your connection and try again."
|
||||
:ui="{
|
||||
root: 'gap-2.5 px-3.5 py-3',
|
||||
icon: 'mt-0.5 size-4',
|
||||
title: 'text-sm font-semibold',
|
||||
description: 'mt-0.5 text-sm text-toned opacity-100',
|
||||
actions: 'mt-2',
|
||||
}"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
|
||||
+26
-20
@@ -11,7 +11,6 @@ interface AlertState {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
closable: boolean
|
||||
}
|
||||
|
||||
const wrongCredentialsAlert: AlertState = {
|
||||
@@ -19,7 +18,6 @@ const wrongCredentialsAlert: AlertState = {
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "That username and password don't match",
|
||||
description: 'Check both and try again. Passwords are case-sensitive.',
|
||||
closable: false,
|
||||
}
|
||||
|
||||
const tooManyAttemptsAlert: AlertState = {
|
||||
@@ -27,7 +25,6 @@ const tooManyAttemptsAlert: AlertState = {
|
||||
icon: 'i-lucide-timer',
|
||||
title: 'Too many attempts',
|
||||
description: 'For your security, wait a moment before trying again.',
|
||||
closable: false,
|
||||
}
|
||||
|
||||
const serverErrorAlert: AlertState = {
|
||||
@@ -35,7 +32,6 @@ const serverErrorAlert: AlertState = {
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't reach RenewCMS",
|
||||
description: 'Check your connection and try again.',
|
||||
closable: false,
|
||||
}
|
||||
|
||||
const sessionExpiredAlert: AlertState = {
|
||||
@@ -44,7 +40,6 @@ const sessionExpiredAlert: AlertState = {
|
||||
title: 'Your session expired',
|
||||
description:
|
||||
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
|
||||
closable: true,
|
||||
}
|
||||
|
||||
const passwordChangedAlert: AlertState = {
|
||||
@@ -52,7 +47,6 @@ const passwordChangedAlert: AlertState = {
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Password changed',
|
||||
description: 'Log in with your new password.',
|
||||
closable: true,
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
@@ -205,14 +199,13 @@ onMounted(async () => {
|
||||
:title="alert.title"
|
||||
:description="alert.description"
|
||||
variant="soft"
|
||||
:close="alert.closable"
|
||||
:close="false"
|
||||
:role="alertRole"
|
||||
@close="alert = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #default="{ controlSize, controlClass }">
|
||||
<UForm :state="{ username, password }" class="flex flex-col gap-4" @submit="onSubmit">
|
||||
<template #default="{ controlSize, controlClass, inputClass }">
|
||||
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
|
||||
<UFormField
|
||||
label="Username"
|
||||
name="username"
|
||||
@@ -221,7 +214,10 @@ onMounted(async () => {
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -229,12 +225,12 @@ onMounted(async () => {
|
||||
v-model="username"
|
||||
placeholder="Your username"
|
||||
autocomplete="username"
|
||||
:disabled="submitting"
|
||||
:disabled="submitDisabled"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
:highlight="!!usernameError"
|
||||
:size="controlSize"
|
||||
:ui="{ base: controlClass }"
|
||||
:ui="{ base: inputClass, trailing: 'pe-1' }"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
@@ -255,7 +251,10 @@ onMounted(async () => {
|
||||
</template>
|
||||
<template v-if="passwordError" #error="{ error }">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -263,12 +262,15 @@ onMounted(async () => {
|
||||
v-model="password"
|
||||
:type="passwordShown ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
:disabled="submitting"
|
||||
:disabled="submitDisabled"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
:highlight="!!passwordError"
|
||||
:size="controlSize"
|
||||
:ui="{ base: controlClass }"
|
||||
:ui="{
|
||||
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
|
||||
trailing: 'pe-1',
|
||||
}"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
@@ -278,6 +280,7 @@ onMounted(async () => {
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
@@ -300,12 +303,15 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template v-if="registrationOpen" #footer>
|
||||
<p>
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
First time here?
|
||||
<RouterLink to="/register" class="text-primary"
|
||||
>Create the owner account</RouterLink
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
</p>
|
||||
Create the owner account
|
||||
</RouterLink>
|
||||
</span>
|
||||
</template>
|
||||
</AuthShell>
|
||||
</template>
|
||||
@@ -7,6 +7,7 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { registrationCancelledToast } from '@/lib/registrationToasts'
|
||||
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -18,6 +19,7 @@ const toast = useToast()
|
||||
const pendingEmail = ref<string | null>(null)
|
||||
const loggingOut = ref(false)
|
||||
const cancelOutcome = ref<'cancelled' | 'alreadyVerified' | null>(null)
|
||||
const verified = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
|
||||
@@ -88,13 +90,15 @@ async function cancelRegistration() {
|
||||
}
|
||||
|
||||
if (cancelOutcome.value === 'alreadyVerified') {
|
||||
await router.push('/home')
|
||||
verified.value = true
|
||||
toast.add({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'Your account is already verified',
|
||||
description: "There's nothing to cancel.",
|
||||
})
|
||||
await wait(VERIFIED_REDIRECT_DELAY_MS)
|
||||
await router.push('/home')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,13 +123,22 @@ const actions = computed(() => [
|
||||
<template>
|
||||
<AuthShell title="">
|
||||
<ResultPanel
|
||||
v-if="verified"
|
||||
tone="success"
|
||||
icon="i-lucide-shield-check"
|
||||
title="You're verified"
|
||||
description="Taking you to your dashboard…"
|
||||
:actions="[]"
|
||||
/>
|
||||
<ResultPanel
|
||||
v-else
|
||||
tone="success"
|
||||
icon="i-lucide-mail-check"
|
||||
title="Check your email"
|
||||
:description="description"
|
||||
:actions="actions"
|
||||
>
|
||||
<p class="text-toned text-sm">
|
||||
<p class="text-sm text-pretty text-muted">
|
||||
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.
|
||||
|
||||
@@ -120,10 +120,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else #default="{ controlSize, controlClass }">
|
||||
<template v-else #default="{ controlSize, controlClass, inputClass }">
|
||||
<UForm
|
||||
:state="{ username, email, password }"
|
||||
class="flex flex-col gap-4"
|
||||
class="flex flex-col gap-4.5"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
@@ -134,7 +134,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -142,7 +145,12 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
Letters and numbers, 3 to 20 characters. You'll use it to log in.
|
||||
</template>
|
||||
<template #hint>
|
||||
<span :class="usernameError ? 'text-error' : 'text-toned'">
|
||||
<span
|
||||
:class="[
|
||||
'font-mono text-xs',
|
||||
usernameError ? 'text-error' : 'text-muted',
|
||||
]"
|
||||
>
|
||||
{{ username.length }} / 20
|
||||
</span>
|
||||
</template>
|
||||
@@ -155,7 +163,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
color="primary"
|
||||
:highlight="!!usernameError"
|
||||
:size="controlSize"
|
||||
:ui="{ base: controlClass }"
|
||||
:ui="{ base: inputClass, trailing: 'pe-1' }"
|
||||
class="w-full"
|
||||
@blur="usernameTouched = true"
|
||||
/>
|
||||
@@ -169,7 +177,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
>
|
||||
<template v-if="emailError" #error="{ error }">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -184,7 +195,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
color="primary"
|
||||
:highlight="!!emailError"
|
||||
:size="controlSize"
|
||||
:ui="{ base: controlClass }"
|
||||
:ui="{ base: inputClass, trailing: 'pe-1' }"
|
||||
class="w-full"
|
||||
@blur="emailTouched = true"
|
||||
/>
|
||||
@@ -205,7 +216,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
variant="outline"
|
||||
color="primary"
|
||||
:size="controlSize"
|
||||
:ui="{ base: controlClass }"
|
||||
:ui="{
|
||||
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
|
||||
trailing: 'pe-1',
|
||||
}"
|
||||
class="w-full"
|
||||
@blur="passwordTouched = true"
|
||||
>
|
||||
@@ -216,6 +230,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
@@ -238,10 +253,15 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
</template>
|
||||
|
||||
<template v-if="!registrationClosed" #footer>
|
||||
<p>
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
Already set up?
|
||||
<RouterLink to="/login" class="text-primary">Log in</RouterLink>
|
||||
</p>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Log in
|
||||
</RouterLink>
|
||||
</span>
|
||||
</template>
|
||||
</AuthShell>
|
||||
</template>
|
||||
@@ -7,6 +7,7 @@ import ResultPanel from '@/components/ResultPanel.vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { registrationCancelledToast } from '@/lib/registrationToasts'
|
||||
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
type VerifyState =
|
||||
@@ -52,8 +53,6 @@ const cancelFailedAlert: AlertState = {
|
||||
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()
|
||||
@@ -78,13 +77,10 @@ const shellDescription = computed(() =>
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const loginSubmitDisabled = computed(
|
||||
() =>
|
||||
username.value.length === 0 ||
|
||||
password.value.length === 0 ||
|
||||
loginSubmitting.value ||
|
||||
retryAfter.active.value,
|
||||
)
|
||||
const usernameError = ref('')
|
||||
const passwordError = ref('')
|
||||
|
||||
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
|
||||
|
||||
const loginSubmitLabel = computed(() => {
|
||||
if (loginSubmitting.value) {
|
||||
@@ -96,10 +92,6 @@ const loginSubmitLabel = computed(() => {
|
||||
return 'Log in and verify'
|
||||
})
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function focusPassword() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
@@ -116,14 +108,14 @@ async function performVerify(code: string) {
|
||||
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.',
|
||||
})
|
||||
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
|
||||
await router.push('/home')
|
||||
return
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
@@ -159,10 +151,19 @@ function handleLoginError(error: unknown) {
|
||||
loginAlert.value = serverErrorAlert
|
||||
}
|
||||
|
||||
function validateLogin() {
|
||||
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
|
||||
return !usernameError.value && !passwordError.value
|
||||
}
|
||||
|
||||
async function onLoginSubmit() {
|
||||
if (loginSubmitDisabled.value) {
|
||||
return
|
||||
}
|
||||
if (!validateLogin()) {
|
||||
return
|
||||
}
|
||||
loginAlert.value = null
|
||||
loginSubmitting.value = true
|
||||
try {
|
||||
@@ -274,14 +275,15 @@ onMounted(() => {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #default="{ controlSize, controlClass }">
|
||||
<template #default="{ controlSize, controlClass, inputClass }">
|
||||
<div
|
||||
v-if="state === 'verifying' || state === 'noSessionRetrying'"
|
||||
role="status"
|
||||
class="flex flex-col items-center gap-4 py-10"
|
||||
class="flex flex-col items-center gap-4 py-10 text-center"
|
||||
>
|
||||
<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>
|
||||
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
|
||||
<p class="text-xl/7 font-semibold text-highlighted">Verifying your account…</p>
|
||||
<p class="text-base text-muted">This only takes a moment.</p>
|
||||
</div>
|
||||
|
||||
<ResultPanel
|
||||
@@ -301,7 +303,7 @@ onMounted(() => {
|
||||
description="It may have expired (links last 2 hours) or already been used."
|
||||
:actions="invalidActions"
|
||||
>
|
||||
<p class="text-toned text-sm">
|
||||
<p class="text-sm text-pretty text-muted">
|
||||
Cancel your registration and start over to get a new link.
|
||||
</p>
|
||||
</ResultPanel>
|
||||
@@ -318,33 +320,66 @@ onMounted(() => {
|
||||
<UForm
|
||||
v-else-if="state === 'noSession'"
|
||||
:state="{ username, password }"
|
||||
class="flex flex-col gap-4"
|
||||
class="flex flex-col gap-4.5"
|
||||
@submit="onLoginSubmit"
|
||||
>
|
||||
<UFormField label="Username" name="username" :size="controlSize">
|
||||
<UFormField
|
||||
label="Username"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="username"
|
||||
:highlight="!!usernameError"
|
||||
placeholder="Your username"
|
||||
autocomplete="username"
|
||||
:disabled="loginSubmitting"
|
||||
:disabled="loginSubmitDisabled"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
:size="controlSize"
|
||||
:ui="{ base: controlClass }"
|
||||
:ui="{ base: inputClass, trailing: 'pe-1' }"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Password" name="password" :size="controlSize">
|
||||
<UFormField
|
||||
label="Password"
|
||||
name="password"
|
||||
:error="passwordError || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="passwordError" #error="{ error }">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="password"
|
||||
:highlight="!!passwordError"
|
||||
:type="passwordShown ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
:disabled="loginSubmitting"
|
||||
:disabled="loginSubmitDisabled"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
:size="controlSize"
|
||||
:ui="{ base: controlClass }"
|
||||
:ui="{
|
||||
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
|
||||
trailing: 'pe-1',
|
||||
}"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
@@ -354,6 +389,7 @@ onMounted(() => {
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
|
||||
@@ -199,6 +199,12 @@ describe('Too many attempts — the button is disabled with a live countdown', (
|
||||
expect(button.text()).toBe('Try again in 0:42')
|
||||
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
const fields = () => [
|
||||
wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement,
|
||||
wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement,
|
||||
]
|
||||
expect(fields().map((field) => field.disabled)).toEqual([true, true])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(42000)
|
||||
await flushPromises()
|
||||
|
||||
@@ -206,6 +212,7 @@ describe('Too many attempts — the button is disabled with a live countdown', (
|
||||
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
)
|
||||
expect(fields().map((field) => field.disabled)).toEqual([false, false])
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Already verified — 403 refreshes the session and navigates to /home with a toast', () => {
|
||||
describe('Already verified — 403 shows the verified panel and toast, then navigates to /home', () => {
|
||||
it('awaits session.retry, navigates to /home and shows the info toast', async () => {
|
||||
const { wrapper } = await mountPending({
|
||||
'DELETE /v1/auth/register': () => mockResponse(403),
|
||||
@@ -263,13 +263,20 @@ describe('Already verified — 403 refreshes the session and navigates to /home
|
||||
overlayMock.resolveOpen(true)
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
expect(wrapper.text()).toContain("You're verified")
|
||||
expect(wrapper.text()).toContain('Taking you to your dashboard…')
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'Your account is already verified',
|
||||
description: "There's nothing to cancel.",
|
||||
})
|
||||
expect(router.currentRoute.value.path).not.toBe('/home')
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
||||
timeout: 2000,
|
||||
})
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||
pushSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -98,10 +98,6 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
|
||||
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',
|
||||
@@ -109,6 +105,11 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
|
||||
description: 'Welcome to RenewCMS.',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
||||
timeout: 2000,
|
||||
})
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
|
||||
const session = useSessionStore()
|
||||
expect(session.session).toEqual({ verified: true })
|
||||
})
|
||||
@@ -233,22 +234,23 @@ describe('No session — the inline log-in form replaces the spinner', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('No session — submit is disabled until both fields are filled', () => {
|
||||
it('enables the submit button once both fields have a value', async () => {
|
||||
describe('No session — empty fields are caught on submit, as on Log in', () => {
|
||||
it('keeps the submit button enabled and shows both field errors without calling login', async () => {
|
||||
const login = vi.fn(() => mockResponse(200))
|
||||
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': () => mockResponse(401),
|
||||
'POST /v1/auth/login': login,
|
||||
})
|
||||
|
||||
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
)
|
||||
const submit = wrapper.find('button[type="submit"]')
|
||||
expect((submit.element as HTMLButtonElement).disabled).toBe(false)
|
||||
|
||||
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('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
)
|
||||
expect(wrapper.text()).toContain('Use at least 3 characters.')
|
||||
expect(wrapper.text()).toContain('Enter your password.')
|
||||
expect(login).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+35
-6
@@ -40,7 +40,8 @@ const contentTypes: Record<string, string> = {
|
||||
}
|
||||
|
||||
const settleMs = 400
|
||||
const diffThreshold = 48
|
||||
const diffThreshold = 24
|
||||
const maxDiffPercent = 0.5
|
||||
|
||||
function slug(text: string) {
|
||||
return text
|
||||
@@ -302,11 +303,18 @@ async function captureApp(
|
||||
await route.fulfill(fulfilment(mock))
|
||||
})
|
||||
const page = await context.newPage()
|
||||
if (setup.now) {
|
||||
if (setup.paused) {
|
||||
const time = setup.now ? new Date(setup.now) : new Date()
|
||||
await page.clock.install({ time })
|
||||
await page.clock.pauseAt(time)
|
||||
} else if (setup.now) {
|
||||
await page.clock.setFixedTime(new Date(setup.now))
|
||||
}
|
||||
await page.goto(`${origin}${setup.path}`)
|
||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)])
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
|
||||
)
|
||||
await setup.steps?.(page)
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.waitForTimeout(settleMs)
|
||||
@@ -386,8 +394,9 @@ async function main() {
|
||||
|
||||
const design = await serveDesign()
|
||||
const app = await serveApp()
|
||||
const browser = await chromium.launch()
|
||||
const browser = await chromium.launch({ args: ['--disable-lcd-text'] })
|
||||
const lines: string[] = []
|
||||
let failures = 0
|
||||
|
||||
try {
|
||||
for (const viewport of args.viewports) {
|
||||
@@ -402,8 +411,9 @@ async function main() {
|
||||
|
||||
const setup = setups[state]
|
||||
if (!setup) {
|
||||
failures++
|
||||
lines.push(
|
||||
`${state} · ${viewport} · ${theme}: design only, no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
|
||||
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -419,14 +429,25 @@ async function main() {
|
||||
appPath,
|
||||
)
|
||||
const result = await diff(browser, designPath, appPath, diffPath)
|
||||
const warnings = [
|
||||
const percent = result.ratio * 100
|
||||
const budget = setup.exception?.maxPercent ?? maxDiffPercent
|
||||
const problems = [
|
||||
...(percent > budget
|
||||
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
|
||||
: []),
|
||||
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
|
||||
? [`size mismatch ${result.sizes}`]
|
||||
: []),
|
||||
...unmocked.map((key) => `unmocked ${key}`),
|
||||
]
|
||||
if (problems.length) {
|
||||
failures++
|
||||
}
|
||||
const exception = setup.exception
|
||||
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
|
||||
: ''
|
||||
lines.push(
|
||||
`${state} · ${viewport} · ${theme}: ${(result.ratio * 100).toFixed(1)}% pixels differ → ${relative(repoRoot, directory)}${warnings.length ? ` (${warnings.join('; ')})` : ''}`,
|
||||
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
|
||||
)
|
||||
}
|
||||
await board.context().close()
|
||||
@@ -438,14 +459,22 @@ async function main() {
|
||||
design.close()
|
||||
}
|
||||
|
||||
const verdict = failures
|
||||
? `FAIL: ${failures} of ${lines.length} captures failed (budget ${maxDiffPercent}% per capture)`
|
||||
: `PASS: all ${lines.length} captures within budget (${maxDiffPercent}% unless an approved exception is listed)`
|
||||
const report = [
|
||||
`# Visual check: ${screen.name}`,
|
||||
`Board: ${screen.board}, group "${screen.group}"`,
|
||||
'',
|
||||
verdict,
|
||||
'',
|
||||
...lines.map((line) => `- ${line}`),
|
||||
].join('\n')
|
||||
await writeFile(join(outRoot, screen.name, 'report.md'), `${report}\n`)
|
||||
console.log(report)
|
||||
if (failures) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
+20
-2
@@ -7,9 +7,17 @@ export interface ApiMock {
|
||||
hang?: boolean
|
||||
}
|
||||
|
||||
export interface DiffException {
|
||||
maxPercent: number
|
||||
reason: string
|
||||
approvedBy: string
|
||||
}
|
||||
|
||||
export interface VisualState {
|
||||
exception?: DiffException
|
||||
path: string
|
||||
now?: string
|
||||
paused?: boolean
|
||||
api?: Record<string, ApiMock>
|
||||
steps?: (page: Page) => Promise<void>
|
||||
}
|
||||
@@ -182,7 +190,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'log-in': {
|
||||
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
|
||||
'Fresh install': { path: '/login', api: { 'GET /v1/auth/registration': registrationOpen } },
|
||||
'Fresh install': {
|
||||
path: '/login',
|
||||
api: { 'GET /v1/auth/registration': registrationOpen },
|
||||
exception: {
|
||||
maxPercent: 1.1,
|
||||
reason: 'The board draws a design-time "API pending" chip in the footer; the API exists, so the app omits it and the theme select fits on the first row on mobile',
|
||||
approvedBy: 'Florian Sylvain, 2026-09-26',
|
||||
},
|
||||
},
|
||||
'Validation errors': {
|
||||
...loginWith({ status: 500 }),
|
||||
steps: submitLogin('ma', ''),
|
||||
@@ -290,6 +306,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Already verified (403)': {
|
||||
path: '/register/pending',
|
||||
paused: true,
|
||||
api: {
|
||||
'DELETE /v1/auth/register': { status: 403 },
|
||||
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
|
||||
@@ -298,7 +315,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await setPendingEmail(page, 'marta@renew.example')
|
||||
await openCancelDialog(page)
|
||||
await confirmCancelDialog(page)
|
||||
await page.waitForURL('**/home')
|
||||
await page.getByText("You're verified").waitFor()
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -309,6 +326,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
Success: {
|
||||
path: '/register/validate?c=abc123',
|
||||
paused: true,
|
||||
api: {
|
||||
'POST /v1/auth/verify': { status: 200, body: { message: 'ok' } },
|
||||
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
|
||||
|
||||
Reference in new issue
Block a user