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.
Option 1 — invite by email
Section titled “Option 1 — invite by email”curl -X POST https://<your-host>/api/invites \ -H 'Content-Type: application/json' \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:
- A
usersrow is created with statusinvitedand no password, and an invite row is created — in one transaction, so a failure can’t leave an orphaned invited user. - The token is 32 random bytes; only its SHA-256 hash is stored.
- The response contains the invite
link. IfPUBLIC_BASE_URLis set it’s absolute; otherwise it’s the relative/admin/accept-invite?token=.... - 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.
Without SMTP configured
Section titled “Without SMTP configured”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.
Resending
Section titled “Resending”curl -X POST https://<your-host>/api/invites/<user-id>/resendA 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”curl -X POST https://<your-host>/api/users \ -H 'Content-Type: application/json' \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.
Changing roles
Section titled “Changing roles”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.
Disabling and deleting
Section titled “Disabling and deleting”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.
Resetting a password
Section titled “Resetting a password”curl -X POST https://<your-host>/api/users/<id>/reset-passwordGenerates a new temporary password, requires a change at next login, revokes existing sessions, and returns the password once.
Self-service
Section titled “Self-service”Any signed-in user, with no permission required:
curl https://<your-host>/api/account/mecurl -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.
The Super Admin is protected
Section titled “The Super Admin is protected”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.
What gets audited
Section titled “What gets audited”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.