diff --git a/features/users_screen.feature b/features/users_screen.feature index dea9b98..ad3f6b7 100644 --- a/features/users_screen.feature +++ b/features/users_screen.feature @@ -13,10 +13,10 @@ Feature: Users screen and InviteUserModal /v1/user/invite; both endpoints already exist (features/invitations.feature) and this slice wires the SPA to them unchanged, with no Go change And the list items keep the API shape: user items are kind "user" (id, username, display_name, - email, role, status "accepted", created_at) or kind "invite" (id, email, status "pending" or - "expired", created_at); SPA display of role, owner-only actions and the redesigned columns is - a later SPA slice; this amendment covers the API shape only; the list is used in the order the API returns it (created date, newest first), - and rows are keyed by kind and id, because a user and an invite can share a numeric id + email, role "owner" or "admin", status "accepted", created_at) or kind "invite" (id, email, + status "pending" or "expired", created_at); the list is used in the order the API returns it + (created date, newest first), and rows are keyed by kind and id, because a user and an + invite can share a numeric id And DashboardShell's navigation gains "Users" (icon users, linking to /users) between "Articles" and "Integration", in the sidebar and in the mobile drawer, so it lists Home, Articles, Users, Integration, Settings in that order; "Users" has aria-current="page" on /users; the @@ -24,32 +24,51 @@ Feature: Users screen and InviteUserModal dashboard_shell_branding.feature are amended accordingly And the PageHeader shows breadcrumb "Users" (mobile title "Users") And the page header is the title "Users" with the subtitle "Everyone who can sign in and work - on your articles." and, on desktop (md and up), a primary "Invite user" button (icon - user-plus) at the right of the header - And below md, "Invite user" is instead a full-width 48 px button (icon user-plus) pinned 16 px - from the left, right and bottom of the screen with the popover shadow, and it is hidden - while InviteUserModal is open - And the list is rendered as UsersTable (desktop, md and up: columns Username, Invite status, - Created, grid minmax(0,1fr) 160px 160px, 36 px header, 44 px rows, an arrow-down icon on the - Created header) or UserCardList (below md: 68 px minimum row height, the name line, then the - InviteStatusBadge and "Created {date}"), both fed by the same items array; rows have no - actions and are not clickable - And a user row's name cell shows the display name first (medium weight, highlighted) with the - username beside it in muted text when display_name is not empty, otherwise the username - alone (medium weight, highlighted); an invite row shows the email address in regular weight, - toned - And the row whose kind is "user" and whose id equals the session user's id (from GET - /v1/auth/me) carries a muted mono "you" tag after the name + on your articles." and, on desktop (md and up) for the owner, a primary "Invite user" button + (icon user-plus) at the right of the header + And below md, for the owner, "Invite user" is instead a full-width 48 px button (icon + user-plus) pinned 16 px from the left, right and bottom of the screen with the popover + shadow, and it is hidden while InviteUserModal is open + And "Invite user" (desktop header button and the mobile pinned button) is shown only when the + session user's role from GET /v1/auth/me is "owner"; for "admin", or any other or absent + value, neither button is rendered, the pinned button's bottom space is not reserved, and + the "Only you" hint is hidden; this is not drawn in docs/design, so it has no visual state + and is covered by Vitest only + And the list is rendered as UsersTable (desktop, md and up) or UserCardList (below md), both + fed by the same items array; rows have no actions and are not clickable + And UsersTable has the columns Email, Display name, Role, Invite status and Created, plus an + unlabeled, empty actions column kept so the grid matches the board: grid + minmax(0,1.3fr) minmax(0,1fr) 84px 120px 110px 150px with a 12 px gap, a 36 px header on + --ui-bg-elevated, 48 px rows with a top border, and an arrow-down icon on the Created header + And UserCardList (below md) has a 68 px minimum row height: the email line (with the "you" + tag), the display name below it only when there is one, then the RoleBadge, the + InviteStatusBadge and "Created {date}" on one line + And the username is not shown anywhere in the list + And the Email cell shows the account's or invite's email address, medium weight, highlighted; + the row whose kind is "user" and whose id equals the session user's id (from GET + /v1/auth/me) carries a muted mono "you" tag after it + And the Display name cell shows the item's display_name, highlighted, or a muted dash "—" when + it is empty or absent; an invite item shows the dash unless it carries a display_name; the + current user's row shows their saved name + And RoleBadge is a 20 px badge, IBM Plex Mono 11 px medium, 7 px side padding, no icon, showing + the word: owner (filled --ui-primary, --ui-bg text, --ui-primary border) or admin + (transparent, --ui-text-toned text, --ui-border-accented border); a user row shows its + role, and an invite row shows "admin", the role it will create And InviteStatusBadge shows icon and word, never color alone: Accepted (circle-check, success soft), Pending (mail, warning soft), Expired (clock-alert, transparent, muted, with an accented border) And the Created column shows the date as "27 Sep 2026", or "Today" when it is the current day; the fixture below uses absolute dates only - And skeleton rows (5) show only after 150 ms of loading, so a fast response never flashes them + And skeleton rows (5) show only after 150 ms of loading, so a fast response never flashes them, + one bar per column plus a RoleBadge placeholder and an InviteStatusBadge placeholder in the + table (md and up); the below-md card skeleton keeps the board's two-bar shape And the Default fixture holds 5 items, newest first: invite id 2 "sam.oliveira@renew.example" - pending 27 Sep 2026; invite id 1 "p.huber@gmail.com" expired 3 Aug 2026; user id 3 "ines" - with display_name "Inès Duarte" accepted 18 Jun 2026; user id 2 "jonas" accepted 2 Apr 2026; - user id 1 "marta" accepted 12 Mar 2026; the session user is id 1 "marta" + pending 27 Sep 2026; invite id 1 "p.huber@gmail.com" with display_name "Paul Huber" expired + 3 Aug 2026; user id 3 "ines" email "ines.duarte@renew.example" display_name "Inès Duarte" + role admin accepted 18 Jun 2026; user id 2 "jonas" email "jonas@renew.example" no display + name, role admin, accepted 2 Apr 2026; user id 1 "marta" email "marta@renew.example" no + display name, role owner, accepted 12 Mar 2026; the session user is id 1 "marta", role + "owner" And InviteUserModal is a UModal opened from either "Invite user" button; every time it opens it starts in its default state: title "Invite a user", text "We'll email them a link to choose a username and password. The link expires after 3 days.", an empty, focused Email field @@ -87,23 +106,32 @@ Feature: Users screen and InviteUserModal form is not disabled by an in-flight request; focus returns to the button that opened it And a 401 and a 403 (unverified) on either endpoint are handled entirely by useApi's global handling (features/api_client_global_error_handling.feature) - And this slice does not build the accept-invite screen (/invite/accept), does not add row - actions (remove, promote, revoke or resend), and does not change how display names look in - the Profile group's "Display names in the Users list" state + And this slice does not build the accept-invite screen (/invite/accept) and does not add row + actions (Cancel invite, Renew invite); the "Display names in the Users list" state of the + Profile group is drawn by this same list - Scenario: Default — the list loads newest first, with display name, "you" tag and status badges - Given GET /v1/user/ resolves 200 with the 5-item Default fixture and the session user is "marta" + Scenario: Default — the list loads newest first with email, display name, role, status and created + Given GET /v1/user/ resolves 200 with the 5-item Default fixture and the session user is + "marta" with role "owner" When the user navigates to /users Then the breadcrumb reads "Users" (mobile title "Users") And the header shows the title "Users", the subtitle and, on desktop, "Invite user" - And the rows appear in exactly the fixture's order - And the first row shows "sam.oliveira@renew.example" (toned), a Pending badge and "27 Sep 2026" - And the second row shows "p.huber@gmail.com", an Expired badge and "3 Aug 2026" - And the third row shows "Inès Duarte" first with "ines" beside it in muted text, an Accepted + And the table header reads Email, Display name, Role, Invite status, Created, and an empty + unlabeled sixth cell + And the rows appear in exactly the fixture's order, each 48 px high + And the first row shows "sam.oliveira@renew.example", "—" (muted), "admin", a Pending badge + and "27 Sep 2026" + And the second row shows "p.huber@gmail.com", "Paul Huber", "admin", an Expired badge and + "3 Aug 2026" + And the third row shows "ines.duarte@renew.example", "Inès Duarte", "admin", an Accepted badge and "18 Jun 2026" - And the fifth row shows "marta" with the "you" tag, an Accepted badge and "12 Mar 2026" - And below md the same rows render as cards, each with the name line, then the badge and - "Created {date}" + And the fourth row shows "jonas@renew.example", "—", "admin", an Accepted badge and "2 Apr 2026" + And the fifth row shows "marta@renew.example" with the "you" tag, "—", "owner" (filled), an + Accepted badge and "12 Mar 2026" + And the username "ines", "jonas" or "marta" appears in no row + And below md the same rows render as cards: the email line, the display name below it only + for "p.huber@gmail.com" and "ines.duarte@renew.example", then the RoleBadge, the status + badge and "Created {date}" on one line Scenario: Navigation — "Users" sits between Articles and Integration Given the user is on /users @@ -115,19 +143,51 @@ Feature: Users screen and InviteUserModal Given GET /v1/user/ has not yet resolved When the user navigates to /users Then nothing is drawn in place of the list for the first 150 ms - And after 150 ms 5 skeleton rows replace the list, and the list container is aria-busy="true" + And after 150 ms 5 skeleton rows replace the list, on desktop each with one bar per column + plus a RoleBadge and an InviteStatusBadge placeholder and below md in the board's two-bar + card shape, and the list container is aria-busy="true" When GET /v1/user/ then resolves 200 with the Default fixture Then the skeleton rows are replaced by the Default state's rows Scenario: Only you — nobody has been invited yet - Given GET /v1/user/ resolves 200 with only user id 1 "marta", accepted 12 Mar 2026, and the - session user is "marta" + Given GET /v1/user/ resolves 200 with only user id 1 "marta" (email "marta@renew.example", + role "owner"), accepted 12 Mar 2026, and the session user is "marta" with role "owner" When the user navigates to /users - Then the list has one row: "marta" with the "you" tag, an Accepted badge and "12 Mar 2026" + Then the list has one row: "marta@renew.example" with the "you" tag, "—", "owner", an + Accepted badge and "12 Mar 2026" And under the list the hint reads "Invite someone so they can sign in with their own account instead of sharing yours." And "Invite user" is still available + Scenario: Display names in the Users list — saved names show, empty ones show a dash + Given GET /v1/auth/me resolves 200 for user id 1 with display_name "Marta Alves" and role "owner" + And GET /v1/user/ resolves 200 with the Default fixture where user id 1 has display_name + "Marta Alves" + When the user navigates to /users + Then the row "marta@renew.example" (you) shows "Marta Alves", "owner" and an Accepted badge + And the row "ines.duarte@renew.example" shows "Inès Duarte", "admin" and an Accepted badge + And the row "jonas@renew.example" shows a muted "—", "admin" and an Accepted badge + And the sidebar chip also shows "Marta Alves" + + Scenario Outline: Non-owners cannot invite — "Invite user" and the hint are not rendered + Given the session user is "" with role "" and GET /v1/user/ resolves 200 + with + When the user navigates to /users at desktop and at a below-md width + Then + And the list still renders normally + And + + Examples: + | username | role | fixture | invite | hint | + | jonas | admin | the Default fixture | no "Invite user" button is rendered, header or pinned | the pinned button's space is not reserved | + | jonas | admin | only user id 1 "marta" and user id 2 "jonas" | no "Invite user" button is rendered, header or pinned | the "Invite someone…" hint is not shown | + | marta | owner | the Default fixture | "Invite user" is rendered in the header (desktop) and pinned (below md) | the list is unchanged | + + Scenario: An unknown or missing role is treated as non-owner + Given GET /v1/auth/me resolves 200 without a role + When the user navigates to /users + Then no "Invite user" button is rendered + Scenario Outline: Error — a 500 or a network failure replaces the list with an alert and a retry Given GET /v1/user/ When the user navigates to /users @@ -185,8 +245,8 @@ Feature: Users screen and InviteUserModal "lea.martin@renew.example", status "pending", created_at now, expires_at 3 days later Then the modal closes and focus returns to "Invite user" And GET /v1/user/ is not called again - And the list has 6 rows, the first being "lea.martin@renew.example" with a Pending badge and - "Today", drawn with the --ui-bg-elevated background + And the list has 6 rows, the first being "lea.martin@renew.example" with "—", "admin", a + Pending badge and "Today", drawn with the --ui-bg-elevated background And the other 5 rows are unchanged and follow it in their previous order And a toast appears: color success, icon i-lucide-circle-check, id "invite-sent-3", title "Invite sent", description "lea.martin@renew.example has 3 days to accept." @@ -200,7 +260,7 @@ Feature: Users screen and InviteUserModal Scenario: Invite sent into the Only-you state keeps a coherent list Given the Only-you state has loaded When an invite for "lea.martin@renew.example" is sent and resolves 201 - Then the list shows the new Pending row above the "marta" row + Then the list shows the new Pending row above the "marta@renew.example" row And the Only-you hint is no longer shown Scenario Outline: Invite: already a user (409) — the field error depends on the response text diff --git a/features/users_screen.qa.md b/features/users_screen.qa.md index 7807f9d..037283b 100644 --- a/features/users_screen.qa.md +++ b/features/users_screen.qa.md @@ -16,20 +16,27 @@ The list items are keyed by `kind` and `id`. ## 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. -Mount UsersPage inside DashboardShell with a resolved verified session (id 1, username "marta") and -a test router at /users. The Default fixture is the Background's 5 items; the invite fixture is +Mount UsersPage inside DashboardShell with a resolved verified session (id 1, username "marta", +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 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`). -1. **Default** — assert breadcrumb "Users", title, subtitle, "Invite user", rows in fixture order, - "Inès Duarte" with "ines" beside it, the "you" tag only on the "marta" row, badge words and - the fixture's absolute dates. +1. **Default** — assert breadcrumb "Users", title, subtitle, "Invite user", the table header + labels (Email, Display name, Role, Invite status, Created), rows in fixture order, per row the + 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, 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 - assert 5 skeleton rows and `aria-busy="true"`; resolve and assert the Default rows. -4. **Only you** — a single "marta" item; assert the row, the hint text and "Invite user". + assert 5 skeleton rows and `aria-busy="true"`, and at desktop that each row has a RoleBadge + 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 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. @@ -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 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 - "Invite sent" and description "lea.martin@renew.example has 3 days to accept." Assert nothing - about the "Today" label. + "Invite sent" and description "lea.martin@renew.example has 3 days to accept." Assert the new + 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 one row for that address, first and Pending. 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. 17. **Cancel discards** — type, click "Cancel"; assert immediate close, no `request`, an empty 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) @@ -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 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 - "Inès Duarte", `jonas`, `marta` with "you"), dates 27 Sep 2026, 3 Aug 2026, 18 Jun 2026, - 2 Apr 2026, 12 Mar 2026; session user `marta`. -- [ ] Loading: 5 skeleton rows; the setup must give skeletons time to show (150 ms) before the capture. -- [ ] Only you: the single `marta` row plus the hint. +- [ ] Fixtures: `usersFixture` in `web/visual/states.ts` follows the board data: invite 2 + `sam.oliveira@renew.example` pending, no name; invite 1 `p.huber@gmail.com` with + `display_name: 'Paul Huber'` expired; user 3 `ines.duarte@renew.example` "Inès Duarte" admin; + user 2 `jonas@renew.example` admin, empty name; user 1 `marta@renew.example` owner, empty + 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. - [ ] Invite: default: modal over the Default list, empty focused Email; the mobile pinned button hidden. - [ ] 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 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. +- [ ] 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 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); @@ -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 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. +- **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 button on mobile. - **"Today" and the elevated row on a live app** — confirm the new row and the "Today" label diff --git a/web/src/components/ArticleDetails.vue b/web/src/components/ArticleDetails.vue index 480a452..0e7b707 100644 --- a/web/src/components/ArticleDetails.vue +++ b/web/src/components/ArticleDetails.vue @@ -48,9 +48,7 @@ function formatDetailDate(iso: string, relative = false) { } const createdLabel = computed(() => formatDetailDate(props.article.created_at)) -const updatedLabel = computed(() => - formatDetailDate(props.article.updated_at, true), -) +const updatedLabel = computed(() => formatDetailDate(props.article.updated_at, true)) const publicUrl = computed(() => `/v1/article/${props.article.id}`) const imagesCount = computed(() => props.article.images.length) const apiNote = computed(() => @@ -94,9 +92,7 @@ async function copyUrl() { }" >
Created
@@ -110,9 +106,7 @@ async function copyUrl() {

Public API URL

-
+
{{ publicUrl }}

{{ apiNote }}

-
+

Title

{{ TITLE_NOTE }}

- +