Skip to content

Managing Users

Users are managed under Settings → Users, which requires users:read to view and users:write to change. Inviting requires invites:create.

There are two ways to add someone, and they differ in who chooses the password.

Terminal window
curl -X POST https://<your-host>/api/invites \
-H 'Content-Type: application/json' \
-d '{"email": "[email protected]", "role_ids": ["<uuid>"]}'

A user can be assigned more than one role — pass multiple ids in role_ids. At least one is required; an empty list is rejected.

What happens:

  1. A users row is created with status invited and no password, and an invite row is created — in one transaction, so a failure can’t leave an orphaned invited user.
  2. The token is 32 random bytes; only its SHA-256 hash is stored.
  3. The response contains the invite link. If PUBLIC_BASE_URL is set it’s absolute; otherwise it’s the relative /admin/accept-invite?token=....
  4. If SMTP is configured (Settings → SMTP), an email is sent. Best-effort — a send failure is logged, never returned as an API error, because the invite already exists and the link is the guaranteed delivery path.

Invites expire after 7 days. Expiry never deletes the user row; an admin resends or deletes it deliberately.

The invitee opens the link, sets a password (8 characters minimum, 72 bytes maximum), and their row flips to active. The accept page checks the token up front, so an expired or invalid link shows that immediately rather than after a failed submit. Submitting the form is per-IP rate limited (by default 10 POSTs per minute with a burst of 5, configurable with UI_LOGIN_RATE_PER_MINUTE and UI_LOGIN_RATE_BURST, answering 429 with Retry-After: 60) and body-capped at 64 KiB, so a retry loop cannot hammer the password hash.

The flow is identical server-side minus the send attempt. The copyable link is always returned and always shown in the UI — so with no SMTP configured on Settings → SMTP, copying the link and sending it yourself is the delivery path.

Terminal window
curl -X POST https://<your-host>/api/invites/<user-id>/resend

A resend creates a fresh invite row with a new token and a new 7-day expiry, and returns the new link. It returns 409 unless the target’s status is exactly invited. Resend applies the same role-grant rule as creating the invite: you can only resend an invite whose roles you could grant yourself, otherwise it returns 403.

Option 2 — create with a temporary password

Section titled “Option 2 — create with a temporary password”
Terminal window
curl -X POST https://<your-host>/api/users \
-H 'Content-Type: application/json' \
-d '{"email": "[email protected]", "role_ids": ["<uuid>"]}'

The server generates a 20-character password from an ambiguity-free alphabet (no i, l, 1, O, 0), creates the user as active with must_change_password set, and returns:

{ "user": { ... }, "temp_password": "..." }

The user must change it at first login; until they do, API calls return 403 password change required.

Terminal window
curl -X PATCH https://<your-host>/api/users/<id> \
-H 'Content-Type: application/json' \
-d '{"role_ids": ["<uuid>", "<uuid>"]}'

role_ids replaces the user’s full set of roles — it isn’t additive. At least one role must remain; a request that would leave the user with none is rejected.

Terminal window
curl -X PATCH https://<your-host>/api/users/<id> -d '{"status": "disabled"}'
curl -X PATCH https://<your-host>/api/users/<id> -d '{"status": "active"}'
curl -X DELETE https://<your-host>/api/users/<id>

status must be exactly active or disabled. Both disabling and deleting revoke all of that user’s sessions immediately, so access ends at once rather than at session expiry.

Prefer disabling over deleting when you may need the account back — deletion cascades the user’s sessions and invites, though audit entries survive.

Terminal window
curl -X POST https://<your-host>/api/users/<id>/reset-password

Generates a new temporary password, requires a change at next login, revokes existing sessions, and returns the password once.

Any signed-in user, with no permission required:

Terminal window
curl https://<your-host>/api/account/me
curl -X POST https://<your-host>/api/account/change-password \
-d '{"current_password": "...", "new_password": "..."}'

/api/account/me returns the email, super-admin flag, whether a password change is required, and the caller’s full permission list — which is what the UI uses to decide what to show.

Only the account bootstrapped from ADMIN_USERNAME/ADMIN_PASSWORD_HASH is protected: role changes, status changes, deletion and admin password resets against it all return 409. An invite also cannot be minted for an already-active user, regardless of super-admin status.

A Super Admin can still change their own password through the self-service route.

More than one account can hold Super Admin: assigning a user the Super Admin role sets the flag, whether by invite (on accept), by creating the user directly, or by a later role change. Only an existing Super Admin can grant that role, or grant anyone a role with more permissions than their own — a plain invites:create or users:write holder can’t self-escalate or escalate someone else this way. Unlike the bootstrapped account, one of these later-granted Super Admins is not protected — another Super Admin can demote, disable, reset the password of, or delete it like any other user.

user.create, user.delete, user.role_change, user.status_change, user.password_reset (both admin resets and self-service changes), invite.create, invite.resend, plus auth.login, auth.login_failed and auth.logout.

Invite entries use the invitee’s user id as the resource id, so a create and its resends correlate. Failed logins are recorded only for emails that resolve to a real user — see Users & permissions.