145 lines
11 KiB
Markdown
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.
|