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 /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
View File
@@ -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
+6 -21
View File
@@ -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 }}
+4 -12
View File
@@ -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',
+5 -15
View File
@@ -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"
+2 -9
View File
@@ -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>
+24
View File
@@ -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>
+1 -4
View File
@@ -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'">
+16 -5
View File
@@ -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>
+20
View File
@@ -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>
-36
View File
@@ -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>
+29 -10
View File
@@ -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')
},
)
})
+5
View File
@@ -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,
} }
} }
+11
View File
@@ -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'
+2 -1
View File
@@ -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', () => {
+7 -3
View File
@@ -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)"
+146 -30
View File
@@ -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
View File
@@ -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 } }),