feat: UsersTable and UserCardList get Email, Display name (dash when empty), Role (new RoleBadge), Invite status and Created

This commit is contained in:
fsylvain committed 2026-09-30 02:00:46 +02:00
1 parent 153fd8719a
commit 1ece778caf
19 files changed
+485 -204

No files matched your search

+103 -43
View File
@@ -13,10 +13,10 @@ Feature: Users screen and InviteUserModal
/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, status "accepted", created_at) or kind "invite" (id, email, status "pending" or
"expired", created_at); SPA display of role, owner-only actions and the redesigned columns is
a later SPA slice; this amendment covers the API shape only; 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
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
@@ -24,32 +24,51 @@ Feature: Users screen and InviteUserModal
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
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
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 have no actions and are not clickable
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
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" 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"
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
@@ -87,23 +106,32 @@ Feature: Users screen and InviteUserModal
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
And this slice does not build the accept-invite screen (/invite/accept) and does not add row
actions (Cancel invite, Renew invite); 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 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"
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 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
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 fifth row shows "marta" with the "you" tag, an Accepted badge and "12 Mar 2026"
And below md the same rows render as cards, each with the name line, then the badge and
"Created {date}"
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
@@ -115,19 +143,51 @@ Feature: Users screen and InviteUserModal
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"
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", accepted 12 Mar 2026, and the
session user is "marta"
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" with the "you" tag, an Accepted badge and "12 Mar 2026"
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
@@ -185,8 +245,8 @@ Feature: Users screen and InviteUserModal
"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 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."
@@ -200,7 +260,7 @@ Feature: Users screen and InviteUserModal
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
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