# Users - **Route:** `/users` - **API:** GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending) - **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Users" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). One shared workspace: every user sees the same articles and this same list. The list shows email, display name, role, invite status and created date. A Pending invite can be cancelled and an Expired one renewed; accepted accounts have no row actions, so nobody can be removed or promoted from here. Only the owner can invite, cancel and renew (see "Roles"). An invite is **Pending** until its link is used, **Expired** after 3 days, and **Accepted** once the account is created. The list is sorted by created date, newest first. ## Roles There are two roles. They are shown as a badge and are not editable. | Role | Who | Can | |---|---|---| | owner | The account that registered first on the workspace. There is exactly one. | Everything an admin can, plus invite users, cancel invites and renew invites. | | admin | Everyone who joined through an invite. Every invite creates an admin. | Read and write articles, see the Users list, edit their own profile, see Settings. | The owner is the only difference for now. The role cannot be changed, transferred or removed from the UI. **Not drawn by the board** (decide in the spec, cover by Vitest and Go tests only, like the 429 on invite): - What a non-owner sees: the recommendation is to hide "Invite user" (desktop and mobile), hide the row actions, and hide the "Invite someone…" hint on the "Only you" state. - The API answer when a non-owner calls an owner-only endpoint: the recommendation is 403 with a plain-text body, and no state change. - The message if the owner's session sees a 403 anyway (role changed under it): the global 403 handling applies. ## The list | Column | Content | |---|---| | Email | The account's or invite's email address, medium weight, highlighted. The current user's row carries a muted mono "you" tag after it. | | Display name | The display name when set, highlighted; a muted dash `—` when not. An invite has none, so it shows the dash. Your own row follows your saved name. | | Role | A mono 11 px badge, 20 px high: `owner` (filled, `--ui-primary` with `--ui-bg` text) or `admin` (transparent, toned text, `--ui-border-accented` border). An invite shows the role it will create, `admin`. | | Invite status | A badge: Accepted (circle-check, success), Pending (mail, warning), Expired (clock-alert, outline, muted). Shown by icon and word as well as color. | | Created | The date as `27 Sep 2026`, or "Today" for the current day. For an invite it is the day the invite was sent (or last renewed), for a user the day the account was created. | | (actions) | Right-aligned, no header text. Pending row: "Cancel invite". Expired row: "Renew invite". Accepted row: empty. | The username is no longer shown in this list. Desktop is a table: 36 px header, 48 px rows, columns `minmax(0,1.3fr) minmax(0,1fr) 84px 120px 110px 150px` with a 12 px gap. Below md it becomes a card list (68 px minimum row height): the email line (with "you"), the display name below it when there is one, then the role badge, the status badge and "Created {date}" on one line, then the row action, if any, as a 44 px button with 14 px side padding. The page header is a title "Users" with the subtitle "Everyone who can sign in and work on your articles." and a primary "Invite user" button (user-plus icon) on its right. On mobile the button becomes a full-width 48 px button pinned to the bottom of the screen. The owner is always listed, so the list is never empty. ### Row actions | Action | Shown on | Icon | Look | |---|---|---|---| | Cancel invite | Pending | user-x | Outline button, text in `--ui-error` | | Renew invite | Expired | refresh-cw | Outline button, text `--ui-text-highlighted` | Desktop buttons are 28 px high, 12 px medium text, a 13 px icon, 9 px side padding, `--ui-border-accented` border, `--ui-bg` fill. Mobile buttons are 44 px high and 14 px side padding with a 14 px icon. - **Cancel invite** asks first with a danger confirmation dialog, since it cannot be undone from here (the address can be invited again). - **Renew invite** asks nothing, since it only sends an email. While the request runs, that row's button reads "Renewing…" with a loader-circle icon at 50 % opacity and is disabled. Only that row is busy. ## States ### Default **Trigger:** The list loaded, newest first. - Breadcrumbs: Users (mobile title "Users") - Rows: - `sam.oliveira@renew.example`, no display name, admin, Pending, 27 Sep 2026, "Cancel invite" - `p.huber@gmail.com`, display name "Paul Huber", admin, Expired, 3 Aug 2026, "Renew invite" - `ines.duarte@renew.example`, "Inès Duarte", admin, Accepted, 18 Jun 2026 - `jonas@renew.example`, no display name, admin, Accepted, 2 Apr 2026 - `marta@renew.example` (you), no display name, owner, Accepted, 12 Mar 2026 The board gives the Expired invite a display name. A real invite has none, so the SPA renders whatever `display_name` the item carries and the dash when it is empty. ### Loading **Trigger:** The first load, or a retry. Skeletons appear after 150 ms. - Breadcrumbs: Users (mobile title "Users") - Five skeleton rows, one bar per column, plus a role-badge and a status-badge placeholder ### Only you **Trigger:** Nobody has been invited yet. The owner is always listed. - Breadcrumbs: Users (mobile title "Users") - One row: `marta@renew.example` (you), owner, Accepted - Hint under the list: "Invite someone so they can sign in with their own account instead of sharing yours." ### Error **Trigger:** A 500 or network failure on the list request. - Breadcrumbs: Users (mobile title "Users") - Alert (error): **Couldn't load users** "Check your connection and try again." - Action "Try again" (rotate-cw) - No table or cards ### Invite: default **Trigger:** "Invite user" in the page header, or the bottom button on mobile. - Breadcrumbs: Users (mobile title "Users") - Modal: **Invite a user** - text "We'll email them a link to choose a username and password. The link expires after 3 days." - field `Email`: placeholder "name@example.com"; focused - close button - cancel "Cancel" - confirm "Send invite" ### Invite: validation **Trigger:** On submit or blur. Nothing is sent. - Breadcrumbs: Users (mobile title "Users") - Modal: **Invite a user** (same text) - field `Email`: value "sam.oliveira@renew"; **error "Enter a full email address, like name@example.com."** - cancel "Cancel" - confirm "Send invite" ### Invite: sending **Trigger:** Request in flight. The field is disabled and the dialog can't be dismissed. - Breadcrumbs: Users (mobile title "Users") - Modal: **Invite a user** (same text) - field `Email`: value "lea.martin@renew.example"; disabled - cancel "Cancel" - confirm "Sending…", loading, not dismissible ### Invite: already a user (409) **Trigger:** The email belongs to an existing user. For a live pending invite the message reads "There's already a pending invite for this address." An expired invite can be sent again. - Breadcrumbs: Users (mobile title "Users") - Modal: **Invite a user** (same text) - field `Email`: value "jonas@renew.example"; **error "This address already has an account."** - cancel "Cancel" - confirm "Send invite" ### Invite: failed **Trigger:** 500, network failure, or the email couldn't be sent. The address stays in the field. - Breadcrumbs: Users (mobile title "Users") - Modal: **Invite a user** (same text) - field `Email`: value "lea.martin@renew.example" - Alert: "Couldn't send the invite. Try again in a moment." - cancel "Cancel" - confirm "Send invite" ### Invite sent **Trigger:** 201: the dialog closes, the new invite appears at the top as Pending (highlighted row), and a toast confirms. - Breadcrumbs: Users (mobile title "Users") - New first row: `lea.martin@renew.example`, no display name, admin, Pending, created "Today", row background `--ui-bg-elevated`, with "Cancel invite" - Toast (success, circle-check): **Invite sent** "lea.martin@renew.example has 3 days to accept." The "Today" label makes this state clock-sensitive, so `task visual:check` skips it. ### Cancel invite: confirm **Trigger:** "Cancel invite" on a Pending row. - Breadcrumbs: Users (mobile title "Users") - Modal (danger): **Cancel this invite?** - text "The link sent to sam.oliveira@renew.example will stop working. You can invite the address again later." - cancel "Keep invite" - confirm "Cancel invite" ### Cancel invite: sending **Trigger:** Request in flight. Both buttons are disabled and the dialog can't be dismissed. - Breadcrumbs: Users (mobile title "Users") - Modal (danger): **Cancel this invite?** (same text) - cancel "Keep invite", disabled - confirm "Cancelling…", loading ### Cancel invite: failed **Trigger:** 500 or network failure. The dialog stays open. - Breadcrumbs: Users (mobile title "Users") - Modal (danger): **Cancel this invite?** (same text) - notice "Couldn't cancel the invite. Try again in a moment." - cancel "Keep invite" - confirm "Cancel invite" ### Cancel invite: done **Trigger:** 204: the row disappears and a toast confirms. - Breadcrumbs: Users (mobile title "Users") - The Pending row `sam.oliveira@renew.example` is gone; the other four remain - Toast (success, circle-check): **Invite cancelled** "sam.oliveira@renew.example can no longer use the link." ### Renew invite: sending **Trigger:** "Renew invite" on an Expired row. No confirmation, since it only sends an email. The button shows progress and is disabled. - Breadcrumbs: Users (mobile title "Users") - The Expired row's button reads "Renewing…" with a loader-circle icon, 50 % opacity, disabled ### Renew invite: done **Trigger:** 200: the row becomes Pending with today's date, moves to the top, and the old link stops working. - Breadcrumbs: Users (mobile title "Users") - First row: `p.huber@gmail.com` Pending, created "Today", row background `--ui-bg-elevated`, now with "Cancel invite" - Toast (success, circle-check): **Invite renewed** "p.huber@gmail.com has 3 days to accept the new link." The "Today" label makes this state clock-sensitive, so `task visual:check` skips it. ### Renew invite: failed **Trigger:** 500 or network failure. The row stays Expired and the button can be pressed again. - Breadcrumbs: Users (mobile title "Users") - Toast (error, circle-x): **Couldn't renew the invite** "Try again in a moment." ## Notes for implementation - Accepted users are now listed by email, display name and role. This reverses the earlier rule that a user is never listed with an email. - How display names look in this list is the state "Display names in the Users list" of [profile.md](profile.md). The board draws it in the Profile group, and `task visual:check` finds states by group. - The board's API line marks both new endpoints "(pending)", meaning "not built yet". Cancel applies to a Pending invite. Renew is drawn on an Expired one; the recommendation is that the API also accepts a Pending one (it resets the 3 days and replaces the link) and refuses an Accepted one with 409. The SPA only offers Renew on Expired rows. - Renewing replaces the code and expiry, resets the created date to now, and sends the invitation email again. The old link stops working. The email is specified in [emails.md](../emails.md). - A 404 on cancel or renew (the invite was already used or cancelled elsewhere) is not drawn. Decide it in the spec; the recommendation is to drop the row and toast the outcome. - 429 on the invite request is not drawn. Decide it in the spec before coding. - The email the invite sends is specified in [emails.md](../emails.md). The screen the link opens is in [accept-invite.md](accept-invite.md). - The role rules and what is not drawn are in "Roles" above.