233 lines
12 KiB
Markdown
233 lines
12 KiB
Markdown
# 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.
|