261 lines
17 KiB
Gherkin
261 lines
17 KiB
Gherkin
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, Settings in that order; "Users" has aria-current="page" on /users; the
|
|
"five navigation destinations" scenario of nuxt_ui_shell_foundation.feature and the items of
|
|
dashboard_shell_branding.feature are amended accordingly
|
|
And the PageHeader shows breadcrumb "Users" (mobile title "Users")
|
|
And the page header is the title "Users" with the subtitle "Everyone who can sign in and work
|
|
on your articles." and, on desktop (md and up), 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, Settings in that order
|
|
And "Users" has the users icon, links to /users and has aria-current="page"
|
|
|
|
Scenario: Loading — skeleton rows after 150 ms while the first request, or a retry, is in flight
|
|
Given GET /v1/user/ has not yet resolved
|
|
When the user navigates to /users
|
|
Then nothing is drawn in place of the list for the first 150 ms
|
|
And after 150 ms 5 skeleton rows replace the list, 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
|