feat(SPA): users page and invite modal
This commit is contained in:
1 parent
acdef18b37
commit
6afffc1984
20 files changed
+1880
-55
No files matched your search
@@ -17,7 +17,7 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
|
|||||||
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
|
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
|
||||||
<span class="font-semibold">RenewCMS</span> the shell renders today
|
<span class="font-semibold">RenewCMS</span> the shell renders today
|
||||||
And the sidebar's #default slot is a UNavigationMenu orientation="vertical" :collapsed bound
|
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
|
And the sidebar's #footer slot renders a UserMenu component, not the plain
|
||||||
<span>Log out</span> text the shell renders today
|
<span>Log out</span> text the shell renders today
|
||||||
And UserMenu is a UDropdownMenu :content="{ side: 'top', align: 'start' }" anchored to a
|
And UserMenu is a UDropdownMenu :content="{ side: 'top', align: 'start' }" anchored to a
|
||||||
|
|||||||
@@ -130,13 +130,14 @@ Feature: Nuxt UI shell foundation and router guards
|
|||||||
Then the alert above the title contains "Your session expired."
|
Then the alert above the title contains "Your session expired."
|
||||||
And the footer area outside the card contains a "Forgot password?" link
|
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
|
Given the current route is /article
|
||||||
When DashboardShell mounts
|
When DashboardShell mounts
|
||||||
Then the sidebar navigation lists exactly three destinations: "Home" linking to /home, "Articles"
|
Then the sidebar navigation lists exactly four destinations in this order: "Home" linking to
|
||||||
linking to /article, and "Integration" linking to /integration
|
/home, "Articles" linking to /article, "Users" linking to /users, and "Integration" linking
|
||||||
|
to /integration
|
||||||
And the "Articles" destination has aria-current="page"
|
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
|
Scenario: DashboardShell shows the offline banner when the browser reports offline
|
||||||
Given navigator.onLine is false
|
Given navigator.onLine is false
|
||||||
|
|||||||
@@ -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
|
13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert
|
||||||
both render outside/above the card content as specified.
|
both render outside/above the card content as specified.
|
||||||
14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert
|
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".
|
has aria-current="page".
|
||||||
15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert
|
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
|
the offline banner is visible; set navigator.onLine true and dispatch the 'online' event; assert
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -36,6 +36,7 @@ const navItems = computed(() =>
|
|||||||
[
|
[
|
||||||
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
|
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
|
||||||
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
|
{ 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' },
|
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
|
||||||
].map((item) => ({
|
].map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 = ''
|
document.body.innerHTML = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('DashboardShell renders the three navigation destinations', () => {
|
describe('DashboardShell renders the four navigation destinations', () => {
|
||||||
it('lists Home, Articles and Integration, with only Articles carrying aria-current', async () => {
|
it('lists Home, Articles, Users and Integration in order, with only Articles carrying aria-current', async () => {
|
||||||
const wrapper = await mountShell('/article')
|
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 home = wrapper.find('a[href="/home"]')
|
||||||
const articles = wrapper.find('a[href="/article"]')
|
const articles = wrapper.find('a[href="/article"]')
|
||||||
|
const users = wrapper.find('a[href="/users"]')
|
||||||
const integration = wrapper.find('a[href="/integration"]')
|
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(articles.attributes('aria-current')).toBe('page')
|
||||||
expect(home.attributes('aria-current')).toBeUndefined()
|
expect(home.attributes('aria-current')).toBeUndefined()
|
||||||
|
expect(users.attributes('aria-current')).toBeUndefined()
|
||||||
expect(integration.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', () => {
|
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
|
||||||
it('shows the banner while offline and hides it once back online', async () => {
|
it('shows the banner while offline and hides it once back online', async () => {
|
||||||
const onLineSpy = vi
|
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||||
.spyOn(navigator, 'onLine', 'get')
|
|
||||||
.mockReturnValue(false)
|
|
||||||
|
|
||||||
const wrapper = await mountShell('/article')
|
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 wrapper = await mountShell('/home')
|
||||||
|
|
||||||
const header = wrapper.find('[data-slot="header"]')
|
const header = wrapper.find('[data-slot="header"]')
|
||||||
expect(
|
expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true)
|
||||||
header.find('svg[role="img"][aria-label="RenewCMS"]').exists(),
|
|
||||||
).toBe(true)
|
|
||||||
expect(header.text()).toContain('RenewCMS')
|
expect(header.text()).toContain('RenewCMS')
|
||||||
expect(header.find('a').exists()).toBe(false)
|
expect(header.find('a').exists()).toBe(false)
|
||||||
|
|
||||||
const headerHtml = header.html()
|
const headerHtml = header.html()
|
||||||
expect(headerHtml.indexOf('<svg')).toBeLessThan(
|
expect(headerHtml.indexOf('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
|
||||||
headerHtml.indexOf('RenewCMS</span>'),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -97,9 +103,7 @@ describe('The sidebar collapse trigger toggles the rail and exposes aria-expande
|
|||||||
await mountShell('/home')
|
await mountShell('/home')
|
||||||
|
|
||||||
const collapseButton = () =>
|
const collapseButton = () =>
|
||||||
document.body.querySelector(
|
document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement
|
||||||
'[data-testid="sidebar-collapse"]',
|
|
||||||
) as HTMLButtonElement
|
|
||||||
|
|
||||||
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
|
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')
|
await mountShell('/home')
|
||||||
|
|
||||||
const logOutItem = () =>
|
const logOutItem = () =>
|
||||||
Array.from(
|
Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
|
||||||
document.body.querySelectorAll('[role="menuitem"]'),
|
(el) => el.textContent?.trim() === 'Log out',
|
||||||
).find((el) => el.textContent?.trim() === 'Log out') as HTMLElement
|
) as HTMLElement
|
||||||
|
|
||||||
const trigger = document.body.querySelector(
|
const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||||
'button[aria-haspopup]',
|
|
||||||
) as HTMLButtonElement
|
|
||||||
trigger.click()
|
trigger.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -151,28 +153,16 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
|
|||||||
collapseButton.click()
|
collapseButton.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const header = document.body.querySelector(
|
const header = document.body.querySelector('[data-slot="header"]') as HTMLElement
|
||||||
'[data-slot="header"]',
|
expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy()
|
||||||
) as HTMLElement
|
|
||||||
expect(
|
|
||||||
header.querySelector('svg[role="img"][aria-label="RenewCMS"]'),
|
|
||||||
).toBeTruthy()
|
|
||||||
expect(header.textContent).not.toContain('RenewCMS')
|
expect(header.textContent).not.toContain('RenewCMS')
|
||||||
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
|
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
|
||||||
|
|
||||||
const footer = document.body.querySelector(
|
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
|
||||||
'[data-slot="footer"]',
|
|
||||||
) as HTMLElement
|
|
||||||
expect(footer.textContent).not.toContain('marta')
|
expect(footer.textContent).not.toContain('marta')
|
||||||
|
|
||||||
const userMenuTrigger = footer.querySelector(
|
const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||||
'button[aria-haspopup]',
|
expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu')
|
||||||
) as HTMLButtonElement
|
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||||
expect(userMenuTrigger.getAttribute('aria-label')).toBe(
|
|
||||||
'marta — account menu',
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
footer.querySelector('[data-slot="fallback"]')?.textContent,
|
|
||||||
).toBe('MA')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -5,11 +5,13 @@ import { useSessionStore } from '@/stores/session'
|
|||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number
|
status: number
|
||||||
retryAfter?: number
|
retryAfter?: number
|
||||||
|
body?: string
|
||||||
|
|
||||||
constructor(status: number, retryAfter?: number) {
|
constructor(status: number, retryAfter?: number, body?: string) {
|
||||||
super(`Request failed with status ${status}`)
|
super(`Request failed with status ${status}`)
|
||||||
this.status = status
|
this.status = status
|
||||||
this.retryAfter = retryAfter
|
this.retryAfter = retryAfter
|
||||||
|
this.body = body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ export class NetworkError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MeResult {
|
export interface MeResult {
|
||||||
|
id?: number
|
||||||
verified: boolean
|
verified: boolean
|
||||||
username?: string
|
username?: string
|
||||||
}
|
}
|
||||||
@@ -51,6 +54,17 @@ function parseRetryAfterSeconds(response: Response): number | undefined {
|
|||||||
return Number.parseInt(header, 10)
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('offline', () => {
|
window.addEventListener('offline', () => {
|
||||||
isOffline.value = true
|
isOffline.value = true
|
||||||
@@ -102,7 +116,7 @@ export function useApi() {
|
|||||||
throw new ApiError(429, parseRetryAfterSeconds(response))
|
throw new ApiError(429, parseRetryAfterSeconds(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ApiError(response.status)
|
throw new ApiError(response.status, undefined, await readErrorBody(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
@@ -113,10 +127,11 @@ export function useApi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getMe(): Promise<MeResult> {
|
async function getMe(): Promise<MeResult> {
|
||||||
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', {
|
const body = await request<{ id?: number; is_verified: boolean; username?: string }>(
|
||||||
onUnauthorized: 'passthrough',
|
'/v1/auth/me',
|
||||||
})
|
{ onUnauthorized: 'passthrough' },
|
||||||
return { verified: Boolean(body.is_verified), username: body.username }
|
)
|
||||||
|
return { id: body.id, verified: Boolean(body.is_verified), username: body.username }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getMe, request }
|
return { getMe, request }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -47,3 +47,16 @@ export function formatRelativeDate(iso: string, now: Date = new Date()) {
|
|||||||
}
|
}
|
||||||
return formatAbsoluteDate(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)
|
||||||
|
}
|
||||||
@@ -66,6 +66,11 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/EditorView.vue'),
|
component: () => import('@/views/EditorView.vue'),
|
||||||
meta: { auth: 'verified', title: 'Editor' },
|
meta: { auth: 'verified', title: 'Editor' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/users',
|
||||||
|
component: () => import('@/views/UsersView.vue'),
|
||||||
|
meta: { auth: 'verified', title: 'Users' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/integration',
|
path: '/integration',
|
||||||
component: () => import('@/views/IntegrationView.vue'),
|
component: () => import('@/views/IntegrationView.vue'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ApiError, useApi } from '@/composables/useApi'
|
|||||||
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
|
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
|
id?: number
|
||||||
verified: boolean
|
verified: boolean
|
||||||
username?: string
|
username?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -152,7 +152,7 @@ const sixArticlesFixture = [
|
|||||||
|
|
||||||
const verifiedSession: ApiMock = {
|
const verifiedSession: ApiMock = {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: { is_verified: true, username: 'marta' },
|
body: { id: 1, is_verified: true, username: 'marta' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorNow = '2026-09-26T11:02:00Z'
|
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'
|
const integrationOrigin = 'https://cms.renew.example'
|
||||||
|
|
||||||
async function clickBaseUrlCopy(page: Page) {
|
async function clickBaseUrlCopy(page: Page) {
|
||||||
@@ -572,6 +641,48 @@ async function clickBaseUrlCopy(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
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: {
|
integration: {
|
||||||
Default: {
|
Default: {
|
||||||
path: '/integration',
|
path: '/integration',
|
||||||
|
|||||||
Reference in new issue
Block a user