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: user items are kind "user" (id, username, display_name, email, role "owner" or "admin", 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, Settings in that order; "Users" has aria-current="page" on /users; the "five 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) for the owner, a primary "Invite user" button (icon user-plus) at the right of the header And below md, for the owner, "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 "Invite user" (desktop header button and the mobile pinned button) is shown only when the session user's role from GET /v1/auth/me is "owner"; for "admin", or any other or absent value, neither button is rendered, the pinned button's bottom space is not reserved, and the "Only you" hint is hidden; this is not drawn in docs/design, so it has no visual state and is covered by Vitest only And the list is rendered as UsersTable (desktop, md and up) or UserCardList (below md), both fed by the same items array; rows are not clickable; row actions are specified in features/users_row_actions.feature And UsersTable has the columns Email, Display name, Role, Invite status and Created, plus an unlabeled, empty actions column kept so the grid matches the board: grid minmax(0,1.3fr) minmax(0,1fr) 84px 120px 110px 150px with a 12 px gap, a 36 px header on --ui-bg-elevated, 48 px rows with a top border, and an arrow-down icon on the Created header And UserCardList (below md) has a 68 px minimum row height: the email line (with the "you" tag), the display name below it only when there is one, then the RoleBadge, the InviteStatusBadge and "Created {date}" on one line And the username is not shown anywhere in the list And the Email cell shows the account's or invite's email address, medium weight, highlighted; 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 it And the Display name cell shows the item's display_name, highlighted, or a muted dash "—" when it is empty or absent; an invite item shows the dash unless it carries a display_name; the current user's row shows their saved name And RoleBadge is a 20 px badge, IBM Plex Mono 11 px medium, 7 px side padding, no icon, showing the word: owner (filled --ui-primary, --ui-bg text, --ui-primary border) or admin (transparent, --ui-text-toned text, --ui-border-accented border); a user row shows its role, and an invite row shows "admin", the role it will create 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, one bar per column plus a RoleBadge placeholder and an InviteStatusBadge placeholder in the table (md and up); the below-md card skeleton keeps the board's two-bar shape 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" with display_name "Paul Huber" expired 3 Aug 2026; user id 3 "ines" email "ines.duarte@renew.example" display_name "Inès Duarte" role admin accepted 18 Jun 2026; user id 2 "jonas" email "jonas@renew.example" no display name, role admin, accepted 2 Apr 2026; user id 1 "marta" email "marta@renew.example" no display name, role owner, accepted 12 Mar 2026; the session user is id 1 "marta", role "owner" 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); the "Display names in the Users list" state of the Profile group is drawn by this same list Scenario: Default — the list loads newest first with email, display name, role, status and created Given GET /v1/user/ resolves 200 with the 5-item Default fixture and the session user is "marta" with role "owner" 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 table header reads Email, Display name, Role, Invite status, Created, and an empty unlabeled sixth cell And the rows appear in exactly the fixture's order, each 48 px high And the first row shows "sam.oliveira@renew.example", "—" (muted), "admin", a Pending badge and "27 Sep 2026" And the second row shows "p.huber@gmail.com", "Paul Huber", "admin", an Expired badge and "3 Aug 2026" And the third row shows "ines.duarte@renew.example", "Inès Duarte", "admin", an Accepted badge and "18 Jun 2026" And the fourth row shows "jonas@renew.example", "—", "admin", an Accepted badge and "2 Apr 2026" And the fifth row shows "marta@renew.example" with the "you" tag, "—", "owner" (filled), an Accepted badge and "12 Mar 2026" And the username "ines", "jonas" or "marta" appears in no row And below md the same rows render as cards: the email line, the display name below it only for "p.huber@gmail.com" and "ines.duarte@renew.example", then the RoleBadge, the status badge and "Created {date}" on one line 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, Settings 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, on desktop each with one bar per column plus a RoleBadge and an InviteStatusBadge placeholder and below md in the board's two-bar card shape, 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" (email "marta@renew.example", role "owner"), accepted 12 Mar 2026, and the session user is "marta" with role "owner" When the user navigates to /users Then the list has one row: "marta@renew.example" with the "you" tag, "—", "owner", 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: Display names in the Users list — saved names show, empty ones show a dash Given GET /v1/auth/me resolves 200 for user id 1 with display_name "Marta Alves" and role "owner" And GET /v1/user/ resolves 200 with the Default fixture where user id 1 has display_name "Marta Alves" When the user navigates to /users Then the row "marta@renew.example" (you) shows "Marta Alves", "owner" and an Accepted badge And the row "ines.duarte@renew.example" shows "Inès Duarte", "admin" and an Accepted badge And the row "jonas@renew.example" shows a muted "—", "admin" and an Accepted badge And the sidebar chip also shows "Marta Alves" Scenario Outline: Non-owners cannot invite — "Invite user" and the hint are not rendered Given the session user is "" with role "" and GET /v1/user/ resolves 200 with When the user navigates to /users at desktop and at a below-md width Then And the list still renders normally And Examples: | username | role | fixture | invite | hint | | jonas | admin | the Default fixture | no "Invite user" button is rendered, header or pinned | the pinned button's space is not reserved | | jonas | admin | only user id 1 "marta" and user id 2 "jonas" | no "Invite user" button is rendered, header or pinned | the "Invite someone…" hint is not shown | | marta | owner | the Default fixture | "Invite user" is rendered in the header (desktop) and pinned (below md) | the list is unchanged | Scenario: An unknown or missing role is treated as non-owner Given GET /v1/auth/me resolves 200 without a role When the user navigates to /users Then no "Invite user" button is rendered 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 "—", "admin", 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@renew.example" 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