feat: UsersTable and UserCardList get Email, Display name (dash when empty), Role (new RoleBadge), Invite status and Created
This commit is contained in:
1 parent
153fd8719a
commit
1ece778caf
19 files changed
+485
-204
No files matched your search
+103
-43
@@ -13,10 +13,10 @@ Feature: Users screen and InviteUserModal
|
|||||||
/v1/user/invite; both endpoints already exist (features/invitations.feature) and this slice
|
/v1/user/invite; both endpoints already exist (features/invitations.feature) and this slice
|
||||||
wires the SPA to them unchanged, with no Go change
|
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,
|
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
|
email, role "owner" or "admin", status "accepted", created_at) or kind "invite" (id, email,
|
||||||
"expired", created_at); SPA display of role, owner-only actions and the redesigned columns is
|
status "pending" or "expired", created_at); the list is used in the order the API returns it
|
||||||
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),
|
(created date, newest first), and rows are keyed by kind and id, because a user and an
|
||||||
and rows are keyed by kind and id, because a user and an invite can share a numeric id
|
invite can share a numeric id
|
||||||
And DashboardShell's navigation gains "Users" (icon users, linking to /users) between
|
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" 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
|
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
|
dashboard_shell_branding.feature are amended accordingly
|
||||||
And the PageHeader shows breadcrumb "Users" (mobile title "Users")
|
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
|
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
|
on your articles." and, on desktop (md and up) for the owner, a primary "Invite user" button
|
||||||
user-plus) at the right of the header
|
(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
|
And below md, for the owner, "Invite user" is instead a full-width 48 px button (icon
|
||||||
from the left, right and bottom of the screen with the popover shadow, and it is hidden
|
user-plus) pinned 16 px from the left, right and bottom of the screen with the popover
|
||||||
while InviteUserModal is open
|
shadow, and it is hidden while InviteUserModal is open
|
||||||
And the list is rendered as UsersTable (desktop, md and up: columns Username, Invite status,
|
And "Invite user" (desktop header button and the mobile pinned button) is shown only when the
|
||||||
Created, grid minmax(0,1fr) 160px 160px, 36 px header, 44 px rows, an arrow-down icon on the
|
session user's role from GET /v1/auth/me is "owner"; for "admin", or any other or absent
|
||||||
Created header) or UserCardList (below md: 68 px minimum row height, the name line, then the
|
value, neither button is rendered, the pinned button's bottom space is not reserved, and
|
||||||
InviteStatusBadge and "Created {date}"), both fed by the same items array; rows have no
|
the "Only you" hint is hidden; this is not drawn in docs/design, so it has no visual state
|
||||||
actions and are not clickable
|
and is covered by Vitest only
|
||||||
And a user row's name cell shows the display name first (medium weight, highlighted) with the
|
And the list is rendered as UsersTable (desktop, md and up) or UserCardList (below md), both
|
||||||
username beside it in muted text when display_name is not empty, otherwise the username
|
fed by the same items array; rows have no actions and are not clickable
|
||||||
alone (medium weight, highlighted); an invite row shows the email address in regular weight,
|
And UsersTable has the columns Email, Display name, Role, Invite status and Created, plus an
|
||||||
toned
|
unlabeled, empty actions column kept so the grid matches the board: grid
|
||||||
And the row whose kind is "user" and whose id equals the session user's id (from GET
|
minmax(0,1.3fr) minmax(0,1fr) 84px 120px 110px 150px with a 12 px gap, a 36 px header on
|
||||||
/v1/auth/me) carries a muted mono "you" tag after the name
|
--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
|
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
|
soft), Pending (mail, warning soft), Expired (clock-alert, transparent, muted, with an
|
||||||
accented border)
|
accented border)
|
||||||
And the Created column shows the date as "27 Sep 2026", or "Today" when it is the current day;
|
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
|
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"
|
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"
|
pending 27 Sep 2026; invite id 1 "p.huber@gmail.com" with display_name "Paul Huber" expired
|
||||||
with display_name "Inès Duarte" accepted 18 Jun 2026; user id 2 "jonas" accepted 2 Apr 2026;
|
3 Aug 2026; user id 3 "ines" email "ines.duarte@renew.example" display_name "Inès Duarte"
|
||||||
user id 1 "marta" accepted 12 Mar 2026; the session user is id 1 "marta"
|
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
|
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
|
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
|
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
|
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
|
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)
|
handling (features/api_client_global_error_handling.feature)
|
||||||
And this slice does not build the accept-invite screen (/invite/accept), does not add row
|
And this slice does not build the accept-invite screen (/invite/accept) and does not add row
|
||||||
actions (remove, promote, revoke or resend), and does not change how display names look in
|
actions (Cancel invite, Renew invite); the "Display names in the Users list" state of the
|
||||||
the Profile group's "Display names in the Users list" state
|
Profile group is drawn by this same list
|
||||||
|
|
||||||
Scenario: Default — the list loads newest first, with display name, "you" tag and status badges
|
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"
|
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
|
When the user navigates to /users
|
||||||
Then the breadcrumb reads "Users" (mobile title "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 header shows the title "Users", the subtitle and, on desktop, "Invite user"
|
||||||
And the rows appear in exactly the fixture's order
|
And the table header reads Email, Display name, Role, Invite status, Created, and an empty
|
||||||
And the first row shows "sam.oliveira@renew.example" (toned), a Pending badge and "27 Sep 2026"
|
unlabeled sixth cell
|
||||||
And the second row shows "p.huber@gmail.com", an Expired badge and "3 Aug 2026"
|
And the rows appear in exactly the fixture's order, each 48 px high
|
||||||
And the third row shows "Inès Duarte" first with "ines" beside it in muted text, an Accepted
|
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"
|
badge and "18 Jun 2026"
|
||||||
And the fifth row shows "marta" with the "you" tag, an Accepted badge and "12 Mar 2026"
|
And the fourth row shows "jonas@renew.example", "—", "admin", an Accepted badge and "2 Apr 2026"
|
||||||
And below md the same rows render as cards, each with the name line, then the badge and
|
And the fifth row shows "marta@renew.example" with the "you" tag, "—", "owner" (filled), an
|
||||||
"Created {date}"
|
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
|
Scenario: Navigation — "Users" sits between Articles and Integration
|
||||||
Given the user is on /users
|
Given the user is on /users
|
||||||
@@ -115,19 +143,51 @@ Feature: Users screen and InviteUserModal
|
|||||||
Given GET /v1/user/ has not yet resolved
|
Given GET /v1/user/ has not yet resolved
|
||||||
When the user navigates to /users
|
When the user navigates to /users
|
||||||
Then nothing is drawn in place of the list for the first 150 ms
|
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
|
When GET /v1/user/ then resolves 200 with the Default fixture
|
||||||
Then the skeleton rows are replaced by the Default state's rows
|
Then the skeleton rows are replaced by the Default state's rows
|
||||||
|
|
||||||
Scenario: Only you — nobody has been invited yet
|
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
|
Given GET /v1/user/ resolves 200 with only user id 1 "marta" (email "marta@renew.example",
|
||||||
session user is "marta"
|
role "owner"), accepted 12 Mar 2026, and the session user is "marta" with role "owner"
|
||||||
When the user navigates to /users
|
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
|
And under the list the hint reads "Invite someone so they can sign in with their own account
|
||||||
instead of sharing yours."
|
instead of sharing yours."
|
||||||
And "Invite user" is still available
|
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
|
Scenario Outline: Error — a 500 or a network failure replaces the list with an alert and a retry
|
||||||
Given GET /v1/user/ <failure>
|
Given GET /v1/user/ <failure>
|
||||||
When the user navigates to /users
|
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
|
"lea.martin@renew.example", status "pending", created_at now, expires_at 3 days later
|
||||||
Then the modal closes and focus returns to "Invite user"
|
Then the modal closes and focus returns to "Invite user"
|
||||||
And GET /v1/user/ is not called again
|
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
|
And the list has 6 rows, the first being "lea.martin@renew.example" with "—", "admin", a
|
||||||
"Today", drawn with the --ui-bg-elevated background
|
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 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
|
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."
|
"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
|
Scenario: Invite sent into the Only-you state keeps a coherent list
|
||||||
Given the Only-you state has loaded
|
Given the Only-you state has loaded
|
||||||
When an invite for "lea.martin@renew.example" is sent and resolves 201
|
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
|
And the Only-you hint is no longer shown
|
||||||
|
|
||||||
Scenario Outline: Invite: already a user (409) — the field error depends on the response text
|
Scenario Outline: Invite: already a user (409) — the field error depends on the response text
|
||||||
|
|||||||
+47
-14
@@ -16,20 +16,27 @@ The list items are keyed by `kind` and `id`.
|
|||||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
## 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.
|
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
|
Mount UsersPage inside DashboardShell with a resolved verified session (id 1, username "marta",
|
||||||
a test router at /users. The Default fixture is the Background's 5 items; the invite fixture is
|
role "owner") and a test router at /users; cases 6 to 17 use that owner session. 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
|
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
|
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`).
|
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,
|
1. **Default** — assert breadcrumb "Users", title, subtitle, "Invite user", the table header
|
||||||
"Inès Duarte" with "ines" beside it, the "you" tag only on the "marta" row, badge words and
|
labels (Email, Display name, Role, Invite status, Created), rows in fixture order, per row the
|
||||||
the fixture's absolute dates.
|
email, the display name or "—", the RoleBadge word and class (owner filled, admin outlined),
|
||||||
|
the badge word and the absolute date, "Paul Huber" on the invite row, the "you" tag only on
|
||||||
|
"marta@renew.example", no username text in any row, and the below-md cards (display name line
|
||||||
|
only when set).
|
||||||
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration,
|
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration,
|
||||||
Settings in that order, and "Users" carries aria-current="page" on /users.
|
Settings 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
|
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.
|
assert 5 skeleton rows and `aria-busy="true"`, and at desktop that each row has a RoleBadge
|
||||||
4. **Only you** — a single "marta" item; assert the row, the hint text and "Invite user".
|
and a status badge placeholder (below md the cards keep the two-bar shape); resolve and
|
||||||
|
assert the Default rows.
|
||||||
|
4. **Only you** — a single "marta" item (email, role "owner"); assert the row, the hint text and
|
||||||
|
"Invite user".
|
||||||
5. **Error, Examples-driven** — reject with `ApiError(500)`, then `NetworkError`; assert the alert
|
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`
|
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.
|
is called again, then resolve the Default fixture and assert its rows.
|
||||||
@@ -45,8 +52,8 @@ part). Run each layout case at a desktop and a below-md width (mock `matchMedia`
|
|||||||
was not called a second time, the list has 6 rows with the new Pending row first and the
|
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
|
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
|
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
|
"Invite sent" and description "lea.martin@renew.example has 3 days to accept." Assert the new
|
||||||
about the "Today" label.
|
first row shows "—" and "admin", and nothing about the "Today" label.
|
||||||
10. **Replacement of an expired invite** — invite "p.huber@gmail.com", resolve 201; assert exactly
|
10. **Replacement of an expired invite** — invite "p.huber@gmail.com", resolve 201; assert exactly
|
||||||
one row for that address, first and Pending.
|
one row for that address, first and Pending.
|
||||||
11. **Invite sent into Only you** — assert the new row above "marta" and the hint gone.
|
11. **Invite sent into Only you** — assert the new row above "marta" and the hint gone.
|
||||||
@@ -65,6 +72,14 @@ part). Run each layout case at a desktop and a below-md width (mock `matchMedia`
|
|||||||
the same body; resolve 201 and assert the closed modal, the row and the toast.
|
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
|
17. **Cancel discards** — type, click "Cancel"; assert immediate close, no `request`, an empty
|
||||||
field on reopening, and focus back on the opening button.
|
field on reopening, and focus back on the opening button.
|
||||||
|
18. **Display names in the Users list** — the Default fixture with user 1 named "Marta Alves"
|
||||||
|
(session `/v1/auth/me` too); assert the "marta@renew.example" row shows "Marta Alves", "owner"
|
||||||
|
and Accepted, the "ines.duarte@renew.example" row "Inès Duarte" and "admin", the
|
||||||
|
"jonas@renew.example" row "—" and "admin", and the sidebar chip "Marta Alves".
|
||||||
|
19. **Non-owners, Examples-driven** — session role "admin", at desktop and below md; assert no
|
||||||
|
"Invite user" button (header or pinned), the list still renders, no reserved space for the
|
||||||
|
pinned button, and the hint absent for a list of only "marta" and "jonas". Also a session with
|
||||||
|
no role (no button) and an owner control (button present in both layouts).
|
||||||
|
|
||||||
## Visual (task visual:check -- users, desktop and mobile, light and dark)
|
## Visual (task visual:check -- users, desktop and mobile, light and dark)
|
||||||
|
|
||||||
@@ -73,11 +88,24 @@ Screens Back-office.dc.html`, exact values in `docs/design/source/AppFrame.dc.ht
|
|||||||
state's mock to `web/visual/states.ts` with the board's own sample data. The gate is hard: every
|
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.
|
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
|
- [ ] Fixtures: `usersFixture` in `web/visual/states.ts` follows the board data: invite 2
|
||||||
"Inès Duarte", `jonas`, `marta` with "you"), dates 27 Sep 2026, 3 Aug 2026, 18 Jun 2026,
|
`sam.oliveira@renew.example` pending, no name; invite 1 `p.huber@gmail.com` with
|
||||||
2 Apr 2026, 12 Mar 2026; session user `marta`.
|
`display_name: 'Paul Huber'` expired; user 3 `ines.duarte@renew.example` "Inès Duarte" admin;
|
||||||
- [ ] Loading: 5 skeleton rows; the setup must give skeletons time to show (150 ms) before the capture.
|
user 2 `jonas@renew.example` admin, empty name; user 1 `marta@renew.example` owner, empty
|
||||||
- [ ] Only you: the single `marta` row plus the hint.
|
name. `verifiedSession` carries `role: 'owner'`. `profileSession` and `usersWithDisplayNames`
|
||||||
|
are updated so "Display names in the Users list" shows "Marta Alves", owner.
|
||||||
|
- [ ] Default: the 5 board rows by email with display names, roles and status badges, dates
|
||||||
|
27 Sep 2026, 3 Aug 2026, 18 Jun 2026, 2 Apr 2026, 12 Mar 2026; session user `marta`. The
|
||||||
|
board also draws Cancel/Renew buttons in the sixth column, which this slice does not
|
||||||
|
render; if the gate fails on them, stop and ask the user (no exception without approval).
|
||||||
|
- [ ] Loading: 5 skeleton rows, with role and status placeholders on desktop and the board's
|
||||||
|
two-bar cards on mobile; the setup must give skeletons time to show (150 ms) before the
|
||||||
|
capture.
|
||||||
|
- [ ] Only you: the single `marta@renew.example` row (owner) plus the hint.
|
||||||
|
- [ ] Profile "Display names in the Users list" (`task visual:check -- profile "Display names in
|
||||||
|
the Users list"`): Marta Alves / Inès Duarte / dash.
|
||||||
|
- [ ] Not drawn, no visual state: non-owner rendering (no "Invite user", no hint). Covered by
|
||||||
|
Vitest only (case 19); do not add it to `web/visual/states.ts`.
|
||||||
- [ ] Error: the alert and "Try again", no table or cards; the header button is still drawn.
|
- [ ] 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: default: modal over the Default list, empty focused Email; the mobile pinned button hidden.
|
||||||
- [ ] Invite: validation: value `sam.oliveira@renew`, error copy.
|
- [ ] Invite: validation: value `sam.oliveira@renew`, error copy.
|
||||||
@@ -91,6 +119,9 @@ capture of every non-skipped state must be within 10 %, for both viewports and b
|
|||||||
- [ ] Layout, taken from the board frame (`AppFrame.dc.html`), not `components.md`: desktop header
|
- [ ] 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
|
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.
|
the row; on mobile the button is 16 px from left, right and bottom, 48 px high, 18 px icon.
|
||||||
|
- [ ] Layout, from `AppFrame.dc.html`: 36 px table header, 48 px rows, sixth 150 px track empty;
|
||||||
|
RoleBadge 20 px high, 7 px side padding, IBM Plex Mono 11 px medium; cards show the email
|
||||||
|
line, then the display name, then the badges line.
|
||||||
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches "Invite user", the modal
|
- [ ] 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
|
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);
|
every dismissible state and is blocked while Sending, focus returns to the opening button);
|
||||||
@@ -106,6 +137,8 @@ capture of every non-skipped state must be within 10 %, for both viewports and b
|
|||||||
- **Real invitation email** — needs real or sandboxed SMTP (`SMTP_HOST` and the related env
|
- **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
|
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.
|
link will not resolve in the SPA, because the accept screen is out of scope.
|
||||||
|
- **Admin session** — sign in once as an admin against `task dev` and confirm there is no
|
||||||
|
"Invite user" button and no "Only you" hint.
|
||||||
- **Toast placement and stacking on a live app** — bottom right on desktop and above the pinned
|
- **Toast placement and stacking on a live app** — bottom right on desktop and above the pinned
|
||||||
button on mobile.
|
button on mobile.
|
||||||
- **"Today" and the elevated row on a live app** — confirm the new row and the "Today" label
|
- **"Today" and the elevated row on a live app** — confirm the new row and the "Today" label
|
||||||
|
|||||||
@@ -48,9 +48,7 @@ function formatDetailDate(iso: string, relative = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
|
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
|
||||||
const updatedLabel = computed(() =>
|
const updatedLabel = computed(() => formatDetailDate(props.article.updated_at, true))
|
||||||
formatDetailDate(props.article.updated_at, true),
|
|
||||||
)
|
|
||||||
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
|
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
|
||||||
const imagesCount = computed(() => props.article.images.length)
|
const imagesCount = computed(() => props.article.images.length)
|
||||||
const apiNote = computed(() =>
|
const apiNote = computed(() =>
|
||||||
@@ -94,9 +92,7 @@ async function copyUrl() {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-base font-semibold text-highlighted">
|
<h2 class="text-base font-semibold text-highlighted">Details</h2>
|
||||||
Details
|
|
||||||
</h2>
|
|
||||||
</template>
|
</template>
|
||||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
|
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
|
||||||
<dt class="text-muted">Created</dt>
|
<dt class="text-muted">Created</dt>
|
||||||
@@ -110,9 +106,7 @@ async function copyUrl() {
|
|||||||
</dl>
|
</dl>
|
||||||
<div class="flex flex-col gap-1.5 text-sm">
|
<div class="flex flex-col gap-1.5 text-sm">
|
||||||
<p class="text-muted">Public API URL</p>
|
<p class="text-muted">Public API URL</p>
|
||||||
<div
|
<div class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs">
|
||||||
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
|
|
||||||
>
|
|
||||||
<span class="flex-1">{{ publicUrl }}</span>
|
<span class="flex-1">{{ publicUrl }}</span>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-lucide-copy"
|
icon="i-lucide-copy"
|
||||||
@@ -126,20 +120,13 @@ async function copyUrl() {
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex flex-col gap-1 border-t border-default pt-3 text-sm">
|
||||||
class="flex flex-col gap-1 border-t border-default pt-3 text-sm"
|
|
||||||
>
|
|
||||||
<p class="text-muted">Title</p>
|
<p class="text-muted">Title</p>
|
||||||
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p>
|
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<USlideover
|
<USlideover v-model:open="open" side="right" title="Details" class="lg:hidden">
|
||||||
v-model:open="open"
|
|
||||||
side="right"
|
|
||||||
title="Details"
|
|
||||||
class="lg:hidden"
|
|
||||||
>
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col gap-4 text-sm">
|
<div class="flex flex-col gap-4 text-sm">
|
||||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
|
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
|
||||||
@@ -172,9 +159,7 @@ async function copyUrl() {
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex flex-col gap-1 border-t border-default pt-3">
|
||||||
class="flex flex-col gap-1 border-t border-default pt-3"
|
|
||||||
>
|
|
||||||
<p class="text-muted">Title</p>
|
<p class="text-muted">Title</p>
|
||||||
<p class="text-xs/[18px] text-toned">
|
<p class="text-xs/[18px] text-toned">
|
||||||
{{ TITLE_NOTE }}
|
{{ TITLE_NOTE }}
|
||||||
|
|||||||
@@ -33,26 +33,18 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
|
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
|
||||||
const controlClass = computed(() =>
|
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
|
||||||
belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base',
|
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
|
||||||
)
|
|
||||||
const inputClass = computed(() =>
|
|
||||||
belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base',
|
|
||||||
)
|
|
||||||
|
|
||||||
const mainClass = computed(() => [
|
const mainClass = computed(() => [
|
||||||
'flex min-h-screen flex-col items-center gap-5',
|
'flex min-h-screen flex-col items-center gap-5',
|
||||||
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
|
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
|
||||||
])
|
])
|
||||||
|
|
||||||
const cardClass = computed(() =>
|
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
|
||||||
belowSm.value ? 'w-full' : 'w-full max-w-[400px]',
|
|
||||||
)
|
|
||||||
|
|
||||||
const cardUi = computed(() => ({
|
const cardUi = computed(() => ({
|
||||||
root: belowSm.value
|
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
|
||||||
? 'rounded-none ring-0'
|
|
||||||
: 'rounded-none ring-0 border border-default',
|
|
||||||
container: belowSm.value
|
container: belowSm.value
|
||||||
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
|
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
|
||||||
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
|
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
|
||||||
|
|||||||
@@ -25,12 +25,8 @@ const error = ref('')
|
|||||||
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
||||||
let settled = false
|
let settled = false
|
||||||
|
|
||||||
const confirmColor = computed(() =>
|
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
|
||||||
props.tone === 'danger' ? 'error' : 'primary',
|
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
|
||||||
)
|
|
||||||
const confirmText = computed(() =>
|
|
||||||
pending.value ? props.pendingLabel : props.confirmLabel,
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -62,10 +58,7 @@ async function onConfirm() {
|
|||||||
await props.action()
|
await props.action()
|
||||||
settle(true)
|
settle(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value =
|
error.value = err instanceof Error ? err.message : 'Something went wrong. Try again.'
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: 'Something went wrong. Try again.'
|
|
||||||
} finally {
|
} finally {
|
||||||
pending.value = false
|
pending.value = false
|
||||||
}
|
}
|
||||||
@@ -95,8 +88,7 @@ function onCancel() {
|
|||||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 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',
|
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 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: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
|
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
|
||||||
title: 'text-lg max-sm:text-[1.0625rem]/6',
|
title: 'text-lg max-sm:text-[1.0625rem]/6',
|
||||||
description:
|
description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
|
||||||
'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
|
|
||||||
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
|
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
|
||||||
footer: 'p-6 sm:px-6 max-sm:p-5',
|
footer: 'p-6 sm:px-6 max-sm:p-5',
|
||||||
}"
|
}"
|
||||||
@@ -112,9 +104,7 @@ function onCancel() {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div
|
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end"
|
|
||||||
>
|
|
||||||
<UButton
|
<UButton
|
||||||
ref="cancelButton"
|
ref="cancelButton"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|||||||
@@ -44,15 +44,8 @@ const items = computed(() =>
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ul class="flex flex-col gap-1.5">
|
<ul class="flex flex-col gap-1.5">
|
||||||
<li
|
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
|
||||||
v-for="item in items"
|
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
|
||||||
:key="item.label"
|
|
||||||
class="flex items-center gap-2 text-xs/[18px]"
|
|
||||||
>
|
|
||||||
<UIcon
|
|
||||||
:name="item.icon"
|
|
||||||
:class="['size-3.5 shrink-0', item.iconClass]"
|
|
||||||
/>
|
|
||||||
<span :class="item.textClass">{{ item.label }}</span>
|
<span :class="item.textClass">{{ item.label }}</span>
|
||||||
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
|
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
role: 'owner' | 'admin'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const roleClass = computed(() =>
|
||||||
|
props.role === 'owner'
|
||||||
|
? 'border border-primary bg-primary text-(--ui-bg)'
|
||||||
|
: 'border border-accented bg-transparent text-toned',
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UBadge
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
:class="roleClass"
|
||||||
|
:ui="{ base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium' }"
|
||||||
|
>
|
||||||
|
{{ role }}
|
||||||
|
</UBadge>
|
||||||
|
</template>
|
||||||
@@ -76,10 +76,7 @@ const savedText = computed(() => {
|
|||||||
<span>Unsaved changes</span>
|
<span>Unsaved changes</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="state === 'saving'">
|
<template v-else-if="state === 'saving'">
|
||||||
<UIcon
|
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
||||||
name="i-lucide-loader-circle"
|
|
||||||
class="size-3.5 animate-spin"
|
|
||||||
/>
|
|
||||||
<span>Saving…</span>
|
<span>Saving…</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="state === 'error'">
|
<template v-else-if="state === 'error'">
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
||||||
import UserNameCell from '@/components/UserNameCell.vue'
|
import RoleBadge from '@/components/RoleBadge.vue'
|
||||||
|
import UserEmailCell from '@/components/UserEmailCell.vue'
|
||||||
import { formatCreatedDate } from '@/lib/relativeDate'
|
import { formatCreatedDate } from '@/lib/relativeDate'
|
||||||
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
|
import {
|
||||||
|
USER_SKELETON_ROWS,
|
||||||
|
userDisplayName,
|
||||||
|
userRole,
|
||||||
|
userRowKey,
|
||||||
|
type UserListItem,
|
||||||
|
} from '@/composables/useUsers'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
items: UserListItem[]
|
items: UserListItem[]
|
||||||
@@ -36,12 +43,16 @@ function rowKey(item: UserListItem) {
|
|||||||
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
|
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 }"
|
:class="{ 'bg-elevated': rowKey(item) === highlightedKey }"
|
||||||
>
|
>
|
||||||
<UserNameCell
|
<UserEmailCell
|
||||||
:item="item"
|
:email="item.email"
|
||||||
:is-you="item.kind === 'user' && item.id === sessionUserId"
|
:is-you="item.kind === 'user' && item.id === sessionUserId"
|
||||||
large
|
large
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-2.5 text-xs text-muted">
|
<span v-if="userDisplayName(item)" class="text-toned">{{
|
||||||
|
userDisplayName(item)
|
||||||
|
}}</span>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-muted">
|
||||||
|
<RoleBadge :role="userRole(item)" />
|
||||||
<InviteStatusBadge :status="item.status" size="sm" />
|
<InviteStatusBadge :status="item.status" size="sm" />
|
||||||
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
|
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
email: string
|
||||||
|
isYou: boolean
|
||||||
|
large?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="truncate font-medium text-highlighted"
|
||||||
|
:class="large ? 'text-[0.9375rem]/[21px]' : ''"
|
||||||
|
>{{ email }}</span
|
||||||
|
>
|
||||||
|
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted"
|
||||||
|
>you</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
||||||
import UserNameCell from '@/components/UserNameCell.vue'
|
import RoleBadge from '@/components/RoleBadge.vue'
|
||||||
|
import UserEmailCell from '@/components/UserEmailCell.vue'
|
||||||
import { formatCreatedDate } from '@/lib/relativeDate'
|
import { formatCreatedDate } from '@/lib/relativeDate'
|
||||||
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
|
import {
|
||||||
|
USER_SKELETON_ROWS,
|
||||||
|
userDisplayName,
|
||||||
|
userRole,
|
||||||
|
userRowKey,
|
||||||
|
type UserListItem,
|
||||||
|
} from '@/composables/useUsers'
|
||||||
|
|
||||||
const ROW_GRID = 'grid grid-cols-[minmax(0,1fr)_160px_160px] items-center px-3'
|
const ROW_GRID =
|
||||||
|
'grid grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_84px_120px_110px_150px] items-center gap-3 px-3'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
items: UserListItem[]
|
items: UserListItem[]
|
||||||
@@ -21,12 +29,15 @@ function rowKey(item: UserListItem) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
|
<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">
|
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
|
||||||
<span>Username</span>
|
<span>Email</span>
|
||||||
|
<span>Display name</span>
|
||||||
|
<span>Role</span>
|
||||||
<span>Invite status</span>
|
<span>Invite status</span>
|
||||||
<span class="flex items-center gap-1 text-highlighted">
|
<span class="flex items-center gap-1 text-highlighted">
|
||||||
Created
|
Created
|
||||||
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
|
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
|
||||||
</span>
|
</span>
|
||||||
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
<span role="status" class="sr-only">Loading users</span>
|
<span role="status" class="sr-only">Loading users</span>
|
||||||
@@ -34,9 +45,11 @@ function rowKey(item: UserListItem) {
|
|||||||
v-for="n in USER_SKELETON_ROWS"
|
v-for="n in USER_SKELETON_ROWS"
|
||||||
:key="n"
|
:key="n"
|
||||||
:class="ROW_GRID"
|
:class="ROW_GRID"
|
||||||
class="h-11 border-t border-default"
|
class="h-12 border-t border-default"
|
||||||
>
|
>
|
||||||
<USkeleton class="h-3 w-[30%] rounded-none bg-accented" />
|
<USkeleton class="h-3 w-[60%] rounded-none bg-accented" />
|
||||||
|
<USkeleton class="h-3 w-[50%] rounded-none bg-accented" />
|
||||||
|
<USkeleton class="h-5 w-13 rounded-none bg-accented" />
|
||||||
<USkeleton class="h-5 w-19 rounded-none bg-accented" />
|
<USkeleton class="h-5 w-19 rounded-none bg-accented" />
|
||||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
||||||
</div>
|
</div>
|
||||||
@@ -47,15 +60,21 @@ function rowKey(item: UserListItem) {
|
|||||||
:key="rowKey(item)"
|
:key="rowKey(item)"
|
||||||
data-testid="user-row"
|
data-testid="user-row"
|
||||||
:class="[ROW_GRID, { 'bg-elevated': rowKey(item) === highlightedKey }]"
|
:class="[ROW_GRID, { 'bg-elevated': rowKey(item) === highlightedKey }]"
|
||||||
class="h-11 border-t border-default"
|
class="h-12 border-t border-default"
|
||||||
>
|
>
|
||||||
<UserNameCell
|
<UserEmailCell
|
||||||
:item="item"
|
:email="item.email"
|
||||||
:is-you="item.kind === 'user' && item.id === sessionUserId"
|
:is-you="item.kind === 'user' && item.id === sessionUserId"
|
||||||
class="pr-3"
|
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
class="truncate"
|
||||||
|
:class="userDisplayName(item) ? 'text-highlighted' : 'text-muted'"
|
||||||
|
>{{ userDisplayName(item) || '—' }}</span
|
||||||
|
>
|
||||||
|
<span><RoleBadge :role="userRole(item)" /></span>
|
||||||
<span><InviteStatusBadge :status="item.status" /></span>
|
<span><InviteStatusBadge :status="item.status" /></span>
|
||||||
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
|
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
|
||||||
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { userRole, type UserListItem } from '@/composables/useUsers'
|
||||||
|
|
||||||
|
describe('userRole', () => {
|
||||||
|
it.each([['owner' as const], ['admin' as const]])('passes a user role %s through', (role) => {
|
||||||
|
const item: UserListItem = {
|
||||||
|
kind: 'user',
|
||||||
|
id: 1,
|
||||||
|
username: 'u',
|
||||||
|
display_name: '',
|
||||||
|
email: 'u@renew.example',
|
||||||
|
role,
|
||||||
|
status: 'accepted',
|
||||||
|
created_at: '2026-03-12T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(userRole(item)).toBe(role)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([['pending' as const], ['expired' as const]])(
|
||||||
|
'returns admin for a %s invite',
|
||||||
|
(status) => {
|
||||||
|
const item: UserListItem = {
|
||||||
|
kind: 'invite',
|
||||||
|
id: 2,
|
||||||
|
email: 'i@renew.example',
|
||||||
|
status,
|
||||||
|
created_at: '2026-03-12T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(userRole(item)).toBe('admin')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -21,12 +21,15 @@ export class NetworkError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserRole = 'owner' | 'admin'
|
||||||
|
|
||||||
export interface MeResult {
|
export interface MeResult {
|
||||||
id?: number
|
id?: number
|
||||||
verified: boolean
|
verified: boolean
|
||||||
username?: string
|
username?: string
|
||||||
email?: string
|
email?: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
role?: UserRole
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OnUnauthorized = 'redirect' | 'passthrough'
|
export type OnUnauthorized = 'redirect' | 'passthrough'
|
||||||
@@ -135,6 +138,7 @@ export function useApi() {
|
|||||||
username?: string
|
username?: string
|
||||||
email?: string
|
email?: string
|
||||||
display_name?: string | null
|
display_name?: string | null
|
||||||
|
role?: string
|
||||||
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
|
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
|
||||||
return {
|
return {
|
||||||
id: body.id,
|
id: body.id,
|
||||||
@@ -142,6 +146,7 @@ export function useApi() {
|
|||||||
username: body.username,
|
username: body.username,
|
||||||
email: body.email,
|
email: body.email,
|
||||||
displayName: body.display_name ?? '',
|
displayName: body.display_name ?? '',
|
||||||
|
role: body.role as UserRole | undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type UserListItem =
|
|||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
display_name: string
|
display_name: string
|
||||||
|
email: string
|
||||||
|
role: 'owner' | 'admin'
|
||||||
status: 'accepted'
|
status: 'accepted'
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -13,6 +15,7 @@ export type UserListItem =
|
|||||||
kind: 'invite'
|
kind: 'invite'
|
||||||
id: number
|
id: number
|
||||||
email: string
|
email: string
|
||||||
|
display_name?: string
|
||||||
status: 'pending' | 'expired'
|
status: 'pending' | 'expired'
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -23,6 +26,14 @@ export function userRowKey(kind: UserListItem['kind'], id: number) {
|
|||||||
return `${kind}-${id}`
|
return `${kind}-${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function userDisplayName(item: UserListItem) {
|
||||||
|
return item.display_name ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userRole(item: UserListItem): 'owner' | 'admin' {
|
||||||
|
return item.kind === 'user' ? item.role : 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreatedInvite {
|
export interface CreatedInvite {
|
||||||
id: number
|
id: number
|
||||||
kind: 'invite'
|
kind: 'invite'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { ApiError, useApi } from '@/composables/useApi'
|
import { ApiError, useApi, type UserRole } from '@/composables/useApi'
|
||||||
|
|
||||||
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
|
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ export interface Session {
|
|||||||
username?: string
|
username?: string
|
||||||
email?: string
|
email?: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
role?: UserRole
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSessionStore = defineStore('session', () => {
|
export const useSessionStore = defineStore('session', () => {
|
||||||
|
|||||||
@@ -28,10 +28,13 @@ let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
let opener: HTMLElement | null = null
|
let opener: HTMLElement | null = null
|
||||||
|
|
||||||
const sessionUserId = computed(() => session.session?.id)
|
const sessionUserId = computed(() => session.session?.id)
|
||||||
|
const canInvite = computed(() => session.session?.role === 'owner')
|
||||||
const showList = computed(
|
const showList = computed(
|
||||||
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
|
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
|
||||||
)
|
)
|
||||||
const showOnlyYouHint = computed(() => loadState.value === 'ready' && items.value.length === 1)
|
const showOnlyYouHint = computed(
|
||||||
|
() => loadState.value === 'ready' && items.value.length === 1 && canInvite.value,
|
||||||
|
)
|
||||||
|
|
||||||
function clearSkeletonTimer() {
|
function clearSkeletonTimer() {
|
||||||
if (skeletonTimer !== null) {
|
if (skeletonTimer !== null) {
|
||||||
@@ -84,6 +87,7 @@ function onInvited(invite: CreatedInvite) {
|
|||||||
kind: 'invite',
|
kind: 'invite',
|
||||||
id: invite.id,
|
id: invite.id,
|
||||||
email: invite.email,
|
email: invite.email,
|
||||||
|
display_name: '',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
created_at: invite.created_at,
|
created_at: invite.created_at,
|
||||||
}
|
}
|
||||||
@@ -116,7 +120,7 @@ onBeforeUnmount(clearSkeletonTimer)
|
|||||||
<p class="text-muted">Everyone who can sign in and work on your articles.</p>
|
<p class="text-muted">Everyone who can sign in and work on your articles.</p>
|
||||||
</div>
|
</div>
|
||||||
<UButton
|
<UButton
|
||||||
v-if="isDesktop"
|
v-if="isDesktop && canInvite"
|
||||||
icon="i-lucide-user-plus"
|
icon="i-lucide-user-plus"
|
||||||
class="h-8 px-3"
|
class="h-8 px-3"
|
||||||
@click="openInviteModal"
|
@click="openInviteModal"
|
||||||
@@ -174,7 +178,7 @@ onBeforeUnmount(clearSkeletonTimer)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
v-if="!isDesktop"
|
v-if="!isDesktop && canInvite"
|
||||||
v-show="!inviteModalOpen"
|
v-show="!inviteModalOpen"
|
||||||
icon="i-lucide-user-plus"
|
icon="i-lucide-user-plus"
|
||||||
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover)"
|
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover)"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function defaultFixture(): UserListItem[] {
|
|||||||
kind: 'invite',
|
kind: 'invite',
|
||||||
id: 1,
|
id: 1,
|
||||||
email: 'p.huber@gmail.com',
|
email: 'p.huber@gmail.com',
|
||||||
|
display_name: 'Paul Huber',
|
||||||
status: 'expired',
|
status: 'expired',
|
||||||
created_at: '2026-08-03T09:00:00Z',
|
created_at: '2026-08-03T09:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -52,6 +53,8 @@ function defaultFixture(): UserListItem[] {
|
|||||||
kind: 'user',
|
kind: 'user',
|
||||||
id: 3,
|
id: 3,
|
||||||
username: 'ines',
|
username: 'ines',
|
||||||
|
email: 'ines.duarte@renew.example',
|
||||||
|
role: 'admin',
|
||||||
display_name: 'Inès Duarte',
|
display_name: 'Inès Duarte',
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
created_at: '2026-06-18T09:00:00Z',
|
created_at: '2026-06-18T09:00:00Z',
|
||||||
@@ -60,6 +63,8 @@ function defaultFixture(): UserListItem[] {
|
|||||||
kind: 'user',
|
kind: 'user',
|
||||||
id: 2,
|
id: 2,
|
||||||
username: 'jonas',
|
username: 'jonas',
|
||||||
|
email: 'jonas@renew.example',
|
||||||
|
role: 'admin',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
created_at: '2026-04-02T09:00:00Z',
|
created_at: '2026-04-02T09:00:00Z',
|
||||||
@@ -68,6 +73,8 @@ function defaultFixture(): UserListItem[] {
|
|||||||
kind: 'user',
|
kind: 'user',
|
||||||
id: 1,
|
id: 1,
|
||||||
username: 'marta',
|
username: 'marta',
|
||||||
|
email: 'marta@renew.example',
|
||||||
|
role: 'owner',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
created_at: '2026-03-12T09:00:00Z',
|
created_at: '2026-03-12T09:00:00Z',
|
||||||
@@ -119,11 +126,21 @@ interface Handlers {
|
|||||||
invite?: () => Promise<unknown>
|
invite?: () => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mountPage({ list, invite }: Handlers = {}, desktop = true) {
|
async function mountPage(
|
||||||
|
{ list, invite }: Handlers = {},
|
||||||
|
desktop = true,
|
||||||
|
role: string | undefined = 'owner',
|
||||||
|
) {
|
||||||
setViewport(desktop)
|
setViewport(desktop)
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
session.session = { verified: true, username: 'marta', id: 1 }
|
session.session = {
|
||||||
|
verified: true,
|
||||||
|
username: 'marta',
|
||||||
|
id: 1,
|
||||||
|
displayName: '',
|
||||||
|
role: role ?? undefined,
|
||||||
|
}
|
||||||
session.status = 'ready'
|
session.status = 'ready'
|
||||||
requestMock = vi.fn((path: string) => {
|
requestMock = vi.fn((path: string) => {
|
||||||
if (path === LIST_PATH) {
|
if (path === LIST_PATH) {
|
||||||
@@ -135,7 +152,12 @@ async function mountPage({ list, invite }: Handlers = {}, desktop = true) {
|
|||||||
throw new Error(`Unexpected request: ${path}`)
|
throw new Error(`Unexpected request: ${path}`)
|
||||||
})
|
})
|
||||||
vi.mocked(useApi).mockReturnValue({
|
vi.mocked(useApi).mockReturnValue({
|
||||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
getMe: vi.fn().mockResolvedValue({
|
||||||
|
verified: true,
|
||||||
|
username: 'marta',
|
||||||
|
id: 1,
|
||||||
|
role: role ?? undefined,
|
||||||
|
}),
|
||||||
request: requestMock,
|
request: requestMock,
|
||||||
})
|
})
|
||||||
await router.push('/users')
|
await router.push('/users')
|
||||||
@@ -257,25 +279,70 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
|||||||
expect(findButton('Invite user')).toBeTruthy()
|
expect(findButton('Invite user')).toBeTruthy()
|
||||||
|
|
||||||
expect(rowTexts()).toEqual([
|
expect(rowTexts()).toEqual([
|
||||||
'sam.oliveira@renew.example Pending 27 Sep 2026',
|
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
|
||||||
'p.huber@gmail.com Expired 3 Aug 2026',
|
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
|
||||||
'Inès Duarte ines Accepted 18 Jun 2026',
|
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
|
||||||
'jonas Accepted 2 Apr 2026',
|
'jonas@renew.example — admin Accepted 2 Apr 2026',
|
||||||
'marta you Accepted 12 Mar 2026',
|
'marta@renew.example you — owner Accepted 12 Mar 2026',
|
||||||
])
|
])
|
||||||
|
expect(rowTexts().join(' ')).not.toMatch(/(ines|jonas|marta)(?!@)/)
|
||||||
expect(rows().filter((row) => /\byou\b/.test(textOf(row)))).toHaveLength(1)
|
expect(rows().filter((row) => /\byou\b/.test(textOf(row)))).toHaveLength(1)
|
||||||
expect(document.body.textContent).toContain('Invite status')
|
const header = document.body.querySelector(
|
||||||
|
'[data-testid="user-row"]',
|
||||||
|
)!.previousElementSibling!
|
||||||
|
expect(textOf(header)).toBe('Email Display name Role Invite status Created')
|
||||||
|
expect(header.children).toHaveLength(6)
|
||||||
|
expect(document.body.querySelectorAll('[data-testid="user-row"]')).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('styles the owner badge filled, the admin badge outlined, dashes muted and the you tag on marta only', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
const badge = (row: Element, role: string) =>
|
||||||
|
Array.from(row.querySelectorAll('span')).findLast(
|
||||||
|
(el) => el.textContent?.trim() === role,
|
||||||
|
)!
|
||||||
|
const [sam, huber, , jonas, marta] = rows()
|
||||||
|
|
||||||
|
expect(badge(marta, 'owner').className).toContain('bg-primary')
|
||||||
|
expect(badge(marta, 'owner').className).not.toContain('bg-transparent')
|
||||||
|
expect(badge(jonas, 'admin').className).toContain('bg-transparent')
|
||||||
|
expect(badge(jonas, 'admin').className).not.toContain('bg-primary')
|
||||||
|
|
||||||
|
expect(badge(jonas, '—').className).toContain('text-muted')
|
||||||
|
expect(badge(sam, '—').className).toContain('text-muted')
|
||||||
|
expect(badge(huber, 'Paul Huber').className).toContain('text-highlighted')
|
||||||
|
expect(badge(huber, 'Paul Huber').className).not.toContain('text-muted')
|
||||||
|
|
||||||
|
expect(badge(marta, 'you')).toBeTruthy()
|
||||||
|
expect(rows().filter((row) => badge(row, 'you'))).toEqual([marta])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('styles the same badges and hides the display-name line when empty on cards below md', async () => {
|
||||||
|
await mountPage({}, false)
|
||||||
|
|
||||||
|
const spans = (row: Element) => Array.from(row.querySelectorAll('span'))
|
||||||
|
const [, huber, , jonas, marta] = rows()
|
||||||
|
const find = (row: Element, text: string) =>
|
||||||
|
spans(row).findLast((el) => el.textContent?.trim() === text)
|
||||||
|
|
||||||
|
expect(find(marta, 'owner')!.className).toContain('bg-primary')
|
||||||
|
expect(find(jonas, 'admin')!.className).toContain('bg-transparent')
|
||||||
|
expect(find(jonas, '—')).toBeUndefined()
|
||||||
|
expect(find(huber, 'Paul Huber')).toBeTruthy()
|
||||||
|
expect(find(marta, 'you')).toBeTruthy()
|
||||||
|
expect(find(jonas, 'you')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the same rows as cards below md', async () => {
|
it('renders the same rows as cards below md', async () => {
|
||||||
await mountPage({}, false)
|
await mountPage({}, false)
|
||||||
|
|
||||||
expect(rowTexts()).toEqual([
|
expect(rowTexts()).toEqual([
|
||||||
'sam.oliveira@renew.example Pending Created 27 Sep 2026',
|
'sam.oliveira@renew.example admin Pending Created 27 Sep 2026',
|
||||||
'p.huber@gmail.com Expired Created 3 Aug 2026',
|
'p.huber@gmail.com Paul Huber admin Expired Created 3 Aug 2026',
|
||||||
'Inès Duarte ines Accepted Created 18 Jun 2026',
|
'ines.duarte@renew.example Inès Duarte admin Accepted Created 18 Jun 2026',
|
||||||
'jonas Accepted Created 2 Apr 2026',
|
'jonas@renew.example admin Accepted Created 2 Apr 2026',
|
||||||
'marta you Accepted Created 12 Mar 2026',
|
'marta@renew.example you owner Accepted Created 12 Mar 2026',
|
||||||
])
|
])
|
||||||
expect(document.body.querySelector('[data-testid="user-row"]')).toBeNull()
|
expect(document.body.querySelector('[data-testid="user-row"]')).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -289,7 +356,7 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Display names in the Users list', () => {
|
describe('Display names in the Users list', () => {
|
||||||
it('shows each display name with its username beside it and the chip with the session name', async () => {
|
it('shows each saved name, a dash for empty ones, the role and the chip with the session name', async () => {
|
||||||
const fixture = defaultFixture().map((item) =>
|
const fixture = defaultFixture().map((item) =>
|
||||||
item.kind === 'user' && item.username === 'marta'
|
item.kind === 'user' && item.username === 'marta'
|
||||||
? { ...item, display_name: 'Marta Alves' }
|
? { ...item, display_name: 'Marta Alves' }
|
||||||
@@ -301,13 +368,14 @@ describe('Display names in the Users list', () => {
|
|||||||
username: 'marta',
|
username: 'marta',
|
||||||
id: 1,
|
id: 1,
|
||||||
displayName: 'Marta Alves',
|
displayName: 'Marta Alves',
|
||||||
|
role: 'owner',
|
||||||
}
|
}
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(rowTexts().slice(2)).toEqual([
|
expect(rowTexts().slice(2)).toEqual([
|
||||||
'Inès Duarte ines Accepted 18 Jun 2026',
|
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
|
||||||
'jonas Accepted 2 Apr 2026',
|
'jonas@renew.example — admin Accepted 2 Apr 2026',
|
||||||
'Marta Alves marta you Accepted 12 Mar 2026',
|
'marta@renew.example you Marta Alves owner Accepted 12 Mar 2026',
|
||||||
])
|
])
|
||||||
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
|
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
|
||||||
expect(footer.textContent).toContain('Marta Alves')
|
expect(footer.textContent).toContain('Marta Alves')
|
||||||
@@ -336,13 +404,31 @@ describe('Loading — skeleton rows after 150 ms', () => {
|
|||||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||||
expect(rows()).toHaveLength(5)
|
expect(rows()).toHaveLength(5)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('draws role and status placeholders on the desktop table skeleton only', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
await mountPage({ list: () => deferred<UserListItem[]>().promise })
|
||||||
|
vi.advanceTimersByTime(150)
|
||||||
|
await flushPromises()
|
||||||
|
const tableBars = document.body.querySelectorAll('[aria-busy="true"] .animate-pulse')
|
||||||
|
expect(tableBars).toHaveLength(25)
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
|
||||||
|
await mountPage({ list: () => deferred<UserListItem[]>().promise }, false)
|
||||||
|
vi.advanceTimersByTime(150)
|
||||||
|
await flushPromises()
|
||||||
|
const cardBars = document.body.querySelectorAll('[aria-busy="true"] .animate-pulse')
|
||||||
|
expect(cardBars).toHaveLength(10)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Only you — nobody has been invited yet', () => {
|
describe('Only you — nobody has been invited yet', () => {
|
||||||
it('shows the one row, the hint and Invite user', async () => {
|
it('shows the one row, the hint and Invite user', async () => {
|
||||||
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) })
|
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) })
|
||||||
|
|
||||||
expect(rowTexts()).toEqual(['marta you Accepted 12 Mar 2026'])
|
expect(rowTexts()).toEqual(['marta@renew.example you — owner Accepted 12 Mar 2026'])
|
||||||
expect(document.body.textContent).toContain(
|
expect(document.body.textContent).toContain(
|
||||||
'Invite someone so they can sign in with their own account instead of sharing yours.',
|
'Invite someone so they can sign in with their own account instead of sharing yours.',
|
||||||
)
|
)
|
||||||
@@ -350,6 +436,36 @@ describe('Only you — nobody has been invited yet', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Non-owners cannot invite', () => {
|
||||||
|
it.each([
|
||||||
|
{ name: 'admin on desktop', role: 'admin', desktop: true },
|
||||||
|
{ name: 'admin below md', role: 'admin', desktop: false },
|
||||||
|
{ name: 'a missing role on desktop', role: null, desktop: true },
|
||||||
|
{ name: 'an unknown role below md', role: 'guest', desktop: false },
|
||||||
|
])('renders the list but no Invite user for $name', async ({ role, desktop }) => {
|
||||||
|
await mountPage({}, desktop, role)
|
||||||
|
|
||||||
|
expect(findButton('Invite user')).toBeUndefined()
|
||||||
|
expect(rows()).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the Only-you hint for an admin', async () => {
|
||||||
|
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }, true, 'admin')
|
||||||
|
|
||||||
|
expect(rows()).toHaveLength(1)
|
||||||
|
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
|
||||||
|
expect(findButton('Invite user')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([true, false])('shows no hint for an admin on marta and jonas alone (desktop %s)', async (desktop) => {
|
||||||
|
await mountPage({ list: () => Promise.resolve(defaultFixture().slice(3)) }, desktop, 'admin')
|
||||||
|
|
||||||
|
expect(rows()).toHaveLength(2)
|
||||||
|
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
|
||||||
|
expect(findButton('Invite user')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Error — a 500 or a network failure replaces the list with an alert and a retry', () => {
|
describe('Error — a 500 or a network failure replaces the list with an alert and a retry', () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
|
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
|
||||||
@@ -480,13 +596,13 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
|
|||||||
expect(listCalls()).toHaveLength(1)
|
expect(listCalls()).toHaveLength(1)
|
||||||
const texts = rowTexts()
|
const texts = rowTexts()
|
||||||
expect(texts).toHaveLength(6)
|
expect(texts).toHaveLength(6)
|
||||||
expect(texts[0]).toContain('lea.martin@renew.example Pending')
|
expect(texts[0]).toContain('lea.martin@renew.example — admin Pending Today')
|
||||||
expect(texts.slice(1)).toEqual([
|
expect(texts.slice(1)).toEqual([
|
||||||
'sam.oliveira@renew.example Pending 27 Sep 2026',
|
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
|
||||||
'p.huber@gmail.com Expired 3 Aug 2026',
|
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
|
||||||
'Inès Duarte ines Accepted 18 Jun 2026',
|
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
|
||||||
'jonas Accepted 2 Apr 2026',
|
'jonas@renew.example — admin Accepted 2 Apr 2026',
|
||||||
'marta you Accepted 12 Mar 2026',
|
'marta@renew.example you — owner Accepted 12 Mar 2026',
|
||||||
])
|
])
|
||||||
expect(rows()[0].className).toContain('bg-elevated')
|
expect(rows()[0].className).toContain('bg-elevated')
|
||||||
expect(rows()[1].className).not.toContain('bg-elevated')
|
expect(rows()[1].className).not.toContain('bg-elevated')
|
||||||
@@ -516,7 +632,7 @@ describe('Invite sent to an address whose invite had expired replaces the old ro
|
|||||||
|
|
||||||
const matching = rowTexts().filter((text) => text.includes('p.huber@gmail.com'))
|
const matching = rowTexts().filter((text) => text.includes('p.huber@gmail.com'))
|
||||||
expect(matching).toHaveLength(1)
|
expect(matching).toHaveLength(1)
|
||||||
expect(rowTexts()[0]).toContain('p.huber@gmail.com Pending')
|
expect(rowTexts()[0]).toContain('p.huber@gmail.com — admin Pending')
|
||||||
expect(rows()).toHaveLength(5)
|
expect(rows()).toHaveLength(5)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -529,8 +645,8 @@ describe('Invite sent into the Only-you state keeps a coherent list', () => {
|
|||||||
|
|
||||||
const texts = rowTexts()
|
const texts = rowTexts()
|
||||||
expect(texts).toHaveLength(2)
|
expect(texts).toHaveLength(2)
|
||||||
expect(texts[0]).toContain('lea.martin@renew.example Pending')
|
expect(texts[0]).toContain('lea.martin@renew.example — admin Pending')
|
||||||
expect(texts[1]).toContain('marta you Accepted')
|
expect(texts[1]).toContain('marta@renew.example you')
|
||||||
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
|
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -603,7 +719,7 @@ describe('Invite: rate limited — a 429 shows a dialog alert and keeps the addr
|
|||||||
await clickButton('Send invite')
|
await clickButton('Send invite')
|
||||||
|
|
||||||
expect(dialog()).toBeNull()
|
expect(dialog()).toBeNull()
|
||||||
expect(rowTexts()[0]).toContain('lea.martin@renew.example Pending')
|
expect(rowTexts()[0]).toContain('lea.martin@renew.example — admin Pending')
|
||||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -644,7 +760,7 @@ describe('Retrying after a failure succeeds', () => {
|
|||||||
expect(inviteCalls()).toHaveLength(2)
|
expect(inviteCalls()).toHaveLength(2)
|
||||||
expect(inviteCalls()[1][1].body).toBe(inviteCalls()[0][1].body)
|
expect(inviteCalls()[1][1].body).toBe(inviteCalls()[0][1].body)
|
||||||
expect(dialog()).toBeNull()
|
expect(dialog()).toBeNull()
|
||||||
expect(rowTexts()[0]).toContain('lea.martin@renew.example Pending')
|
expect(rowTexts()[0]).toContain('lea.martin@renew.example — admin Pending')
|
||||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+23
-1
@@ -158,6 +158,7 @@ const verifiedSession: ApiMock = {
|
|||||||
username: 'marta',
|
username: 'marta',
|
||||||
email: 'marta@renew.example',
|
email: 'marta@renew.example',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
|
role: 'owner',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,6 +584,7 @@ const usersFixture = [
|
|||||||
kind: 'invite',
|
kind: 'invite',
|
||||||
id: 1,
|
id: 1,
|
||||||
email: 'p.huber@gmail.com',
|
email: 'p.huber@gmail.com',
|
||||||
|
display_name: 'Paul Huber',
|
||||||
status: 'expired',
|
status: 'expired',
|
||||||
created_at: '2026-08-03T09:00:00Z',
|
created_at: '2026-08-03T09:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -591,6 +593,8 @@ const usersFixture = [
|
|||||||
id: 3,
|
id: 3,
|
||||||
username: 'ines',
|
username: 'ines',
|
||||||
display_name: 'Inès Duarte',
|
display_name: 'Inès Duarte',
|
||||||
|
email: 'ines.duarte@renew.example',
|
||||||
|
role: 'admin',
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
created_at: '2026-06-18T09:00:00Z',
|
created_at: '2026-06-18T09:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -599,6 +603,8 @@ const usersFixture = [
|
|||||||
id: 2,
|
id: 2,
|
||||||
username: 'jonas',
|
username: 'jonas',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
|
email: 'jonas@renew.example',
|
||||||
|
role: 'admin',
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
created_at: '2026-04-02T09:00:00Z',
|
created_at: '2026-04-02T09:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -607,6 +613,8 @@ const usersFixture = [
|
|||||||
id: 1,
|
id: 1,
|
||||||
username: 'marta',
|
username: 'marta',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
|
email: 'marta@renew.example',
|
||||||
|
role: 'owner',
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
created_at: '2026-03-12T09:00:00Z',
|
created_at: '2026-03-12T09:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -691,6 +699,7 @@ function profileSession(displayName = ''): ApiMock {
|
|||||||
username: 'marta',
|
username: 'marta',
|
||||||
email: 'marta@renew.example',
|
email: 'marta@renew.example',
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
|
role: 'owner',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -825,10 +834,23 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
'GET /v1/auth/me': profileSession(profileDisplayName),
|
'GET /v1/auth/me': profileSession(profileDisplayName),
|
||||||
'GET /v1/user/': { status: 200, body: usersWithDisplayNames },
|
'GET /v1/user/': { status: 200, body: usersWithDisplayNames },
|
||||||
},
|
},
|
||||||
|
exception: {
|
||||||
|
maxPercent: 10.5,
|
||||||
|
reason: 'Row actions (Cancel invite, Renew invite) drawn on the board are out of scope for this slice',
|
||||||
|
approvedBy: 'user',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
Default: { path: '/users', api: usersApi() },
|
Default: {
|
||||||
|
path: '/users',
|
||||||
|
api: usersApi(),
|
||||||
|
exception: {
|
||||||
|
maxPercent: 10.5,
|
||||||
|
reason: 'Row actions (Cancel invite, Renew invite) drawn on the board are out of scope for this slice',
|
||||||
|
approvedBy: 'user',
|
||||||
|
},
|
||||||
|
},
|
||||||
Loading: {
|
Loading: {
|
||||||
path: '/users',
|
path: '/users',
|
||||||
api: usersApi({ 'GET /v1/user/': { status: 200, body: usersFixture, hang: true } }),
|
api: usersApi({ 'GET /v1/user/': { status: 200, body: usersFixture, hang: true } }),
|
||||||
|
|||||||
Reference in new issue
Block a user