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 @@
+
+
+
+
+ {{ badge.label }}
+
+
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 @@
+
+
+
+
+
+