The API behind the admin’s Members page. It issues invites and
password-reset links; identity owns the tokens
table and redeems them, and mail delivers them when a
backend is configured.
Install
npm install @orthacms/users-server @orthacms/users-adminRegister
// apps/server/src/plugins.ts
import { UsersPlugin } from '@orthacms/users-server';
// after IdentityPlugin, WorkspacesPlugin and (if used) the mail plugin
UsersPlugin();// apps/admin/src/plugins.ts
import { UsersPlugin } from '@orthacms/users-admin';
UsersPlugin();No options and no tables of its own — it reads and writes identity’s.
The admin half draws the Members page, the account menu in the sidebar footer
and, since 0.12.0, an Invite member button in Home’s top bar for anyone
holding users:create.
Since 0.13.0 the Members page has quick filters — Everyone, Admins,
Invited, Disabled — and a Last active column; a row’s menu follows
the member’s status (Change role…, Password reset link, Sign out
everywhere, Suspend… for an active member; Resend invite and Revoke
invite… for an invited one; Reactivate for a suspended one). Invite
people opens a page that takes several addresses at once, one role and the
workspaces, and ends on a link per person when no mail is sent. A member’s page
has four tabs — Overview, Workspaces, Sessions & access and
Activity — plus Preferences on your own; the old …/general, …/roles
and …/sessions addresses redirect. Change role shows what the new role
grants and takes away before you confirm.
Configuration
Users reads no environment variable itself. Whether a link is mailed or returned in the response depends on the mail plugin:
| Setting | Invite / reset response |
|---|---|
MAIL_PROVIDER unset (no mail plugin) | Carries inviteToken / resetToken — hand the link over yourself |
MAIL_PROVIDER=smtp or console | No token field — the message carries the link |
… plus MAIL_REVEAL_LINKS=true | Both: the message is sent and the token is returned (delivery debugging) |
See mail configuration for the rest of the MAIL_* variables.
Routes
Admin routes: session cookie, no X-Workspace-Id (members are global).
| Route | Permission | Does |
|---|---|---|
GET /api/users | users:read | List — search, status, role, filter, page, pageSize (default 10, ≤ 100) |
GET /api/users/roles | users:read | The three assignable roles and the permission keys each grants. Since 0.13.0 |
GET /api/users/:id | users:read | One member |
GET /api/users/:id/sign-in | users:update | { hasPassword, ssoProviders }. Since 0.13.0 |
POST /api/users/invites | users:create | Invite by email with a role |
POST /api/users/invites/batch | users:create and users:read | Invite 1–25 addresses with one role and one set of workspaces, all or nothing. Since 0.13.0 |
POST /api/users/:id/invites/resend | users:create | Rotate a pending invite |
DELETE /api/users/:id/invites | users:delete | Revoke a pending invite |
PATCH /api/users/:id | users:update | { name?, role? } |
POST /api/users/:id/disable · /enable | users:update | Disable (and revoke sessions) or re-enable |
POST /api/users/:id/password-reset | users:update | Mint a reset link for an active member |
GET /api/users/:id/sessions · DELETE …/sessions/:sessionId | users:update | The member’s sessions; revoke one (identity’s routes) |
DELETE /api/users/:id/sessions | users:update | Sign out everywhere — every session but the caller’s own → { revoked }. Since 0.13.0, identity’s |
POST /api/users/:id/reveal-link | users:manage | The link of a message mail has not delivered |
Every role holds users:read; the other keys are the admin’s.
A list response carries counts — { everyone, active, admins, invited, disabled } over the whole directory, which no query parameter narrows — and
each member carries lastActiveAt, the newest use of any of their sessions
(null if they never signed in), derived at read time.
Invite a member
curl -c cookies.txt https://cms.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"…"}'
curl -X POST https://cms.example.com/api/users/invites \
-b cookies.txt \
-H 'Content-Type: application/json' \
-d '{"email":"grace@example.com","role":"contributor",
"name":"Grace Hopper","workspaceIds":["3f7a…"]}' const response = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'grace@example.com',
role: 'contributor',
workspaceIds: [workspaceId]
})
});
const member = await response.json();
// member.inviteToken: present when MAIL_PROVIDER is unset, or MAIL_REVEAL_LINKS=true {
"id": "5e21…",
"email": "grace@example.com",
"name": "Grace Hopper",
"role": { "id": "r-2…", "key": "contributor", "name": "Contributor" },
"status": "pending",
"createdAt": "2026-09-30T09:12:00.000Z",
"isLastAdmin": false,
"workspaces": [{ "id": "3f7a…", "name": "Marketing", "description": null, "color": "#6d28d9" }],
"lastActiveAt": null,
"inviteToken": "8f14e45fceea167a…",
"inviteExpiresAt": "2026-10-07T09:12:00.000Z"
}role:admin,contributororviewer.workspaceIdsis optional; unknown ids are ignored.- The raw token is shown once. List and detail reads never carry it; only its hash is stored.
- Resending rotates the token, so the link the invitee already holds stops working.
POST /api/users/invites/batchtakes{ emails, role, workspaceIds? }and returns{ items }, one invited member per address in the order given. If any address already has an account the whole batch is a409 EMAIL_TAKENwhoseemailsnames every one of them, and nobody is invited. It needsusers:readas well because that answer says who has an account.
The invitee redeems it through identity (no session needed):
curl -X POST https://cms.example.com/api/auth/invite/accept \
-H 'Content-Type: application/json' \
-d '{"token":"8f14e45fceea167a…","password":"…"}'confirmPassword is optional since 0.13.0; when sent it must match.
A reset link is redeemed the same way at POST /api/auth/reset with the same
body shape.
Password reset and reveal-link
curl -X POST https://cms.example.com/api/users/5e21…/password-reset -b cookies.txt
# → the member and "resetExpiresAt", plus "resetToken" when MAIL_PROVIDER is unset or MAIL_REVEAL_LINKS=trueWith mail, a signed-out member can also ask for a link themselves from the sign-in page’s Forgot it? — identity owns that public route, and this plugin issues the link behind it under the same rules: active members only, the same 60-second cooldown, the token rotated. See forgotten passwords.
With mail configured, reveal-link is the audited way to get a link that did
not arrive:
curl -X POST https://cms.example.com/api/users/5e21…/reveal-link -b cookies.txt{
"kind": "invite",
"link": "https://cms.example.com/identity/accept-invite?token=8f14e45fceea167a",
"queuedAt": "2026-09-30T09:12:00.000Z",
"attempts": 3,
"lastError": "ECONNECTION: connect ETIMEDOUT"
}kind is invite, invite_resent, password_reset or, since 0.13.0,
password_recovery.
Errors
Conflicts are 409 with a stable code:
{
"statusCode": 409,
"error": "Conflict",
"message": "…",
"code": "INVITE_RECENTLY_SENT",
"retryAfterSeconds": 42
}code | When |
|---|---|
EMAIL_TAKEN | Invite for an address already on an account (case-insensitive). From the batch route it carries emails, every taken address |
INVITE_RECENTLY_SENT | Resend within 60 s of the last one — carries retryAfterSeconds |
PASSWORD_RESET_RECENTLY_SENT | Reset within 60 s of the last one — carries retryAfterSeconds |
LAST_ADMIN_PROTECTED | Disabling or demoting the last active admin |
SELF_ACTION | Disabling or re-roling your own account |
INVALID_MEMBER_STATE | E.g. resending for a member who is not pending |
MAIL_NOT_CONFIGURED | reveal-link while MAIL_PROVIDER is unset |
NO_REVEALABLE_LINK | reveal-link with nothing undelivered |
The 60-second cooldown stops a double click minting a second link that kills the first.
Behaviour to know
- Disabling revokes live sessions, so “disabled” takes effect immediately.
The admin calls it Suspend and its undo Reactivate; the routes and
the
disabledstatus keep their names. - Accounts are never deleted.
DELETE /users/:id/invitesremoves a pending placeholder only; real accounts are disabled, so the audit log keeps naming someone who exists. - Each member carries a server-computed
isLastAdmin; the admin UI disables the controls that would remove the last administrator. This check does not run on the SSO path — a role mapping never demotes an existing admin anyway.
Extension points
MAIL_DISPATCHER(from@orthacms/mail-domain) is the optional port users injects. Bind it — the mail plugin does — and invites and resets are queued in the same transaction as the token.- Copilot tool:
workspace_members_list.