8.4 KiB
QA: Users screen and InviteUserModal
Implementation note for coder (informational, not a QA assertion)
The invitations API is already built and covered by features/invitations.qa.md: this slice has no Go change, so it has no HTTP-level Go section.
The shell specs were amended to five destinations (Home, Articles, Users, Integration, Settings): nuxt_ui_shell_foundation.feature / .qa.md (case 14) and dashboard_shell_branding.feature. Their Vitest tests must be updated in the same change.
The Invite sent toast uses useToast().add({ id: 'invite-sent-{id}', color: 'success', icon: 'i-lucide-circle-check', title: 'Invite sent', description: '{email} has 3 days to accept.' }).
The list items are keyed by kind and id.
Automated (Vitest + Vue Test Utils, task test:web)
Mock useApi's request for GET /v1/user/ and POST /v1/user/invite per case; no real backend.
Mount UsersPage inside DashboardShell with a resolved verified session (id 1, username "marta") and
a test router at /users. The Default fixture is the Background's 5 items; the invite fixture is
id 3, email "lea.martin@renew.example". No test freezes the system date. The specs only assert on
the fixture's absolute dates; the sent row's "Today" label is not asserted anywhere (see the Visual
part). Run each layout case at a desktop and a below-md width (mock matchMedia).
- Default — assert breadcrumb "Users", title, subtitle, "Invite user", rows in fixture order, "Inès Duarte" with "ines" beside it, the "you" tag only on the "marta" row, badge words and the fixture's absolute dates; assert no accepted row shows an email.
- Navigation — assert the sidebar and the drawer list Home, Articles, Users, Integration, Settings in that order, and "Users" carries aria-current="page" on /users.
- Loading — unresolved
GET; with fake timers assert nothing is drawn at 149 ms; at 150 ms assert 5 skeleton rows andaria-busy="true"; resolve and assert the Default rows. - Only you — a single "marta" item; assert the row, the hint text and "Invite user".
- Error, Examples-driven — reject with
ApiError(500), thenNetworkError; assert the alert copy, the "Try again" action, no close button and no rows; click "Try again"; assertrequestis called again, then resolve the Default fixture and assert its rows. - Invite: default — click "Invite user"; assert title, text, an empty focused Email with its placeholder, "Cancel", "Send invite"; below md assert the pinned button is hidden while open.
- Invite: validation, Examples-driven — "sam.oliveira@renew" then blur; "not-an-email" then
submit; empty then submit; assert the field error, no
requestcall, and focus on the field. Assert that fixing the value clears the error. - Invite: sending — valid address, submit; assert one
POST /v1/user/invitewith body{"email":"lea.martin@renew.example"}, the field and "Cancel" disabled, "Sending…", and Escape and backdrop click not closing the modal. - Invite sent — resolve 201 with the invite fixture; assert the modal closes,
GET /v1/user/was not called a second time, the list has 6 rows with the new Pending row first and the others unchanged, the first row has the--ui-bg-elevatedbackground class, and the toast was raised with id "invite-sent-3", color success, icon "i-lucide-circle-check", title "Invite sent" and description "lea.martin@renew.example has 3 days to accept." Assert nothing about the "Today" label. - Replacement of an expired invite — invite "p.huber@gmail.com", resolve 201; assert exactly one row for that address, first and Pending.
- Invite sent into Only you — assert the new row above "marta" and the hint gone.
- 409, Examples-driven — reject with
ApiError(409)carrying each of the two texts; assert the matching field error, the address kept, no alert. Also assert an unknown 409 body falls back to "This address already has an account.". - 400 got through — reject with
ApiError(400); assert the alert and the field error. - Rate limited (429) — reject with
ApiError(429)carryingapperr.TooManyRequestsErrorMessageas body; assert the alert "Too many invites. Wait a moment, then try again." above the buttons (the raw body is not shown), the address kept, the form re-enabled, no field error and the list unchanged; submit again, resolve 201, and assert the closed modal, the row and the toast. - Failed, Examples-driven —
ApiError(500),ApiError(500)with the API's own text as body,NetworkError; assert the alert "Couldn't send the invite. Try again in a moment." (shown once, not the raw body), the address kept, no field error, the list unchanged. - Retry after failure — from 15, submit again; assert
requestis called a second time with the same body; resolve 201 and assert the closed modal, the row and the toast. - Cancel discards — type, click "Cancel"; assert immediate close, no
request, an empty field on reopening, and focus back on the opening button.
Visual (task visual:check -- users, desktop and mobile, light and dark)
Reference: docs/design/screens/users.md, board group "Users" in docs/design/source/RenewCMS Screens Back-office.dc.html, exact values in docs/design/source/AppFrame.dc.html. Add each
state's mock to web/visual/states.ts with the board's own sample data. The gate is hard: every
capture of every non-skipped state must be within 10 %, for both viewports and both themes.
- Default: the 5 board rows (
sam.oliveira@renew.example,p.huber@gmail.com,ineswith "Inès Duarte",jonas,martawith "you"), dates 27 Sep 2026, 3 Aug 2026, 18 Jun 2026, 2 Apr 2026, 12 Mar 2026; session usermarta. - Loading: 5 skeleton rows; the setup must give skeletons time to show (150 ms) before the capture.
- Only you: the single
martarow plus the hint. - Error: the alert and "Try again", no table or cards; the header button is still drawn.
- Invite: default: modal over the Default list, empty focused Email; the mobile pinned button hidden.
- Invite: validation: value
sam.oliveira@renew, error copy. - Invite: sending: value
lea.martin@renew.example, disabled field, "Sending…". - Invite: already a user (409): value
jonas@renew.example, error "This address already has an account.". - Invite: failed: value
lea.martin@renew.example, alert "Couldn't send the invite. Try again in a moment.". - skip: clock-sensitive. Invite sent:
skip: 'clock-sensitive'inweb/visual/states.ts("Today"). It prints SKIP, and is never investigated or date-frozen. Its Vitest coverage is case 9. - Not drawn, no visual state: the pending-invite 409 variant and the 429 alert. They are
covered by Vitest only (cases 12 and 14); do not add them to
web/visual/states.ts. - Layout, taken from the board frame (
AppFrame.dc.html), notcomponents.md: desktop header row is title block left and the 32 px "Invite user" button right, aligned to the bottom of the row; on mobile the button is 16 px from left, right and bottom, 48 px high, 18 px icon. - Not covered by screenshots: keyboard and focus order (Tab reaches "Invite user", the modal focus starts on Email, then Cancel, then "Send invite", focus is trapped, Esc closes it in every dismissible state and is blocked while Sending, focus returns to the opening button); hover and active on the buttons; motion (240 ms modal, skeleton pulse 1.6 s, both stopped under reduced motion).
Manual verification
- Real round trips against
task dev— Vitest only mocksuseApi. Invite a real address and confirm: the 201 row, the 409 texts for an existing account and a live pending invite, the replacement of an expired invite, the 429 after exceedingAUTH_RATE_LIMIT_MAX_ATTEMPTSwithinAUTH_RATE_LIMIT_WINDOW_SECONDS, and the list order after a reload. - Real invitation email — needs real or sandboxed SMTP (
SMTP_HOSTand the related env vars): the mail arrives and its link{PUBLIC_URL}/invite/accept?c=...carries the code. The link will not resolve in the SPA, because the accept screen is out of scope. - Toast placement and stacking on a live app — bottom right on desktop and above the pinned button on mobile.
- "Today" and the elevated row on a live app — confirm the new row and the "Today" label against the real clock, since visual:check skips this state.