feat(SPA): users page and invite modal

This commit is contained in:
fsylvain committed 2026-09-29 08:35:14 +02:00
1 parent acdef18b37
commit 6afffc1984
20 files changed
+1880 -55

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
<span class="font-semibold">RenewCMS</span> 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
<span>Log out</span> text the shell renders today
And UserMenu is a UDropdownMenu :content="{ side: 'top', align: 'start' }" anchored to a
+5 -4
View File
@@ -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
+1 -1
View File
@@ -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
+260
View File
@@ -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": "<value as typed>"}; 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/ <failure>
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 "<value>" into Email and <trigger>
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 "<email>", and "Send invite" has been activated
When POST /v1/user/invite resolves 409 with body "<body>"
Then the modal re-enables, keeping "<email>" in the field
And the field shows the error "<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 <failure>
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
+112
View File
@@ -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.
+1
View File
@@ -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,
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
status: 'accepted' | 'pending' | 'expired'
size?: 'sm' | 'md'
}>(),
{
size: 'md',
},
)
const badges = {
accepted: {
label: 'Accepted',
icon: 'i-lucide-circle-check',
color: 'success',
class: 'border border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
},
pending: {
label: 'Pending',
icon: 'i-lucide-mail',
color: 'warning',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
expired: {
label: 'Expired',
icon: 'i-lucide-clock-alert',
color: 'neutral',
class: 'border border-accented bg-transparent text-muted',
},
} as const
const badge = computed(() => badges[props.status])
const sizeClass = computed(() => (props.size === 'sm' ? 'h-5 px-1.5' : 'h-5.5 px-1.75'))
</script>
<template>
<UBadge
:color="badge.color"
variant="soft"
:icon="badge.icon"
:class="[badge.class, sizeClass]"
:ui="{ base: 'gap-1 py-0 text-xs', leadingIcon: 'size-3' }"
>
{{ badge.label }}
</UBadge>
</template>
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useUsers, type CreatedInvite } from '@/composables/useUsers'
const EMAIL_PLACEHOLDER = 'name@example.com'
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
const INVALID_EMAIL_MESSAGE = 'Enter a full email address, like name@example.com.'
const HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
const PENDING_INVITE_MESSAGE = "There's already a pending invite for this address."
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ invited: [invite: CreatedInvite] }>()
const { inviteUser } = useUsers()
const email = ref('')
const touched = ref(false)
const focused = ref(false)
const submitting = ref(false)
const serverError = ref('')
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const emailError = computed(
() => serverError.value || (touched.value && !emailValid.value ? INVALID_EMAIL_MESSAGE : ''),
)
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
}
if (formAlert.value === 'rateLimited') {
return 'Too many invites. Wait a moment, then try again.'
}
if (formAlert.value === 'failed') {
return "Couldn't send the invite. Try again in a moment."
}
return ''
})
function resetForm() {
email.value = ''
touched.value = false
focused.value = false
submitting.value = false
serverError.value = ''
formAlert.value = ''
}
function emailInput() {
return document.querySelector<HTMLInputElement>(`input[placeholder="${EMAIL_PLACEHOLDER}"]`)
}
async function focusEmailField() {
await nextTick()
await nextTick()
emailInput()?.focus()
focused.value = true
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
resetForm()
focusEmailField()
},
{ immediate: true },
)
watch(email, () => {
serverError.value = ''
})
function onBlur() {
touched.value = true
focused.value = false
}
function messageForConflict(error: ApiError) {
return error.body === PENDING_INVITE_MESSAGE ? PENDING_INVITE_MESSAGE : HAS_ACCOUNT_MESSAGE
}
function showFailure(error: unknown) {
if (error instanceof ApiError && error.status === 409) {
serverError.value = messageForConflict(error)
return
}
if (error instanceof ApiError && error.status === 400) {
serverError.value = INVALID_EMAIL_MESSAGE
formAlert.value = 'invalid'
return
}
formAlert.value = error instanceof ApiError && error.status === 429 ? 'rateLimited' : 'failed'
}
async function onSubmit() {
if (submitting.value) {
return
}
touched.value = true
focused.value = false
if (!emailValid.value) {
emailInput()?.focus()
return
}
serverError.value = ''
formAlert.value = ''
submitting.value = true
try {
const invite = await inviteUser(email.value)
open.value = false
emit('invited', invite)
} catch (error) {
showFailure(error)
} finally {
submitting.value = false
}
}
function onOpenChange(isOpen: boolean) {
if (!isOpen && submitting.value) {
return
}
open.value = isOpen
}
function onCancel() {
if (submitting.value) {
return
}
resetForm()
open.value = false
}
</script>
<template>
<UModal
:open="open"
title="Invite a user"
description="We'll email them a link to choose a username and password. The link expires after 3 days."
:dismissible="!submitting"
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
@update:open="onOpenChange"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: 'pe-10',
close: 'top-5 end-6 size-7 justify-center p-0',
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template #body>
<div class="flex flex-col gap-4">
<UAlert
v-if="alertMessage"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage"
/>
<UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
<UFormField
label="Email"
name="email"
:error="emailError || undefined"
:ui="{ error: 'mt-1.5 text-xs/5' }"
>
<template v-if="emailError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
v-model="email"
type="email"
:placeholder="EMAIL_PLACEHOLDER"
autocomplete="off"
:disabled="submitting"
color="primary"
:highlight="focused || !!emailError"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
class="w-full"
@focus="focused = true"
@blur="onBlur"
/>
</UFormField>
</UForm>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:disabled="submitting"
@click="onCancel"
>
Cancel
</UButton>
<UButton
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:loading="submitting"
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Sending…' : 'Send invite' }}
</UButton>
</div>
</template>
</UModal>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import UserNameCell from '@/components/UserNameCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
defineProps<{
items: UserListItem[]
loading: boolean
sessionUserId?: number
highlightedKey?: string
}>()
function rowKey(item: UserListItem) {
return userRowKey(item.kind, item.id)
}
</script>
<template>
<ul class="-mx-4 border-y border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<li
v-for="n in USER_SKELETON_ROWS"
:key="n"
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
>
<USkeleton class="h-3.5 w-[55%] rounded-none bg-accented" />
<USkeleton class="h-3 w-[40%] rounded-none bg-accented" />
</li>
</template>
<template v-else>
<li
v-for="item in items"
:key="rowKey(item)"
data-testid="user-card"
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
:class="{ 'bg-elevated': rowKey(item) === highlightedKey }"
>
<UserNameCell
:item="item"
:is-you="item.kind === 'user' && item.id === sessionUserId"
large
/>
<div class="flex items-center gap-2.5 text-xs text-muted">
<InviteStatusBadge :status="item.status" size="sm" />
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
</div>
</li>
</template>
</ul>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { UserListItem } from '@/composables/useUsers'
const props = defineProps<{
item: UserListItem
isYou: boolean
large?: boolean
}>()
const primary = computed(() => {
if (props.item.kind === 'invite') {
return props.item.email
}
return props.item.display_name || props.item.username
})
const secondary = computed(() =>
props.item.kind === 'user' && props.item.display_name ? props.item.username : '',
)
const primaryClass = computed(() => [
props.item.kind === 'invite' ? 'text-toned' : 'font-medium text-highlighted',
props.large ? 'text-[0.9375rem]/[21px]' : '',
])
</script>
<template>
<span class="flex min-w-0 items-center gap-2">
<span class="truncate" :class="primaryClass">{{ primary }}</span>
<span v-if="secondary" class="shrink-0 text-muted">{{ secondary }}</span>
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted"
>you</span
>
</span>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import UserNameCell from '@/components/UserNameCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
const ROW_GRID = 'grid grid-cols-[minmax(0,1fr)_160px_160px] items-center px-3'
defineProps<{
items: UserListItem[]
loading: boolean
sessionUserId?: number
highlightedKey?: string
}>()
function rowKey(item: UserListItem) {
return userRowKey(item.kind, item.id)
}
</script>
<template>
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
<span>Username</span>
<span>Invite status</span>
<span class="flex items-center gap-1 text-highlighted">
Created
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
</div>
<template v-if="loading">
<span role="status" class="sr-only">Loading users</span>
<div
v-for="n in USER_SKELETON_ROWS"
:key="n"
:class="ROW_GRID"
class="h-11 border-t border-default"
>
<USkeleton class="h-3 w-[30%] rounded-none bg-accented" />
<USkeleton class="h-5 w-19 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</template>
<template v-else>
<div
v-for="item in items"
:key="rowKey(item)"
data-testid="user-row"
:class="[ROW_GRID, { 'bg-elevated': rowKey(item) === highlightedKey }]"
class="h-11 border-t border-default"
>
<UserNameCell
:item="item"
:is-you="item.kind === 'user' && item.id === sessionUserId"
class="pr-3"
/>
<span><InviteStatusBadge :status="item.status" /></span>
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
</div>
</template>
</div>
</template>
@@ -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('<svg')).toBeLessThan(
headerHtml.indexOf('RenewCMS</span>'),
)
expect(headerHtml.indexOf('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
})
})
@@ -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')
})
})
+21 -6
View File
@@ -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<string | undefined> {
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<MeResult> {
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 }
+51
View File
@@ -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<UserListItem[]> {
return request<UserListItem[]>('/v1/user/')
}
function inviteUser(email: string): Promise<CreatedInvite> {
return request<CreatedInvite>('/v1/user/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
}
return { listUsers, inviteUser }
}
+13
View File
@@ -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)
}
+5
View File
@@ -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'),
+1
View File
@@ -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
}
+193
View File
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import InviteUserModal from '@/components/InviteUserModal.vue'
import UserCardList from '@/components/UserCardList.vue'
import UsersTable from '@/components/UsersTable.vue'
import { useUsers, userRowKey, type CreatedInvite, type UserListItem } from '@/composables/useUsers'
import { useSessionStore } from '@/stores/session'
const SKELETON_DELAY_MS = 150
const DESKTOP_QUERY = '(min-width: 48rem)'
type LoadState = 'loading' | 'ready' | 'error'
const toast = useToast()
const session = useSessionStore()
const { listUsers } = useUsers()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const loadState = ref<LoadState>('loading')
const items = ref<UserListItem[]>([])
const showSkeleton = ref(false)
const inviteModalOpen = ref(false)
const highlightedKey = ref('')
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let opener: HTMLElement | null = null
const sessionUserId = computed(() => session.session?.id)
const showList = computed(
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
)
const showOnlyYouHint = computed(() => loadState.value === 'ready' && items.value.length === 1)
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadUsers() {
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
items.value = await listUsers()
highlightedKey.value = ''
loadState.value = 'ready'
} catch {
loadState.value = 'error'
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
function openInviteModal() {
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
inviteModalOpen.value = true
}
async function restoreFocus() {
await nextTick()
opener?.focus()
opener = null
}
function onInviteModalOpenChange(isOpen: boolean) {
inviteModalOpen.value = isOpen
if (!isOpen) {
restoreFocus()
}
}
function onInvited(invite: CreatedInvite) {
const sameAddress = invite.email.toLowerCase()
const newRow: UserListItem = {
kind: 'invite',
id: invite.id,
email: invite.email,
status: 'pending',
created_at: invite.created_at,
}
items.value = [
newRow,
...items.value.filter(
(item) => !(item.kind === 'invite' && item.email.toLowerCase() === sameAddress),
),
]
highlightedKey.value = userRowKey('invite', invite.id)
toast.add({
id: `invite-sent-${invite.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Invite sent',
description: `${invite.email} has 3 days to accept.`,
})
}
onMounted(loadUsers)
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<DashboardShell>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Users</h1>
<p class="text-muted">Everyone who can sign in and work on your articles.</p>
</div>
<UButton
v-if="isDesktop"
icon="i-lucide-user-plus"
class="h-8 px-3"
@click="openInviteModal"
>
Invite user
</UButton>
</div>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load users"
description="Check your connection and try again."
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadUsers"
>
Try again
</UButton>
</template>
</UAlert>
<template v-if="showList">
<UsersTable
v-if="isDesktop"
:items="items"
:loading="loadState === 'loading'"
:session-user-id="sessionUserId"
:highlighted-key="highlightedKey"
/>
<UserCardList
v-else
:items="items"
:loading="loadState === 'loading'"
:session-user-id="sessionUserId"
:highlighted-key="highlightedKey"
/>
</template>
<p v-if="showOnlyYouHint" class="text-pretty text-muted">
Invite someone so they can sign in with their own account instead of sharing yours.
</p>
</div>
<UButton
v-if="!isDesktop"
v-show="!inviteModalOpen"
icon="i-lucide-user-plus"
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover)"
:ui="{ base: 'gap-2', leadingIcon: 'size-4.5' }"
@click="openInviteModal"
>
Invite user
</UButton>
<InviteUserModal
:open="inviteModalOpen"
@update:open="onInviteModalOpenChange"
@invited="onInvited"
/>
</DashboardShell>
</template>
+641
View File
@@ -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<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<typeof vi.fn>
let wrapper: VueWrapper | null = null
interface Handlers {
list?: () => Promise<unknown>
invite?: () => Promise<unknown>
}
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<HTMLElement>(
'[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<HTMLInputElement>('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<UserListItem[]>()
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<CreatedInvite>()
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)
})
})
+112 -1
View File
@@ -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<VisualState, 'steps'> {
}
}
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<string, ApiMock> = {}): Record<string, ApiMock> {
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<string, Record<string, VisualState>> = {
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',