Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Users plugin

Invites, roles, disabling accounts and reset links — routes, bodies and error codes.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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-admin

Register

// 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:

SettingInvite / reset response
MAIL_PROVIDER unset (no mail plugin)Carries inviteToken / resetToken — hand the link over yourself
MAIL_PROVIDER=smtp or consoleNo token field — the message carries the link
… plus MAIL_REVEAL_LINKS=trueBoth: 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).

RoutePermissionDoes
GET /api/usersusers:readList — search, status, role, filter, page, pageSize (default 10, ≤ 100)
GET /api/users/rolesusers:readThe three assignable roles and the permission keys each grants. Since 0.13.0
GET /api/users/:idusers:readOne member
GET /api/users/:id/sign-inusers:update{ hasPassword, ssoProviders }. Since 0.13.0
POST /api/users/invitesusers:createInvite by email with a role
POST /api/users/invites/batchusers:create and users:readInvite 1–25 addresses with one role and one set of workspaces, all or nothing. Since 0.13.0
POST /api/users/:id/invites/resendusers:createRotate a pending invite
DELETE /api/users/:id/invitesusers:deleteRevoke a pending invite
PATCH /api/users/:idusers:update{ name?, role? }
POST /api/users/:id/disable · /enableusers:updateDisable (and revoke sessions) or re-enable
POST /api/users/:id/password-resetusers:updateMint a reset link for an active member
GET /api/users/:id/sessions · DELETE …/sessions/:sessionIdusers:updateThe member’s sessions; revoke one (identity’s routes)
DELETE /api/users/:id/sessionsusers:updateSign out everywhere — every session but the caller’s own → { revoked }. Since 0.13.0, identity’s
POST /api/users/:id/reveal-linkusers:manageThe 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

Example language
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, contributor or viewer.
  • workspaceIds is 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/batch takes { 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 a 409 EMAIL_TAKEN whose emails names every one of them, and nobody is invited. It needs users:read as 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.

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=true

With 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
}
codeWhen
EMAIL_TAKENInvite for an address already on an account (case-insensitive). From the batch route it carries emails, every taken address
INVITE_RECENTLY_SENTResend within 60 s of the last one — carries retryAfterSeconds
PASSWORD_RESET_RECENTLY_SENTReset within 60 s of the last one — carries retryAfterSeconds
LAST_ADMIN_PROTECTEDDisabling or demoting the last active admin
SELF_ACTIONDisabling or re-roling your own account
INVALID_MEMBER_STATEE.g. resending for a member who is not pending
MAIL_NOT_CONFIGUREDreveal-link while MAIL_PROVIDER is unset
NO_REVEALABLE_LINKreveal-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 disabled status keep their names.
  • Accounts are never deleted. DELETE /users/:id/invites removes 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.