Skip to main content
View as Markdown

Invitations

POST /api/auth/admin/create-user makes an admin choose the new user's password and sends them nothing — workable for a script, unusable for onboarding a customer. The invitation flow closes that gap: the admin supplies { email, name, role } with no password, Sovrium emails a single-use link, and the invitee sets their own password and lands in an authenticated session.

app.yaml
auth:
  strategies:
    - type: emailAndPassword
  invitationTokenExpiry: '72h' # default; accepts '30s' / '15m' / '72h' / '7d' / ms
  emailTemplates:
    invitation:
      subject: 'You are invited to join, $name'
      text: |
        Hi $name,
        $inviterName invited you to join.
        Set your password: $url
        This invitation expires in 72 hours.

The Two Endpoints

Endpoint Behavior
POST /api/auth/admin/invite-user Accepts { email, name, role } (no password). Returns 200 with { user, invitationSent: true }. 401 when unauthenticated and 404 when the caller may not invite that role — never 403, so the endpoint leaks nothing; 400 for invalid input; 422 when the email already maps to a fully-onboarded user.
POST /api/auth/admin/accept-invitation Backs the public /accept-invitation?token=... page. The invitee sets a password and lands authenticated. 400 for an invalid token, 410 for an expired one.

Tokens reuse the auth.verification table (the same shape Better Auth uses for password reset), expire after invitationTokenExpiry72h by default — and are single-use, consumed on the first successful accept. A replay is rejected rather than silently re-onboarding.

The email body is rendered from the invitation template and substitutes $name, $url, $email and $inviterName.

Delegated Invitations

By default only the admin-equivalent role may invite. A role that declares canInvite: true may invite as well, without becoming admin-equivalent in any other respect:

app.yaml
auth:
  roles:
    - name: engineer
      level: 80
    - name: customer-admin
      level: 40
      canInvite: true
    - name: customer-member
      level: 20
  scopeTables:
    - clients

customer-admin can now onboard its own colleagues. Three limits come with the grant, and none of them is configurable:

Limit Effect
The level ceiling holds The invited role's level must be at or below the inviter's. customer-admin may invite customer-member or a peer, never engineer.
Admin-tier roles are out An operator role that reaches the admin dashboard can never be invited, whatever the levels say.
Tenants do not widen The invitee inherits the inviter's scope rows, and nothing else — so an inviter can only ever hand on access they already hold.

A refused invitation answers 404, never 403, so a caller learns nothing about which roles exist.

Role Assignment

Sovrium ships three default roles — admin, member, viewer — with hierarchy levels 80, 40 and 10, and accepts custom roles declared in the auth block.

An invitation carries a role explicitly. When one is not supplied at creation, a new user receives auth.defaultRole, which itself falls back to member:

app.yaml
auth:
  strategies:
    - type: emailAndPassword
  defaultRole: viewer
  roles:
    - name: editor
      description: Can edit content
      level: 30

defaultRole is validated against the built-in roles plus your declared roles[], so a typo fails sovrium validate rather than quietly assigning nothing. The first bootstrap admin is always created with the admin role and a verified email, regardless of defaultRole.

Roles drive every authorization decision in the platform: table permissions, per-field access, page access, and the admin API itself. A role can be changed after the fact with POST /api/auth/admin/set-role.

From the Console

The whole lifecycle is also available in the admin console, so an operator never has to reach for the API to onboard someone.

The account directory at /_admin/users carries an Invite affordance, which opens the Invitations page at /_admin/users/invitations. There you issue an invitation, see what is still outstanding, resend one, and revoke one that should not be accepted.

It is a sibling page rather than a panel on the directory for a reason worth knowing: invite-user creates the account row immediately and only then sends the link, so an invited-but-unaccepted person already appears in the directory. Showing the pending list beside that grid would put the same address in two tables meaning two different things — "this account exists" and "this invitation is outstanding" — with no way to tell which row a control belonged to.

The role picker offers every role your app can assign, not just the three built-ins: the options are the same set the invite endpoint accepts, so a custom editor or reviewer you declared under auth.roles[] is there. A picker narrower than that boundary would hide roles the app really uses — on an app whose roles are entirely custom, the built-ins share no members with them at all — and a wider one would offer a value the endpoint is guaranteed to refuse.

The console is in English. /_admin sits outside the locale namespace, and no console surface is translated.

Invitation vs. Create-User

You want Use
A customer to choose their own password invite-user — they receive a link and never see an admin-set secret.
A service or seed account, no mailbox involved create-user — you set the password, nothing is sent.
To onboard when SMTP is not configured create-user — an invitation email would never be delivered.

Last updated September 1, 2026

This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta. Contributions and corrections are welcome.

Built with Sovrium