Files
RenewCMS/docs/design/screens/users.md
T

12 KiB

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. 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.
  • 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. The screen the link opens is in accept-invite.md.
  • The role rules and what is not drawn are in "Roles" above.