diff --git a/features/dashboard_shell_branding.feature b/features/dashboard_shell_branding.feature index 1a3c8db..0326f6a 100644 --- a/features/dashboard_shell_branding.feature +++ b/features/dashboard_shell_branding.feature @@ -17,7 +17,7 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain RenewCMS the shell renders today And the sidebar's #default slot is a UNavigationMenu orientation="vertical" :collapsed bound - to the sidebar's own collapsed state, with items Home, Articles and Integration + to the sidebar's own collapsed state, with items Home, Articles, Users and Integration, in that order And the sidebar's #footer slot renders a UserMenu component, not the plain Log out text the shell renders today And UserMenu is a UDropdownMenu :content="{ side: 'top', align: 'start' }" anchored to a diff --git a/features/nuxt_ui_shell_foundation.feature b/features/nuxt_ui_shell_foundation.feature index 124c4d2..726bf67 100644 --- a/features/nuxt_ui_shell_foundation.feature +++ b/features/nuxt_ui_shell_foundation.feature @@ -130,13 +130,14 @@ Feature: Nuxt UI shell foundation and router guards Then the alert above the title contains "Your session expired." And the footer area outside the card contains a "Forgot password?" link - Scenario: DashboardShell renders the three navigation destinations + Scenario: DashboardShell renders the four navigation destinations Given the current route is /article When DashboardShell mounts - Then the sidebar navigation lists exactly three destinations: "Home" linking to /home, "Articles" - linking to /article, and "Integration" linking to /integration + Then the sidebar navigation lists exactly four destinations in this order: "Home" linking to + /home, "Articles" linking to /article, "Users" linking to /users, and "Integration" linking + to /integration And the "Articles" destination has aria-current="page" - And neither "Home" nor "Integration" has aria-current set + And none of "Home", "Users" and "Integration" has aria-current set Scenario: DashboardShell shows the offline banner when the browser reports offline Given navigator.onLine is false diff --git a/features/nuxt_ui_shell_foundation.qa.md b/features/nuxt_ui_shell_foundation.qa.md index 30ce062..dcfbe52 100644 --- a/features/nuxt_ui_shell_foundation.qa.md +++ b/features/nuxt_ui_shell_foundation.qa.md @@ -41,7 +41,7 @@ Mock `useApi` / the `/v1/auth/me` call per case; no real backend, no httptest se 13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert both render outside/above the card content as specified. 14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert - 3 links (Home→/home, Articles→/article, Integration→/integration), and only the Articles link + 4 links in order (Home→/home, Articles→/article, Users→/users, Integration→/integration), and only the Articles link has aria-current="page". 15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert the offline banner is visible; set navigator.onLine true and dispatch the 'online' event; assert diff --git a/features/users_screen.feature b/features/users_screen.feature new file mode 100644 index 0000000..553e423 --- /dev/null +++ b/features/users_screen.feature @@ -0,0 +1,260 @@ +Feature: Users screen and InviteUserModal + + As a verified RenewCMS user in the shared workspace + I want /users to list everyone who has an account or an open invite, and let me invite someone + by email from a modal without leaving the page + So that a teammate can sign in with their own account, and I can always see who is in the + workspace and whether their invite is still open + + Background: + Given /users is a verified route (meta.auth "verified") rendered as UsersPage inside + DashboardShell, per docs/design/routes.md and docs/design/screens/users.md + And UsersPage calls GET /v1/user/ once per navigation, and InviteUserModal sends POST + /v1/user/invite; both endpoints already exist (features/invitations.feature) and this slice + wires the SPA to them unchanged, with no Go change + And the list items keep the API shape: kind "user" (id, username, display_name, status + "accepted", created_at) or kind "invite" (id, email, status "pending" or "expired", + created_at); the list is used in the order the API returns it (created date, newest first), + and rows are keyed by kind and id, because a user and an invite can share a numeric id + And DashboardShell's navigation gains "Users" (icon users, linking to /users) between + "Articles" and "Integration", in the sidebar and in the mobile drawer, so it lists Home, + Articles, Users, Integration in that order; "Users" has aria-current="page" on /users; the + "four navigation destinations" scenario of nuxt_ui_shell_foundation.feature and the items of + dashboard_shell_branding.feature are amended accordingly + And the PageHeader shows breadcrumb "Users" (mobile title "Users") + And the page header is the title "Users" with the subtitle "Everyone who can sign in and work + on your articles." and, on desktop (md and up), a primary "Invite user" button (icon + user-plus) at the right of the header + And below md, "Invite user" is instead a full-width 48 px button (icon user-plus) pinned 16 px + from the left, right and bottom of the screen with the popover shadow, and it is hidden + while InviteUserModal is open + And the list is rendered as UsersTable (desktop, md and up: columns Username, Invite status, + Created, grid minmax(0,1fr) 160px 160px, 36 px header, 44 px rows, an arrow-down icon on the + Created header) or UserCardList (below md: 68 px minimum row height, the name line, then the + InviteStatusBadge and "Created {date}"), both fed by the same items array; rows have no + actions and are not clickable + And a user row's name cell shows the display name first (medium weight, highlighted) with the + username beside it in muted text when display_name is not empty, otherwise the username + alone (medium weight, highlighted); an invite row shows the email address in regular weight, + toned; an accepted user is never shown by email + And the row whose kind is "user" and whose id equals the session user's id (from GET + /v1/auth/me) carries a muted mono "you" tag after the name + And InviteStatusBadge shows icon and word, never color alone: Accepted (circle-check, success + soft), Pending (mail, warning soft), Expired (clock-alert, transparent, muted, with an + accented border) + And the Created column shows the date as "27 Sep 2026", or "Today" when it is the current day; + the fixture below uses absolute dates only + And skeleton rows (5) show only after 150 ms of loading, so a fast response never flashes them + And the Default fixture holds 5 items, newest first: invite id 2 "sam.oliveira@renew.example" + pending 27 Sep 2026; invite id 1 "p.huber@gmail.com" expired 3 Aug 2026; user id 3 "ines" + with display_name "Inès Duarte" accepted 18 Jun 2026; user id 2 "jonas" accepted 2 Apr 2026; + user id 1 "marta" accepted 12 Mar 2026; the session user is id 1 "marta" + And InviteUserModal is a UModal opened from either "Invite user" button; every time it opens it + starts in its default state: title "Invite a user", text "We'll email them a link to choose + a username and password. The link expires after 3 days.", an empty, focused Email field + with placeholder "name@example.com", a "Cancel" button and a "Send invite" button + And the Email field validates on blur and again on submit, then on every input once it shows + an error; an empty value or one that is not a full email address shows "Enter a full email + address, like name@example.com." under the field, sends no request, and moves focus to the + field on a failed submit; the rule mirrors the API's email validation + And activating "Send invite" with a valid address sends POST /v1/user/invite with JSON + {"email": ""}; while the request is in flight the field and "Cancel" are + disabled, the confirm button reads "Sending…" with a loading indicator, and the modal cannot + be dismissed by Cancel, Esc or a backdrop click + And a 201 response closes the modal, inserts the response invite (kind "invite", status + "pending") as the first row of the list without a refetch, removes any other invite row for + the same address (the server replaced an expired one), draws the new row with the + --ui-bg-elevated background until the next load or navigation, and raises a toast: color + success, icon i-lucide-circle-check, id "invite-sent-{id}", title "Invite sent", description + "{email} has 3 days to accept." + And a 409 response never shows a request failure alert: it re-enables the form with the + address kept and shows the field error "This address already has an account." when the + response body is that text, or "There's already a pending invite for this address." when the + body is that text; any other 409 body shows "This address already has an account." + And a 400 response that got past this form's own check re-enables the form with the address + kept and shows the alert "Something in the form isn't valid. Check the highlighted fields." + with the field error "Enter a full email address, like name@example.com." + And a 429 response (the auth rate limiter) re-enables the form with the address kept and shows + the alert "Too many invites. Wait a moment, then try again." above the buttons, with no field + error; this state is not drawn in docs/design, so it has no visual state and is covered by + Vitest only + And a 500 response (including the API's "Couldn't send the invite. Try again in a moment.") or + a request that never reaches the server re-enables the form with the address kept and shows + the alert "Couldn't send the invite. Try again in a moment." above the buttons, with no + field error; the body text is never shown verbatim + And "Cancel" closes the modal immediately, discards the field and sends nothing, whenever the + form is not disabled by an in-flight request; focus returns to the button that opened it + And a 401 and a 403 (unverified) on either endpoint are handled entirely by useApi's global + handling (features/api_client_global_error_handling.feature) + And this slice does not build the accept-invite screen (/invite/accept), does not add row + actions (remove, promote, revoke or resend), and does not change how display names look in + the Profile group's "Display names in the Users list" state + + Scenario: Default — the list loads newest first, with display name, "you" tag and status badges + Given GET /v1/user/ resolves 200 with the 5-item Default fixture and the session user is "marta" + When the user navigates to /users + Then the breadcrumb reads "Users" (mobile title "Users") + And the header shows the title "Users", the subtitle and, on desktop, "Invite user" + And the rows appear in exactly the fixture's order + And the first row shows "sam.oliveira@renew.example" (toned), a Pending badge and "27 Sep 2026" + And the second row shows "p.huber@gmail.com", an Expired badge and "3 Aug 2026" + And the third row shows "Inès Duarte" first with "ines" beside it in muted text, an Accepted + badge and "18 Jun 2026" + And the fifth row shows "marta" with the "you" tag, an Accepted badge and "12 Mar 2026" + And no row shows an email address for an accepted user + And below md the same rows render as cards, each with the name line, then the badge and + "Created {date}" + + Scenario: Navigation — "Users" sits between Articles and Integration + Given the user is on /users + When DashboardShell mounts + Then the sidebar and the mobile drawer list Home, Articles, Users, Integration in that order + And "Users" has the users icon, links to /users and has aria-current="page" + + Scenario: Loading — skeleton rows after 150 ms while the first request, or a retry, is in flight + Given GET /v1/user/ has not yet resolved + When the user navigates to /users + Then nothing is drawn in place of the list for the first 150 ms + And after 150 ms 5 skeleton rows replace the list, and the list container is aria-busy="true" + When GET /v1/user/ then resolves 200 with the Default fixture + Then the skeleton rows are replaced by the Default state's rows + + Scenario: Only you — nobody has been invited yet + Given GET /v1/user/ resolves 200 with only user id 1 "marta", accepted 12 Mar 2026, and the + session user is "marta" + When the user navigates to /users + Then the list has one row: "marta" with the "you" tag, an Accepted badge and "12 Mar 2026" + And under the list the hint reads "Invite someone so they can sign in with their own account + instead of sharing yours." + And "Invite user" is still available + + Scenario Outline: Error — a 500 or a network failure replaces the list with an alert and a retry + Given GET /v1/user/ + When the user navigates to /users + Then an error alert with no close button shows the title "Couldn't load users" and the text + "Check your connection and try again.", with an action "Try again" (icon rotate-cw) + And no table or cards are shown + When the user activates "Try again" + Then the Loading state shows and GET /v1/user/ is sent again + When it then resolves 200 with the Default fixture + Then the Default state's rows replace the alert + + Examples: + | failure | + | resolves with a 500 response | + | never reaches the server | + + Scenario: Invite: default — opening the modal + Given the Default state has loaded + When the user activates "Invite user" + Then InviteUserModal opens with title "Invite a user" and the text "We'll email them a link to + choose a username and password. The link expires after 3 days." + And the Email field is empty, focused, with placeholder "name@example.com" + And "Cancel" and "Send invite" are present, and nothing has been sent + And the list stays visible behind the modal, and below md the pinned "Invite user" button is hidden + + Scenario Outline: Invite: validation — an invalid address is refused before any request + Given the modal is open + When the user types "" into Email and + Then the field shows the error "Enter a full email address, like name@example.com." + And POST /v1/user/invite is never sent + + Examples: + | value | trigger | + | sam.oliveira@renew | moves focus away | + | not-an-email | activates "Send invite" | + | | activates "Send invite" | + + Scenario: Invite: validation — the error clears as soon as the address is fixed + Given the modal shows the Email error for "sam.oliveira@renew" + When the user changes the value to "sam.oliveira@renew.example" + Then the error disappears + + Scenario: Invite: sending — the form is locked and cannot be dismissed + Given the modal is open with Email "lea.martin@renew.example" + When the user activates "Send invite" + Then POST /v1/user/invite is sent with JSON {"email": "lea.martin@renew.example"} + And the Email field and "Cancel" are disabled and the confirm button reads "Sending…" with a + loading indicator + And pressing Esc or clicking the backdrop does not close the modal + + Scenario: Invite sent — a 201 closes the modal, adds a Pending row on top and raises a toast + Given the Default state has loaded, and the modal is open with Email "lea.martin@renew.example" + with "Send invite" activated + When POST /v1/user/invite resolves 201 with id 3, kind "invite", email + "lea.martin@renew.example", status "pending", created_at now, expires_at 3 days later + Then the modal closes and focus returns to "Invite user" + And GET /v1/user/ is not called again + And the list has 6 rows, the first being "lea.martin@renew.example" with a Pending badge and + "Today", drawn with the --ui-bg-elevated background + And the other 5 rows are unchanged and follow it in their previous order + And a toast appears: color success, icon i-lucide-circle-check, id "invite-sent-3", title + "Invite sent", description "lea.martin@renew.example has 3 days to accept." + + Scenario: Invite sent to an address whose invite had expired replaces the old row + Given the Default state has loaded, with the Expired row "p.huber@gmail.com" + When the user invites "p.huber@gmail.com" and POST /v1/user/invite resolves 201 + Then the list holds exactly one row for "p.huber@gmail.com", first, with a Pending badge + And the Expired row is gone + + Scenario: Invite sent into the Only-you state keeps a coherent list + Given the Only-you state has loaded + When an invite for "lea.martin@renew.example" is sent and resolves 201 + Then the list shows the new Pending row above the "marta" row + And the Only-you hint is no longer shown + + Scenario Outline: Invite: already a user (409) — the field error depends on the response text + Given the modal is open with Email "", and "Send invite" has been activated + When POST /v1/user/invite resolves 409 with body "" + Then the modal re-enables, keeping "" in the field + And the field shows the error "" + And no request-failure alert is shown + + Examples: + | email | body | error | + | jonas@renew.example | This address already has an account. | This address already has an account. | + | sam.oliveira@renew.example | There's already a pending invite for this address. | There's already a pending invite for this address. | + + Scenario: A 400 that got past this form's own check + Given the modal is open with Email "lea.martin@renew.example", and "Send invite" has been activated + When POST /v1/user/invite resolves 400 + Then the modal re-enables, keeping the address + And the field shows "Enter a full email address, like name@example.com." + And an alert reads "Something in the form isn't valid. Check the highlighted fields." + + Scenario: Invite: rate limited — a 429 shows a dialog alert and keeps the address + Given the modal is open with Email "lea.martin@renew.example", and "Send invite" has been activated + When POST /v1/user/invite resolves 429 with body apperr.TooManyRequestsErrorMessage + Then the modal re-enables, keeping "lea.martin@renew.example" in the field + And an alert above the buttons reads "Too many invites. Wait a moment, then try again." + And the field shows no error + And the list is unchanged + When the user activates "Send invite" again and POST /v1/user/invite resolves 201 + Then the modal closes, the Pending row appears on top and the "Invite sent" toast is raised + + Scenario Outline: Invite: failed — a 500 or a network failure keeps the address and offers a retry + Given the modal is open with Email "lea.martin@renew.example", and "Send invite" has been activated + When POST /v1/user/invite + Then the modal re-enables, keeping "lea.martin@renew.example" in the field + And an alert above the buttons reads "Couldn't send the invite. Try again in a moment." + And the field shows no error + And the list is unchanged + + Examples: + | failure | + | resolves with a 500 response | + | resolves with a 500 response whose body is "Couldn't send the invite. Try again in a moment." | + | never reaches the server | + + Scenario: Retrying after a failure succeeds + Given the modal shows the failed alert for "lea.martin@renew.example" + When the user activates "Send invite" again + Then POST /v1/user/invite is sent again with the same JSON body + When it resolves 201 + Then the modal closes, the Pending row appears on top and the "Invite sent" toast is raised + + Scenario: Cancel discards the address with no confirmation + Given the modal is open with Email "lea.martin@renew.example" + When the user activates "Cancel" + Then the modal closes immediately, with no confirmation prompt, and no request is sent + And reopening the modal shows an empty Email field in its default state diff --git a/features/users_screen.qa.md b/features/users_screen.qa.md new file mode 100644 index 0000000..2ed3a98 --- /dev/null +++ b/features/users_screen.qa.md @@ -0,0 +1,112 @@ +# 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 four destinations (Home, Articles, Users, Integration): +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`). + +1. **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. +2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration 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"`; resolve and assert the Default rows. +4. **Only you** — a single "marta" item; 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 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. + +## 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`, `ines` with + "Inès Duarte", `jonas`, `marta` with "you"), dates 27 Sep 2026, 3 Aug 2026, 18 Jun 2026, + 2 Apr 2026, 12 Mar 2026; session user `marta`. +- [ ] Loading: 5 skeleton rows; the setup must give skeletons time to show (150 ms) before the capture. +- [ ] Only you: the single `marta` row 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'` 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. +- [ ] 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. +- **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. diff --git a/web/src/components/DashboardShell.vue b/web/src/components/DashboardShell.vue index 8008d8a..d895534 100644 --- a/web/src/components/DashboardShell.vue +++ b/web/src/components/DashboardShell.vue @@ -36,6 +36,7 @@ const navItems = computed(() => [ { label: 'Home', to: '/home', icon: 'i-lucide-home' }, { label: 'Articles', to: '/article', icon: 'i-lucide-file-text' }, + { label: 'Users', to: '/users', icon: 'i-lucide-users' }, { label: 'Integration', to: '/integration', icon: 'i-lucide-plug' }, ].map((item) => ({ ...item, diff --git a/web/src/components/InviteStatusBadge.vue b/web/src/components/InviteStatusBadge.vue new file mode 100644 index 0000000..3ad44ed --- /dev/null +++ b/web/src/components/InviteStatusBadge.vue @@ -0,0 +1,49 @@ + + + diff --git a/web/src/components/InviteUserModal.vue b/web/src/components/InviteUserModal.vue new file mode 100644 index 0000000..3530dcd --- /dev/null +++ b/web/src/components/InviteUserModal.vue @@ -0,0 +1,233 @@ + + + diff --git a/web/src/components/UserCardList.vue b/web/src/components/UserCardList.vue new file mode 100644 index 0000000..8eb57b3 --- /dev/null +++ b/web/src/components/UserCardList.vue @@ -0,0 +1,51 @@ + + + diff --git a/web/src/components/UserNameCell.vue b/web/src/components/UserNameCell.vue new file mode 100644 index 0000000..a1bfab2 --- /dev/null +++ b/web/src/components/UserNameCell.vue @@ -0,0 +1,36 @@ + + + diff --git a/web/src/components/UsersTable.vue b/web/src/components/UsersTable.vue new file mode 100644 index 0000000..2890d59 --- /dev/null +++ b/web/src/components/UsersTable.vue @@ -0,0 +1,62 @@ + + + diff --git a/web/src/components/__tests__/DashboardShell.spec.ts b/web/src/components/__tests__/DashboardShell.spec.ts index e99f3e6..6407753 100644 --- a/web/src/components/__tests__/DashboardShell.spec.ts +++ b/web/src/components/__tests__/DashboardShell.spec.ts @@ -36,29 +36,39 @@ afterEach(async () => { document.body.innerHTML = '' }) -describe('DashboardShell renders the three navigation destinations', () => { - it('lists Home, Articles and Integration, with only Articles carrying aria-current', async () => { +describe('DashboardShell renders the four navigation destinations', () => { + it('lists Home, Articles, Users and Integration in order, with only Articles carrying aria-current', async () => { const wrapper = await mountShell('/article') + const hrefs = wrapper + .findAll('nav[aria-label="Main"] a') + .map((link) => link.attributes('href')) + expect(hrefs).toEqual(['/home', '/article', '/users', '/integration']) + const home = wrapper.find('a[href="/home"]') const articles = wrapper.find('a[href="/article"]') + const users = wrapper.find('a[href="/users"]') const integration = wrapper.find('a[href="/integration"]') - expect(home.exists()).toBe(true) - expect(articles.exists()).toBe(true) - expect(integration.exists()).toBe(true) - expect(articles.attributes('aria-current')).toBe('page') expect(home.attributes('aria-current')).toBeUndefined() + expect(users.attributes('aria-current')).toBeUndefined() expect(integration.attributes('aria-current')).toBeUndefined() }) + + it('marks Users with aria-current on /users', async () => { + const wrapper = await mountShell('/users') + + const users = wrapper.find('a[href="/users"]') + expect(users.text()).toContain('Users') + expect(users.attributes('aria-current')).toBe('page') + expect(wrapper.find('a[href="/article"]').attributes('aria-current')).toBeUndefined() + }) }) describe('DashboardShell shows the offline banner when the browser reports offline', () => { it('shows the banner while offline and hides it once back online', async () => { - const onLineSpy = vi - .spyOn(navigator, 'onLine', 'get') - .mockReturnValue(false) + const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) const wrapper = await mountShell('/article') @@ -79,16 +89,12 @@ describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () => const wrapper = await mountShell('/home') const header = wrapper.find('[data-slot="header"]') - expect( - header.find('svg[role="img"][aria-label="RenewCMS"]').exists(), - ).toBe(true) + expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true) expect(header.text()).toContain('RenewCMS') expect(header.find('a').exists()).toBe(false) const headerHtml = header.html() - expect(headerHtml.indexOf(''), - ) + expect(headerHtml.indexOf('')) }) }) @@ -97,9 +103,7 @@ describe('The sidebar collapse trigger toggles the rail and exposes aria-expande await mountShell('/home') const collapseButton = () => - document.body.querySelector( - '[data-testid="sidebar-collapse"]', - ) as HTMLButtonElement + document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement expect(collapseButton().getAttribute('aria-expanded')).toBe('true') @@ -120,13 +124,11 @@ describe('A successful logout clears the session and navigates to /login', () => await mountShell('/home') const logOutItem = () => - Array.from( - document.body.querySelectorAll('[role="menuitem"]'), - ).find((el) => el.textContent?.trim() === 'Log out') as HTMLElement + Array.from(document.body.querySelectorAll('[role="menuitem"]')).find( + (el) => el.textContent?.trim() === 'Log out', + ) as HTMLElement - const trigger = document.body.querySelector( - 'button[aria-haspopup]', - ) as HTMLButtonElement + const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement trigger.click() await flushPromises() @@ -151,28 +153,16 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac collapseButton.click() await flushPromises() - const header = document.body.querySelector( - '[data-slot="header"]', - ) as HTMLElement - expect( - header.querySelector('svg[role="img"][aria-label="RenewCMS"]'), - ).toBeTruthy() + const header = document.body.querySelector('[data-slot="header"]') as HTMLElement + expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy() expect(header.textContent).not.toContain('RenewCMS') expect(header.querySelector('span[aria-hidden="true"]')).toBeNull() - const footer = document.body.querySelector( - '[data-slot="footer"]', - ) as HTMLElement + const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement expect(footer.textContent).not.toContain('marta') - const userMenuTrigger = footer.querySelector( - 'button[aria-haspopup]', - ) as HTMLButtonElement - expect(userMenuTrigger.getAttribute('aria-label')).toBe( - 'marta — account menu', - ) - expect( - footer.querySelector('[data-slot="fallback"]')?.textContent, - ).toBe('MA') + const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement + expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu') + expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA') }) }) diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts index 4df8226..2c510a3 100644 --- a/web/src/composables/useApi.ts +++ b/web/src/composables/useApi.ts @@ -5,11 +5,13 @@ import { useSessionStore } from '@/stores/session' export class ApiError extends Error { status: number retryAfter?: number + body?: string - constructor(status: number, retryAfter?: number) { + constructor(status: number, retryAfter?: number, body?: string) { super(`Request failed with status ${status}`) this.status = status this.retryAfter = retryAfter + this.body = body } } @@ -20,6 +22,7 @@ export class NetworkError extends Error { } export interface MeResult { + id?: number verified: boolean username?: string } @@ -51,6 +54,17 @@ function parseRetryAfterSeconds(response: Response): number | undefined { return Number.parseInt(header, 10) } +async function readErrorBody(response: Response): Promise { + if (typeof response.text !== 'function') { + return undefined + } + try { + return (await response.text()).trim() + } catch { + return undefined + } +} + if (typeof window !== 'undefined') { window.addEventListener('offline', () => { isOffline.value = true @@ -102,7 +116,7 @@ export function useApi() { throw new ApiError(429, parseRetryAfterSeconds(response)) } - throw new ApiError(response.status) + throw new ApiError(response.status, undefined, await readErrorBody(response)) } if (response.status === 204) { @@ -113,10 +127,11 @@ export function useApi() { } async function getMe(): Promise { - const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', { - onUnauthorized: 'passthrough', - }) - return { verified: Boolean(body.is_verified), username: body.username } + const body = await request<{ id?: number; is_verified: boolean; username?: string }>( + '/v1/auth/me', + { onUnauthorized: 'passthrough' }, + ) + return { id: body.id, verified: Boolean(body.is_verified), username: body.username } } return { getMe, request } diff --git a/web/src/composables/useUsers.ts b/web/src/composables/useUsers.ts new file mode 100644 index 0000000..dbb98f9 --- /dev/null +++ b/web/src/composables/useUsers.ts @@ -0,0 +1,51 @@ +import { useApi } from '@/composables/useApi' + +export type UserListItem = + | { + kind: 'user' + id: number + username: string + display_name: string + status: 'accepted' + created_at: string + } + | { + kind: 'invite' + id: number + email: string + status: 'pending' | 'expired' + created_at: string + } + +export const USER_SKELETON_ROWS = 5 + +export function userRowKey(kind: UserListItem['kind'], id: number) { + return `${kind}-${id}` +} + +export interface CreatedInvite { + id: number + kind: 'invite' + email: string + status: 'pending' + created_at: string + expires_at: string +} + +export function useUsers() { + const { request } = useApi() + + function listUsers(): Promise { + return request('/v1/user/') + } + + function inviteUser(email: string): Promise { + return request('/v1/user/invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + } + + return { listUsers, inviteUser } +} diff --git a/web/src/lib/relativeDate.ts b/web/src/lib/relativeDate.ts index c0e34ec..504d091 100644 --- a/web/src/lib/relativeDate.ts +++ b/web/src/lib/relativeDate.ts @@ -47,3 +47,16 @@ export function formatRelativeDate(iso: string, now: Date = new Date()) { } return formatAbsoluteDate(date) } + +function isSameLocalDay(a: Date, b: Date) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +export function formatCreatedDate(iso: string, now: Date = new Date()) { + const date = new Date(iso) + return isSameLocalDay(date, now) ? 'Today' : formatAbsoluteDate(date) +} diff --git a/web/src/router/index.ts b/web/src/router/index.ts index bda09cb..00c0b99 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -66,6 +66,11 @@ const router = createRouter({ component: () => import('@/views/EditorView.vue'), meta: { auth: 'verified', title: 'Editor' }, }, + { + path: '/users', + component: () => import('@/views/UsersView.vue'), + meta: { auth: 'verified', title: 'Users' }, + }, { path: '/integration', component: () => import('@/views/IntegrationView.vue'), diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index 3b85106..3338b52 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -5,6 +5,7 @@ import { ApiError, useApi } from '@/composables/useApi' export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error' export interface Session { + id?: number verified: boolean username?: string } diff --git a/web/src/views/UsersView.vue b/web/src/views/UsersView.vue new file mode 100644 index 0000000..8c34dfe --- /dev/null +++ b/web/src/views/UsersView.vue @@ -0,0 +1,193 @@ + + + diff --git a/web/src/views/__tests__/UsersView.spec.ts b/web/src/views/__tests__/UsersView.spec.ts new file mode 100644 index 0000000..ca21cb1 --- /dev/null +++ b/web/src/views/__tests__/UsersView.spec.ts @@ -0,0 +1,641 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import uiPlugin from '@nuxt/ui/vue-plugin' +import router from '@/router' +import { ApiError, NetworkError, useApi } from '@/composables/useApi' +import type { CreatedInvite, UserListItem } from '@/composables/useUsers' +import { useSessionStore } from '@/stores/session' +import UsersView from '../UsersView.vue' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useApi: vi.fn(), + } +}) + +let toastAdd: ReturnType + +vi.mock('@nuxt/ui/composables', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useToast: () => ({ add: toastAdd }), + } +}) + +const LIST_PATH = '/v1/user/' +const INVITE_PATH = '/v1/user/invite' +const INVALID_EMAIL = 'Enter a full email address, like name@example.com.' +const FAILED_ALERT = "Couldn't send the invite. Try again in a moment." +const VALID_EMAIL = 'lea.martin@renew.example' + +function defaultFixture(): UserListItem[] { + return [ + { + kind: 'invite', + id: 2, + email: 'sam.oliveira@renew.example', + status: 'pending', + created_at: '2026-09-27T09:00:00Z', + }, + { + kind: 'invite', + id: 1, + email: 'p.huber@gmail.com', + status: 'expired', + created_at: '2026-08-03T09:00:00Z', + }, + { + kind: 'user', + id: 3, + username: 'ines', + display_name: 'Inès Duarte', + status: 'accepted', + created_at: '2026-06-18T09:00:00Z', + }, + { + kind: 'user', + id: 2, + username: 'jonas', + display_name: '', + status: 'accepted', + created_at: '2026-04-02T09:00:00Z', + }, + { + kind: 'user', + id: 1, + username: 'marta', + display_name: '', + status: 'accepted', + created_at: '2026-03-12T09:00:00Z', + }, + ] +} + +function onlyYouFixture(): UserListItem[] { + return defaultFixture().slice(4) +} + +function inviteFixture(email = VALID_EMAIL): CreatedInvite { + return { + id: 3, + kind: 'invite', + email, + status: 'pending', + created_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +function setViewport(desktop: boolean) { + window.matchMedia = ((query: string) => ({ + matches: desktop, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia +} + +let requestMock: ReturnType +let wrapper: VueWrapper | null = null + +interface Handlers { + list?: () => Promise + invite?: () => Promise +} + +async function mountPage({ list, invite }: Handlers = {}, desktop = true) { + setViewport(desktop) + setActivePinia(createPinia()) + const session = useSessionStore() + session.session = { verified: true, username: 'marta', id: 1 } + session.status = 'ready' + requestMock = vi.fn((path: string) => { + if (path === LIST_PATH) { + return (list ?? (() => Promise.resolve(defaultFixture())))() + } + if (path === INVITE_PATH) { + return (invite ?? (() => Promise.resolve(inviteFixture())))() + } + throw new Error(`Unexpected request: ${path}`) + }) + vi.mocked(useApi).mockReturnValue({ + getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }), + request: requestMock, + }) + await router.push('/users') + wrapper = mount(UsersView, { + attachTo: document.body, + global: { plugins: [router, uiPlugin] }, + }) + await flushPromises() + return wrapper +} + +function rows() { + return Array.from( + document.body.querySelectorAll( + '[data-testid="user-row"], [data-testid="user-card"]', + ), + ) +} + +function textOf(element: Element) { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + const parts: string[] = [] + while (walker.nextNode()) { + const text = walker.currentNode.textContent?.trim() + if (text) { + parts.push(text) + } + } + return parts.join(' ') +} + +function rowTexts() { + return rows().map(textOf) +} + +function findButton(text: string) { + return Array.from(document.body.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === text, + ) +} + +async function clickButton(text: string) { + findButton(text)?.click() + await flushPromises() +} + +function emailInput() { + return document.body.querySelector('input[placeholder="name@example.com"]')! +} + +function dialog() { + return document.body.querySelector('[role="dialog"]') +} + +function dispatch(element: HTMLElement, type: string) { + element.dispatchEvent(new Event(type, { bubbles: true })) +} + +async function typeEmail(value: string) { + const input = emailInput() + input.value = value + dispatch(input, 'input') + await flushPromises() +} + +async function blurEmail() { + dispatch(emailInput(), 'blur') + await flushPromises() +} + +async function openModal() { + const opener = findButton('Invite user')! + opener.focus() + opener.click() + await flushPromises() +} + +async function submitWith(value: string) { + await typeEmail(value) + await clickButton('Send invite') +} + +function inviteCalls() { + return requestMock.mock.calls.filter(([path]) => path === INVITE_PATH) +} + +function listCalls() { + return requestMock.mock.calls.filter(([path]) => path === LIST_PATH) +} + +function alertText() { + return document.body.querySelector('[role="alert"]')?.textContent?.trim() ?? '' +} + +beforeEach(() => { + toastAdd = vi.fn() +}) + +afterEach(async () => { + wrapper?.unmount() + wrapper = null + vi.useRealTimers() + await router.push('/login') + document.body.innerHTML = '' +}) + +describe('Default — the list loads newest first, with display name, "you" tag and status badges', () => { + it('shows the header, the rows in fixture order and the badges on desktop', async () => { + await mountPage() + + const breadcrumb = document.body.querySelector( + '[aria-label="breadcrumb"], nav:not([aria-label="Main"])', + ) + expect(breadcrumb?.textContent).toContain('Users') + expect(document.body.querySelector('h1')?.textContent).toBe('Users') + expect(document.body.textContent).toContain( + 'Everyone who can sign in and work on your articles.', + ) + expect(findButton('Invite user')).toBeTruthy() + + expect(rowTexts()).toEqual([ + 'sam.oliveira@renew.example Pending 27 Sep 2026', + 'p.huber@gmail.com Expired 3 Aug 2026', + 'Inès Duarte ines Accepted 18 Jun 2026', + 'jonas Accepted 2 Apr 2026', + 'marta you Accepted 12 Mar 2026', + ]) + expect(rows().filter((row) => /\byou\b/.test(textOf(row)))).toHaveLength(1) + expect(document.body.textContent).toContain('Invite status') + }) + + it('renders the same rows as cards below md', async () => { + await mountPage({}, false) + + expect(rowTexts()).toEqual([ + 'sam.oliveira@renew.example Pending Created 27 Sep 2026', + 'p.huber@gmail.com Expired Created 3 Aug 2026', + 'Inès Duarte ines Accepted Created 18 Jun 2026', + 'jonas Accepted Created 2 Apr 2026', + 'marta you Accepted Created 12 Mar 2026', + ]) + expect(document.body.querySelector('[data-testid="user-row"]')).toBeNull() + }) + + it('gives the Users link aria-current on /users', async () => { + await mountPage() + + const link = document.body.querySelector('nav[aria-label="Main"] a[href="/users"]') + expect(link?.getAttribute('aria-current')).toBe('page') + }) +}) + +describe('Loading — skeleton rows after 150 ms', () => { + it('draws nothing until 150 ms, then five skeleton rows, then the rows', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const pending = deferred() + await mountPage({ list: () => pending.promise }) + + vi.advanceTimersByTime(149) + await flushPromises() + expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() + expect(document.body.querySelector('[data-slot="base"].animate-pulse')).toBeNull() + + vi.advanceTimersByTime(1) + await flushPromises() + const busy = document.body.querySelector('[aria-busy="true"]')! + expect(busy.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(5) + expect(rows()).toHaveLength(0) + + pending.resolve(defaultFixture()) + await flushPromises() + expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() + expect(rows()).toHaveLength(5) + }) +}) + +describe('Only you — nobody has been invited yet', () => { + it('shows the one row, the hint and Invite user', async () => { + await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }) + + expect(rowTexts()).toEqual(['marta you Accepted 12 Mar 2026']) + expect(document.body.textContent).toContain( + 'Invite someone so they can sign in with their own account instead of sharing yours.', + ) + expect(findButton('Invite user')).toBeTruthy() + }) +}) + +describe('Error — a 500 or a network failure replaces the list with an alert and a retry', () => { + it.each([ + { name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) }, + { name: 'a network failure', failure: () => Promise.reject(new NetworkError()) }, + ])('shows the alert and recovers after Try again on $name', async ({ failure }) => { + await mountPage({ list: failure }) + + expect(alertText()).toContain("Couldn't load users") + expect(alertText()).toContain('Check your connection and try again.') + expect(findButton('Try again')).toBeTruthy() + expect(document.body.querySelector('[role="alert"] button[aria-label="Close"]')).toBeNull() + expect(rows()).toHaveLength(0) + expect(findButton('Invite user')).toBeTruthy() + + requestMock.mockImplementation((path: string) => + path === LIST_PATH + ? Promise.resolve(defaultFixture()) + : Promise.reject(new Error(path)), + ) + await clickButton('Try again') + + expect(listCalls()).toHaveLength(2) + expect(rows()).toHaveLength(5) + expect(document.body.querySelector('[role="alert"]')).toBeNull() + }) +}) + +describe('Invite: default — opening the modal', () => { + it('shows the copy, an empty focused Email and both buttons, and sends nothing', async () => { + await mountPage() + await openModal() + + expect(dialog()?.textContent).toContain('Invite a user') + expect(dialog()?.textContent).toContain( + "We'll email them a link to choose a username and password. The link expires after 3 days.", + ) + expect(emailInput().value).toBe('') + expect(document.activeElement).toBe(emailInput()) + expect(findButton('Cancel')).toBeTruthy() + expect(findButton('Send invite')).toBeTruthy() + expect(inviteCalls()).toHaveLength(0) + expect(rows()).toHaveLength(5) + }) + + it('hides the pinned button below md while the modal is open', async () => { + await mountPage({}, false) + + const pinned = findButton('Invite user')! + expect(pinned.style.display).not.toBe('none') + + await openModal() + expect(pinned.style.display).toBe('none') + + await clickButton('Cancel') + expect(pinned.style.display).not.toBe('none') + }) +}) + +describe('Invite: validation — an invalid address is refused before any request', () => { + it.each([ + { value: 'sam.oliveira@renew', trigger: 'blur' }, + { value: 'not-an-email', trigger: 'submit' }, + { value: '', trigger: 'submit' }, + ])('refuses "$value" on $trigger', async ({ value, trigger }) => { + await mountPage() + await openModal() + + await typeEmail(value) + if (trigger === 'blur') { + await blurEmail() + } else { + await clickButton('Send invite') + expect(document.activeElement).toBe(emailInput()) + } + + expect(dialog()?.textContent).toContain(INVALID_EMAIL) + expect(inviteCalls()).toHaveLength(0) + }) + + it('clears the error as soon as the address is fixed', async () => { + await mountPage() + await openModal() + await typeEmail('sam.oliveira@renew') + await blurEmail() + expect(dialog()?.textContent).toContain(INVALID_EMAIL) + + await typeEmail('sam.oliveira@renew.example') + + expect(dialog()?.textContent).not.toContain(INVALID_EMAIL) + }) +}) + +describe('Invite: sending — the form is locked and cannot be dismissed', () => { + it('sends the JSON body, disables the form and ignores Esc and the backdrop', async () => { + const pending = deferred() + await mountPage({ invite: () => pending.promise }) + await openModal() + await submitWith(VALID_EMAIL) + + expect(inviteCalls()).toHaveLength(1) + const [, options] = inviteCalls()[0] + expect(options.method).toBe('POST') + expect(JSON.parse(options.body)).toEqual({ email: VALID_EMAIL }) + expect(emailInput().disabled).toBe(true) + expect(findButton('Cancel')!.disabled).toBe(true) + expect(findButton('Sending…')).toBeTruthy() + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await flushPromises() + expect(dialog()).toBeTruthy() + + document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) + await flushPromises() + expect(dialog()).toBeTruthy() + + pending.resolve(inviteFixture()) + await flushPromises() + }) +}) + +describe('Invite sent — a 201 closes the modal, adds a Pending row on top and raises a toast', () => { + it('closes, prepends the highlighted row without a refetch and toasts', async () => { + await mountPage() + await openModal() + await submitWith(VALID_EMAIL) + + expect(dialog()).toBeNull() + expect(listCalls()).toHaveLength(1) + const texts = rowTexts() + expect(texts).toHaveLength(6) + expect(texts[0]).toContain('lea.martin@renew.example Pending') + expect(texts.slice(1)).toEqual([ + 'sam.oliveira@renew.example Pending 27 Sep 2026', + 'p.huber@gmail.com Expired 3 Aug 2026', + 'Inès Duarte ines Accepted 18 Jun 2026', + 'jonas Accepted 2 Apr 2026', + 'marta you Accepted 12 Mar 2026', + ]) + expect(rows()[0].className).toContain('bg-elevated') + expect(rows()[1].className).not.toContain('bg-elevated') + expect(toastAdd).toHaveBeenCalledWith({ + id: 'invite-sent-3', + color: 'success', + icon: 'i-lucide-circle-check', + title: 'Invite sent', + description: 'lea.martin@renew.example has 3 days to accept.', + }) + }) + + it('returns focus to Invite user', async () => { + await mountPage() + await openModal() + await submitWith(VALID_EMAIL) + + expect(document.activeElement).toBe(findButton('Invite user')) + }) +}) + +describe('Invite sent to an address whose invite had expired replaces the old row', () => { + it('keeps exactly one Pending row for the address, first', async () => { + await mountPage({ invite: () => Promise.resolve(inviteFixture('p.huber@gmail.com')) }) + await openModal() + await submitWith('p.huber@gmail.com') + + const matching = rowTexts().filter((text) => text.includes('p.huber@gmail.com')) + expect(matching).toHaveLength(1) + expect(rowTexts()[0]).toContain('p.huber@gmail.com Pending') + expect(rows()).toHaveLength(5) + }) +}) + +describe('Invite sent into the Only-you state keeps a coherent list', () => { + it('puts the new row above marta and hides the hint', async () => { + await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }) + await openModal() + await submitWith(VALID_EMAIL) + + const texts = rowTexts() + expect(texts).toHaveLength(2) + expect(texts[0]).toContain('lea.martin@renew.example Pending') + expect(texts[1]).toContain('marta you Accepted') + expect(document.body.textContent).not.toContain('Invite someone so they can sign in') + }) +}) + +describe('Invite: already a user (409) — the field error depends on the response text', () => { + it.each([ + { + email: 'jonas@renew.example', + body: 'This address already has an account.', + error: 'This address already has an account.', + }, + { + email: 'sam.oliveira@renew.example', + body: "There's already a pending invite for this address.", + error: "There's already a pending invite for this address.", + }, + { + email: 'jonas@renew.example', + body: 'Something else', + error: 'This address already has an account.', + }, + ])('shows "$error" for the body "$body"', async ({ email, body, error }) => { + await mountPage({ invite: () => Promise.reject(new ApiError(409, undefined, body)) }) + await openModal() + await submitWith(email) + + expect(dialog()).toBeTruthy() + expect(emailInput().value).toBe(email) + expect(emailInput().disabled).toBe(false) + expect(dialog()?.textContent).toContain(error) + expect(document.body.querySelector('[role="alert"]')).toBeNull() + }) +}) + +describe("A 400 that got past this form's own check", () => { + it('keeps the address and shows the alert and the field error', async () => { + await mountPage({ invite: () => Promise.reject(new ApiError(400)) }) + await openModal() + await submitWith(VALID_EMAIL) + + expect(emailInput().value).toBe(VALID_EMAIL) + expect(emailInput().disabled).toBe(false) + expect(alertText()).toContain( + "Something in the form isn't valid. Check the highlighted fields.", + ) + expect(dialog()?.textContent).toContain(INVALID_EMAIL) + }) +}) + +describe('Invite: rate limited — a 429 shows a dialog alert and keeps the address', () => { + it('shows the fixed alert, no field error, then succeeds on retry', async () => { + const outcomes = [ + () => + Promise.reject( + new ApiError(429, undefined, 'Too many requests. Please try again later.'), + ), + () => Promise.resolve(inviteFixture()), + ] + await mountPage({ invite: () => outcomes.shift()!() }) + await openModal() + await submitWith(VALID_EMAIL) + + expect(alertText()).toBe('Too many invites. Wait a moment, then try again.') + expect(dialog()?.textContent).not.toContain('Too many requests') + expect(dialog()?.textContent).not.toContain(INVALID_EMAIL) + expect(emailInput().value).toBe(VALID_EMAIL) + expect(emailInput().disabled).toBe(false) + expect(rows()).toHaveLength(5) + + await clickButton('Send invite') + + expect(dialog()).toBeNull() + expect(rowTexts()[0]).toContain('lea.martin@renew.example Pending') + expect(toastAdd).toHaveBeenCalledTimes(1) + }) +}) + +describe('Invite: failed — a 500 or a network failure keeps the address and offers a retry', () => { + it.each([ + { name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) }, + { + name: 'a 500 with the API text', + failure: () => Promise.reject(new ApiError(500, undefined, FAILED_ALERT)), + }, + { name: 'a network failure', failure: () => Promise.reject(new NetworkError()) }, + ])('shows the alert once on $name', async ({ failure }) => { + await mountPage({ invite: failure }) + await openModal() + await submitWith(VALID_EMAIL) + + expect(alertText()).toBe(FAILED_ALERT) + expect(document.body.querySelectorAll('[role="alert"]')).toHaveLength(1) + expect(emailInput().value).toBe(VALID_EMAIL) + expect(emailInput().disabled).toBe(false) + expect(dialog()?.textContent).not.toContain(INVALID_EMAIL) + expect(rows()).toHaveLength(5) + }) +}) + +describe('Retrying after a failure succeeds', () => { + it('sends the same body again and completes', async () => { + const outcomes = [ + () => Promise.reject(new ApiError(500)), + () => Promise.resolve(inviteFixture()), + ] + await mountPage({ invite: () => outcomes.shift()!() }) + await openModal() + await submitWith(VALID_EMAIL) + await clickButton('Send invite') + + expect(inviteCalls()).toHaveLength(2) + expect(inviteCalls()[1][1].body).toBe(inviteCalls()[0][1].body) + expect(dialog()).toBeNull() + expect(rowTexts()[0]).toContain('lea.martin@renew.example Pending') + expect(toastAdd).toHaveBeenCalledTimes(1) + }) +}) + +describe('Cancel discards the address with no confirmation', () => { + it('closes at once, sends nothing, restores focus and reopens empty', async () => { + await mountPage() + await openModal() + await typeEmail(VALID_EMAIL) + await clickButton('Cancel') + + expect(dialog()).toBeNull() + expect(inviteCalls()).toHaveLength(0) + expect(document.activeElement).toBe(findButton('Invite user')) + + await openModal() + expect(emailInput().value).toBe('') + expect(dialog()?.textContent).not.toContain(INVALID_EMAIL) + }) +}) diff --git a/web/visual/states.ts b/web/visual/states.ts index 12dd924..a2f2804 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -152,7 +152,7 @@ const sixArticlesFixture = [ const verifiedSession: ApiMock = { status: 200, - body: { is_verified: true, username: 'marta' }, + body: { id: 1, is_verified: true, username: 'marta' }, } const editorNow = '2026-09-26T11:02:00Z' @@ -565,6 +565,75 @@ function resetConfirmWith(response: ApiMock): Omit { } } +const usersFixture = [ + { + kind: 'invite', + id: 2, + email: 'sam.oliveira@renew.example', + status: 'pending', + created_at: '2026-09-27T09:00:00Z', + }, + { + kind: 'invite', + id: 1, + email: 'p.huber@gmail.com', + status: 'expired', + created_at: '2026-08-03T09:00:00Z', + }, + { + kind: 'user', + id: 3, + username: 'ines', + display_name: 'Inès Duarte', + status: 'accepted', + created_at: '2026-06-18T09:00:00Z', + }, + { + kind: 'user', + id: 2, + username: 'jonas', + display_name: '', + status: 'accepted', + created_at: '2026-04-02T09:00:00Z', + }, + { + kind: 'user', + id: 1, + username: 'marta', + display_name: '', + status: 'accepted', + created_at: '2026-03-12T09:00:00Z', + }, +] + +function usersApi(overrides: Record = {}): Record { + return { + 'GET /v1/auth/me': verifiedSession, + 'GET /v1/user/': { status: 200, body: usersFixture }, + ...overrides, + } +} + +async function openInviteModal(page: Page) { + await page.getByRole('button', { name: 'Invite user' }).click() + await page.getByRole('dialog').waitFor() + await page.mouse.move(0, 0) +} + +function inviteWith(email: string, response?: ApiMock) { + return async (page: Page) => { + await openInviteModal(page) + await page.getByPlaceholder('name@example.com').fill(email) + if (response) { + await page.getByRole('button', { name: 'Send invite', exact: true }).click() + await page.mouse.move(0, 0) + } else { + await page.getByPlaceholder('name@example.com').blur() + await page.mouse.move(0, 0) + } + } +} + const integrationOrigin = 'https://cms.renew.example' async function clickBaseUrlCopy(page: Page) { @@ -572,6 +641,48 @@ async function clickBaseUrlCopy(page: Page) { } export const visualStates: Record> = { + users: { + Default: { path: '/users', api: usersApi() }, + Loading: { + path: '/users', + api: usersApi({ 'GET /v1/user/': { status: 200, body: usersFixture, hang: true } }), + steps: async (page) => { + await page.waitForTimeout(400) + }, + }, + 'Only you': { + path: '/users', + api: usersApi({ 'GET /v1/user/': { status: 200, body: usersFixture.slice(4) } }), + }, + Error: { path: '/users', api: usersApi({ 'GET /v1/user/': { status: 500 } }) }, + 'Invite: default': { path: '/users', api: usersApi(), steps: openInviteModal }, + 'Invite: validation': { + path: '/users', + api: usersApi(), + steps: inviteWith('sam.oliveira@renew'), + }, + 'Invite: sending': { + path: '/users', + api: usersApi({ 'POST /v1/user/invite': { status: 201, hang: true } }), + steps: inviteWith('lea.martin@renew.example', { status: 201, hang: true }), + }, + 'Invite: already a user (409)': { + path: '/users', + api: usersApi({ + 'POST /v1/user/invite': { + status: 409, + body: 'This address already has an account.', + }, + }), + steps: inviteWith('jonas@renew.example', { status: 409 }), + }, + 'Invite: failed': { + path: '/users', + api: usersApi({ 'POST /v1/user/invite': { status: 500 } }), + steps: inviteWith('lea.martin@renew.example', { status: 500 }), + }, + 'Invite sent': { path: '/users', skip: 'clock-sensitive' }, + }, integration: { Default: { path: '/integration',