Files
RenewCMS/features/users_screen.qa.md

145 lines
11 KiB
Markdown

# 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 in this slice; the API shape was extended by user_roles.feature, 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",
role "owner") and a test router at /users; cases 6 to 17 use that owner session. 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`).
1. **Default** — assert breadcrumb "Users", title, subtitle, "Invite user", the table header
labels (Email, Display name, Role, Invite status, Created), rows in fixture order, per row the
email, the display name or "—", the RoleBadge word and class (owner filled, admin outlined),
the badge word and the absolute date, "Paul Huber" on the invite row, the "you" tag only on
"marta@renew.example", no username text in any row, and the below-md cards (display name line
only when set).
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration,
Settings in that order, and "Users" carries aria-current="page" on /users.
3. **Loading** — unresolved `GET`; with fake timers assert nothing is drawn at 149 ms; at 150 ms
assert 5 skeleton rows and `aria-busy="true"`, and at desktop that each row has a RoleBadge
and a status badge placeholder (below md the cards keep the two-bar shape); resolve and
assert the Default rows.
4. **Only you** — a single "marta" item (email, role "owner"); assert the row, the hint text and
"Invite user".
5. **Error, Examples-driven** — reject with `ApiError(500)`, then `NetworkError`; assert the alert
copy, the "Try again" action, no close button and no rows; click "Try again"; assert `request`
is called again, then resolve the Default fixture and assert its rows.
6. **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.
7. **Invite: validation, Examples-driven** — "sam.oliveira@renew" then blur; "not-an-email" then
submit; empty then submit; assert the field error, no `request` call, and focus on the field.
Assert that fixing the value clears the error.
8. **Invite: sending** — valid address, submit; assert one `POST /v1/user/invite` with body
`{"email":"lea.martin@renew.example"}`, the field and "Cancel" disabled, "Sending…", and
Escape and backdrop click not closing the modal.
9. **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-elevated` background 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 the new
first row shows "—" and "admin", and nothing about the "Today" label.
10. **Replacement of an expired invite** — invite "p.huber@gmail.com", resolve 201; assert exactly
one row for that address, first and Pending.
11. **Invite sent into Only you** — assert the new row above "marta" and the hint gone.
12. **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.".
13. **400 got through** — reject with `ApiError(400)`; assert the alert and the field error.
14. **Rate limited (429)** — reject with `ApiError(429)` carrying `apperr.TooManyRequestsErrorMessage`
as 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.
15. **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.
16. **Retry after failure** — from 15, submit again; assert `request` is called a second time with
the same body; resolve 201 and assert the closed modal, the row and the toast.
17. **Cancel discards** — type, click "Cancel"; assert immediate close, no `request`, an empty
field on reopening, and focus back on the opening button.
18. **Display names in the Users list** — the Default fixture with user 1 named "Marta Alves"
(session `/v1/auth/me` too); assert the "marta@renew.example" row shows "Marta Alves", "owner"
and Accepted, the "ines.duarte@renew.example" row "Inès Duarte" and "admin", the
"jonas@renew.example" row "—" and "admin", and the sidebar chip "Marta Alves".
19. **Non-owners, Examples-driven** — session role "admin", at desktop and below md; assert no
"Invite user" button (header or pinned), the list still renders, no reserved space for the
pinned button, and the hint absent for a list of only "marta" and "jonas". Also a session with
no role (no button) and an owner control (button present in both layouts).
## 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.
- [ ] Fixtures: `usersFixture` in `web/visual/states.ts` follows the board data: invite 2
`sam.oliveira@renew.example` pending, no name; invite 1 `p.huber@gmail.com` with
`display_name: 'Paul Huber'` expired; user 3 `ines.duarte@renew.example` "Inès Duarte" admin;
user 2 `jonas@renew.example` admin, empty name; user 1 `marta@renew.example` owner, empty
name. `verifiedSession` carries `role: 'owner'`. `profileSession` and `usersWithDisplayNames`
are updated so "Display names in the Users list" shows "Marta Alves", owner.
- [ ] Default: the 5 board rows by email with display names, roles and status badges, dates
27 Sep 2026, 3 Aug 2026, 18 Jun 2026, 2 Apr 2026, 12 Mar 2026; session user `marta`. The
Cancel/Renew buttons in the sixth column are specified in features/users_row_actions.feature.
- [ ] Loading: 5 skeleton rows, with role and status placeholders on desktop and the board's
two-bar cards on mobile; the setup must give skeletons time to show (150 ms) before the
capture.
- [ ] Only you: the single `marta@renew.example` row (owner) plus the hint.
- [ ] Profile "Display names in the Users list" (`task visual:check -- profile "Display names in
the Users list"`): Marta Alves / Inès Duarte / dash.
- [ ] Not drawn, no visual state: non-owner rendering (no "Invite user", no hint). Covered by
Vitest only (case 19); do not add it to `web/visual/states.ts`.
- [ ] 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'` in `web/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`), not `components.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.
- [ ] Layout, from `AppFrame.dc.html`: 36 px table header, 48 px rows, sixth 150 px track empty;
RoleBadge 20 px high, 7 px side padding, IBM Plex Mono 11 px medium; cards show the email
line, then the display name, then the badges line.
- [ ] 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 mocks `useApi`. 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 exceeding `AUTH_RATE_LIMIT_MAX_ATTEMPTS` within
`AUTH_RATE_LIMIT_WINDOW_SECONDS`, and the list order after a reload.
- **Real invitation email** — needs real or sandboxed SMTP (`SMTP_HOST` and 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.
- **Admin session** — sign in once as an admin against `task dev` and confirm there is no
"Invite user" button and no "Only you" hint.
- **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.