Files
RenewCMS/features/users_screen.qa.md

11 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 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.