320 lines
21 KiB
Gherkin
320 lines
21 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: user items are kind "user" (id, username, display_name,
|
|
email, role "owner" or "admin", status "accepted", created_at) or kind "invite" (id, email,
|
|
status "pending" or "expired", created_at); the list is used in the order the API returns it
|
|
(created date, newest first), and rows are keyed by kind and id, because a user and an
|
|
invite can share a numeric id
|
|
And DashboardShell's navigation gains "Users" (icon users, linking to /users) between
|
|
"Articles" and "Integration", in the sidebar and in the mobile drawer, so it lists Home,
|
|
Articles, Users, Integration, Settings in that order; "Users" has aria-current="page" on /users; the
|
|
"five navigation destinations" scenario of nuxt_ui_shell_foundation.feature and the items of
|
|
dashboard_shell_branding.feature are amended accordingly
|
|
And the PageHeader shows breadcrumb "Users" (mobile title "Users")
|
|
And the page header is the title "Users" with the subtitle "Everyone who can sign in and work
|
|
on your articles." and, on desktop (md and up) for the owner, a primary "Invite user" button
|
|
(icon user-plus) at the right of the header
|
|
And below md, for the owner, "Invite user" is instead a full-width 48 px button (icon
|
|
user-plus) pinned 16 px from the left, right and bottom of the screen with the popover
|
|
shadow, and it is hidden while InviteUserModal is open
|
|
And "Invite user" (desktop header button and the mobile pinned button) is shown only when the
|
|
session user's role from GET /v1/auth/me is "owner"; for "admin", or any other or absent
|
|
value, neither button is rendered, the pinned button's bottom space is not reserved, and
|
|
the "Only you" hint is hidden; this is not drawn in docs/design, so it has no visual state
|
|
and is covered by Vitest only
|
|
And the list is rendered as UsersTable (desktop, md and up) or UserCardList (below md), both
|
|
fed by the same items array; rows are not clickable; row actions are specified in features/users_row_actions.feature
|
|
And UsersTable has the columns Email, Display name, Role, Invite status and Created, plus an
|
|
unlabeled, empty actions column kept so the grid matches the board: grid
|
|
minmax(0,1.3fr) minmax(0,1fr) 84px 120px 110px 150px with a 12 px gap, a 36 px header on
|
|
--ui-bg-elevated, 48 px rows with a top border, and an arrow-down icon on the Created header
|
|
And UserCardList (below md) has a 68 px minimum row height: the email line (with the "you"
|
|
tag), the display name below it only when there is one, then the RoleBadge, the
|
|
InviteStatusBadge and "Created {date}" on one line
|
|
And the username is not shown anywhere in the list
|
|
And the Email cell shows the account's or invite's email address, medium weight, highlighted;
|
|
the row whose kind is "user" and whose id equals the session user's id (from GET
|
|
/v1/auth/me) carries a muted mono "you" tag after it
|
|
And the Display name cell shows the item's display_name, highlighted, or a muted dash "—" when
|
|
it is empty or absent; an invite item shows the dash unless it carries a display_name; the
|
|
current user's row shows their saved name
|
|
And RoleBadge is a 20 px badge, IBM Plex Mono 11 px medium, 7 px side padding, no icon, showing
|
|
the word: owner (filled --ui-primary, --ui-bg text, --ui-primary border) or admin
|
|
(transparent, --ui-text-toned text, --ui-border-accented border); a user row shows its
|
|
role, and an invite row shows "admin", the role it will create
|
|
And InviteStatusBadge shows icon and word, never color alone: Accepted (circle-check, success
|
|
soft), Pending (mail, warning soft), Expired (clock-alert, transparent, muted, with an
|
|
accented border)
|
|
And the Created column shows the date as "27 Sep 2026", or "Today" when it is the current day;
|
|
the fixture below uses absolute dates only
|
|
And skeleton rows (5) show only after 150 ms of loading, so a fast response never flashes them,
|
|
one bar per column plus a RoleBadge placeholder and an InviteStatusBadge placeholder in the
|
|
table (md and up); the below-md card skeleton keeps the board's two-bar shape
|
|
And the Default fixture holds 5 items, newest first: invite id 2 "sam.oliveira@renew.example"
|
|
pending 27 Sep 2026; invite id 1 "p.huber@gmail.com" with display_name "Paul Huber" expired
|
|
3 Aug 2026; user id 3 "ines" email "ines.duarte@renew.example" display_name "Inès Duarte"
|
|
role admin accepted 18 Jun 2026; user id 2 "jonas" email "jonas@renew.example" no display
|
|
name, role admin, accepted 2 Apr 2026; user id 1 "marta" email "marta@renew.example" no
|
|
display name, role owner, accepted 12 Mar 2026; the session user is id 1 "marta", role
|
|
"owner"
|
|
And InviteUserModal is a UModal opened from either "Invite user" button; every time it opens it
|
|
starts in its default state: title "Invite a user", text "We'll email them a link to choose
|
|
a username and password. The link expires after 3 days.", an empty, focused Email field
|
|
with placeholder "name@example.com", a "Cancel" button and a "Send invite" button
|
|
And the Email field validates on blur and again on submit, then on every input once it shows
|
|
an error; an empty value or one that is not a full email address shows "Enter a full email
|
|
address, like name@example.com." under the field, sends no request, and moves focus to the
|
|
field on a failed submit; the rule mirrors the API's email validation
|
|
And activating "Send invite" with a valid address sends POST /v1/user/invite with JSON
|
|
{"email": "<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); the "Display names in the Users list" state of the
|
|
Profile group is drawn by this same list
|
|
|
|
Scenario: Default — the list loads newest first with email, display name, role, status and created
|
|
Given GET /v1/user/ resolves 200 with the 5-item Default fixture and the session user is
|
|
"marta" with role "owner"
|
|
When the user navigates to /users
|
|
Then the breadcrumb reads "Users" (mobile title "Users")
|
|
And the header shows the title "Users", the subtitle and, on desktop, "Invite user"
|
|
And the table header reads Email, Display name, Role, Invite status, Created, and an empty
|
|
unlabeled sixth cell
|
|
And the rows appear in exactly the fixture's order, each 48 px high
|
|
And the first row shows "sam.oliveira@renew.example", "—" (muted), "admin", a Pending badge
|
|
and "27 Sep 2026"
|
|
And the second row shows "p.huber@gmail.com", "Paul Huber", "admin", an Expired badge and
|
|
"3 Aug 2026"
|
|
And the third row shows "ines.duarte@renew.example", "Inès Duarte", "admin", an Accepted
|
|
badge and "18 Jun 2026"
|
|
And the fourth row shows "jonas@renew.example", "—", "admin", an Accepted badge and "2 Apr 2026"
|
|
And the fifth row shows "marta@renew.example" with the "you" tag, "—", "owner" (filled), an
|
|
Accepted badge and "12 Mar 2026"
|
|
And the username "ines", "jonas" or "marta" appears in no row
|
|
And below md the same rows render as cards: the email line, the display name below it only
|
|
for "p.huber@gmail.com" and "ines.duarte@renew.example", then the RoleBadge, the status
|
|
badge and "Created {date}" on one line
|
|
|
|
Scenario: Navigation — "Users" sits between Articles and Integration
|
|
Given the user is on /users
|
|
When DashboardShell mounts
|
|
Then the sidebar and the mobile drawer list Home, Articles, Users, Integration, Settings in that order
|
|
And "Users" has the users icon, links to /users and has aria-current="page"
|
|
|
|
Scenario: Loading — skeleton rows after 150 ms while the first request, or a retry, is in flight
|
|
Given GET /v1/user/ has not yet resolved
|
|
When the user navigates to /users
|
|
Then nothing is drawn in place of the list for the first 150 ms
|
|
And after 150 ms 5 skeleton rows replace the list, on desktop each with one bar per column
|
|
plus a RoleBadge and an InviteStatusBadge placeholder and below md in the board's two-bar
|
|
card shape, and the list container is aria-busy="true"
|
|
When GET /v1/user/ then resolves 200 with the Default fixture
|
|
Then the skeleton rows are replaced by the Default state's rows
|
|
|
|
Scenario: Only you — nobody has been invited yet
|
|
Given GET /v1/user/ resolves 200 with only user id 1 "marta" (email "marta@renew.example",
|
|
role "owner"), accepted 12 Mar 2026, and the session user is "marta" with role "owner"
|
|
When the user navigates to /users
|
|
Then the list has one row: "marta@renew.example" with the "you" tag, "—", "owner", an
|
|
Accepted badge and "12 Mar 2026"
|
|
And under the list the hint reads "Invite someone so they can sign in with their own account
|
|
instead of sharing yours."
|
|
And "Invite user" is still available
|
|
|
|
Scenario: Display names in the Users list — saved names show, empty ones show a dash
|
|
Given GET /v1/auth/me resolves 200 for user id 1 with display_name "Marta Alves" and role "owner"
|
|
And GET /v1/user/ resolves 200 with the Default fixture where user id 1 has display_name
|
|
"Marta Alves"
|
|
When the user navigates to /users
|
|
Then the row "marta@renew.example" (you) shows "Marta Alves", "owner" and an Accepted badge
|
|
And the row "ines.duarte@renew.example" shows "Inès Duarte", "admin" and an Accepted badge
|
|
And the row "jonas@renew.example" shows a muted "—", "admin" and an Accepted badge
|
|
And the sidebar chip also shows "Marta Alves"
|
|
|
|
Scenario Outline: Non-owners cannot invite — "Invite user" and the hint are not rendered
|
|
Given the session user is "<username>" with role "<role>" and GET /v1/user/ resolves 200
|
|
with <fixture>
|
|
When the user navigates to /users at desktop and at a below-md width
|
|
Then <invite>
|
|
And the list still renders normally
|
|
And <hint>
|
|
|
|
Examples:
|
|
| username | role | fixture | invite | hint |
|
|
| jonas | admin | the Default fixture | no "Invite user" button is rendered, header or pinned | the pinned button's space is not reserved |
|
|
| jonas | admin | only user id 1 "marta" and user id 2 "jonas" | no "Invite user" button is rendered, header or pinned | the "Invite someone…" hint is not shown |
|
|
| marta | owner | the Default fixture | "Invite user" is rendered in the header (desktop) and pinned (below md) | the list is unchanged |
|
|
|
|
Scenario: An unknown or missing role is treated as non-owner
|
|
Given GET /v1/auth/me resolves 200 without a role
|
|
When the user navigates to /users
|
|
Then no "Invite user" button is rendered
|
|
|
|
Scenario Outline: Error — a 500 or a network failure replaces the list with an alert and a retry
|
|
Given GET /v1/user/ <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 "—", "admin", a
|
|
Pending badge and "Today", drawn with the --ui-bg-elevated background
|
|
And the other 5 rows are unchanged and follow it in their previous order
|
|
And a toast appears: color success, icon i-lucide-circle-check, id "invite-sent-3", title
|
|
"Invite sent", description "lea.martin@renew.example has 3 days to accept."
|
|
|
|
Scenario: Invite sent to an address whose invite had expired replaces the old row
|
|
Given the Default state has loaded, with the Expired row "p.huber@gmail.com"
|
|
When the user invites "p.huber@gmail.com" and POST /v1/user/invite resolves 201
|
|
Then the list holds exactly one row for "p.huber@gmail.com", first, with a Pending badge
|
|
And the Expired row is gone
|
|
|
|
Scenario: Invite sent into the Only-you state keeps a coherent list
|
|
Given the Only-you state has loaded
|
|
When an invite for "lea.martin@renew.example" is sent and resolves 201
|
|
Then the list shows the new Pending row above the "marta@renew.example" row
|
|
And the Only-you hint is no longer shown
|
|
|
|
Scenario Outline: Invite: already a user (409) — the field error depends on the response text
|
|
Given the modal is open with Email "<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
|