# Let your app's admins manage users (auth.users)
Source: https://sovaria.in/docs/app-user-admins

An admin screen inside your own app: people with a role you choose add, list, re-role, disable and remove your app's users with their own sign-in, no server key. Every rule is checked by the server.

Many apps have their own admins: the manager of a CRM adds a new sales rep, a school admin adds teachers. `auth.users` lets them do that **from inside your app, with their own session**. You choose which app roles may do it; nobody else can, and no server key ever goes into the browser.

(Diagram: Who decides)

## Turn it on

1. **Have the roles** — Your app's roles are in **Authentication › Roles** (for example `ADMIN`, `SALES`). Give at least one person the admin role (console **Users** page, or `auth.admin`).
2. **Choose who manages users** — In **Authentication › Users**, under **Who can manage users in your app**, tick the roles (for example `ADMIN`) and save. It is off until you do (no roles). The setting is `userAdminRoles` in the project settings; only the project team can change it.
3. **Build the screen** — Call `auth.users` from your app. Sovariq can do all of this for you: ask for "an admin page where admins add staff".

## Use it

TypeScript:
```ts
// Show the screen only to people the server says can use it.
const { can_manage_users, assignable_roles } = await sovaria.auth.users.access()

const { users, nextCursor } = await sovaria.auth.users.list({ search: 'ada', status: 'active', limit: 50 })

// Every user, 100 per request following the cursor (stops after max, default 10,000)
for await (const u of sovaria.auth.users.all({ role: 'SALES' })) console.log(u.email)

// Invite by email (a sign-in link), or make a temporary password shown once:
const { user, temporary_password } = await sovaria.auth.users.create({
  email: 'rep@acme.com', name: 'New Rep', roles: ['SALES'], mode: 'temporary_password',
})

await sovaria.auth.users.setRoles(user.id, ['SALES', 'MANAGER'])
await sovaria.auth.users.disable(user.id)     // signed out everywhere, can't sign in
await sovaria.auth.users.enable(user.id)
await sovaria.auth.users.delete(user.id)
```

Python:
```python
access = await sov.auth.users.access()
page = await sov.auth.users.list(search="ada", status="active")      # {"users": [...], "next_cursor": ...}
async for u in sov.auth.users.iter_all(role="SALES"):                 # every user, 100 per request
    print(u["email"])
made = await sov.auth.users.create("rep@acme.com", name="New Rep", roles=["SALES"], mode="temporary_password")
await sov.auth.users.set_roles(made["user"]["id"], ["SALES", "MANAGER"])
await sov.auth.users.disable(made["user"]["id"])
await sov.auth.users.delete(made["user"]["id"])
```

Swift:
```swift
let access = try await sovaria.auth.users.access()
let page = try await sovaria.auth.users.list(search: "ada")
let made = try await sovaria.auth.users.create(email: "rep@acme.com", name: "New Rep", roles: ["SALES"], mode: "invite")
try await sovaria.auth.users.setRoles(userId, roles: ["SALES"])
try await sovaria.auth.users.disable(userId)
```

Kotlin:
```kotlin
val access = sovaria.auth.users.access()
val page = sovaria.auth.users.list(search = "ada")
val made = sovaria.auth.users.create("rep@acme.com", name = "New Rep", roles = listOf("SALES"), mode = "invite")
sovaria.auth.users.setRoles(userId, listOf("SALES"))
sovaria.auth.users.disable(userId)
```

Dart:
```dart
final access = await sovaria.auth.users.access();
final page = await sovaria.auth.users.list(search: 'ada');
final made = await sovaria.auth.users.create('rep@acme.com', name: 'New Rep', roles: ['SALES'], mode: 'invite');
await sovaria.auth.users.setRoles(userId, ['SALES']);
await sovaria.auth.users.disable(userId);
```

HTTP:
```bash
# The signed-in admin's own access token; never a key.
curl https://<ref>.sovarias.in/api/v1/auth/app-users -H "Authorization: Bearer $USER_ACCESS_TOKEN"
curl -X POST https://<ref>.sovarias.in/api/v1/auth/app-users -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H 'Content-Type: application/json' -d '{"email": "rep@acme.com", "roles": ["SALES"], "mode": "invite"}'
```

| Call | HTTP | Notes |
|---|---|---|
| `access()` | `GET /api/v1/auth/app-users/access` | Any signed-in user may ask. `can_manage_users`, `your_roles`, `user_admin_roles`, `assignable_roles`. |
| `list({ limit, cursor, search, status, role })` | `GET /api/v1/auth/app-users` | Newest first, at most 100 a page. Id, email, name, roles, active, email verified, `must_change_password`, created, last sign-in. Never passwords, factors or sessions. |
| `create({ email, name, roles, mode })` | `POST /api/v1/auth/app-users` | `mode: 'invite'` (default) emails a sign-in link. `'temporary_password'` returns `temporary_password` once. |
| `setRoles(id, roles)` | `PUT /api/v1/auth/app-users/{id}/roles` | Replaces the roles. The user stays signed in: their requests use the new roles at once (within 20 seconds at most), even with a token issued before the change, and the next refreshed token carries them. |
| `disable(id)` / `enable(id)` | `POST …/{id}/disable` · `…/enable` | Disabled users are signed out everywhere and can't sign in. |
| `delete(id)` | `DELETE /api/v1/auth/app-users/{id}` | Deletes the user and everything tied to the account. |

## Temporary passwords

With `mode: 'temporary_password'` the server makes a strong password that meets your password rules and returns it **once**, only to the admin who made the account. Show it on screen so they can hand it over; never store it, log it or send it to analytics. The account is marked `must_change_password` (`user.app_metadata.must_change_password`, and `must_change_password` on `/auth/me`) until the user sets their own password with `updateUser({ password, currentPassword })` or a password reset. If email verification is on, the user also confirms their email the first time.

**The server enforces it.** Until the password is changed, that user's session gets `403 PASSWORD_CHANGE_REQUIRED` on every data call: tables (REST, GraphQL), storage, functions, realtime, agents, and managing users. Only these still work: reading and updating their own account (`GET`/`PATCH /auth/me`, `auth.updateUser`), the confirmation code for the change (`auth.reauthenticate`), signing out, refreshing and the password reset. A user an admin never gave a temporary password is not affected.

TypeScript:
```ts
// At sign-in: send the user to a "choose a new password" screen first.
const me = await sovaria.auth.me()
if (me.must_change_password) {
  const { error } = await sovaria.auth.updateUser({ password: newPassword, currentPassword: temporaryPassword })
  if (!error) await sovaria.auth.refreshSession()   // the refreshed session no longer carries the flag
}
```

HTTP:
```bash
curl -X PATCH https://<ref>.sovarias.in/api/v1/auth/me -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H 'Content-Type: application/json' -d '{"password": "<new>", "current_password": "<temporary>"}'
# Then refresh: the old access token keeps the flag until it is refreshed or expires.
curl -X POST https://<ref>.sovarias.in/api/v1/auth/refresh -H 'Content-Type: application/json' \
  -d '{"refresh_token": "<refresh token>"}'
```

Refresh right after the change: the access token used for the change still carries the flag until it is refreshed or expires. The starter app does all of this for you: its `PasswordChangeGate` shows the screen and `changePassword` refreshes the session. If your own server verifies Sovaria tokens with the project's signing keys, check `app_metadata.must_change_password` in the claims as well.

## What the server enforces

Nothing here trusts your app's code. A user who edits the page in their browser, or sends requests by hand, gets the same answers as your UI:

| Rule | Refused with |
|---|---|
| The caller is a signed-in app user of **this** project with their own session: the anon key, server keys, anonymous visitors and impersonation sessions are refused. | `403 APP_USER_SESSION_REQUIRED` |
| The feature is on (at least one role chosen). | `403 USER_ADMIN_OFF` |
| The caller holds one of the chosen roles, **read from the database** (the roles in a token or request don't count). | `403 USER_ADMIN_ROLE_REQUIRED` |
| Only your app's own roles can be given (Authentication › Roles); never team/organization roles or reserved names. | `422 UNKNOWN_APP_ROLE` · `RESERVED_APP_ROLE` |
| A role from the "manage users" list can only be given by someone who has that role. | `403 ROLE_ESCALATION` |
| Nobody changes, disables or deletes their own account here. | `403 SELF_CHANGE` |
| A user holding a "manage users" role the caller doesn't have can't be changed by them. With several roles in the list there is no ranking: give your top admins every listed role. | `403 TARGET_OUTRANKS` |
| The last active user admin can't be removed, disabled or demoted. | `409 LAST_USER_ADMIN` |
| Only users of the same project (and environment) exist here. | `404 USER_NOT_FOUND` |
| New users per hour per project, and changes per hour per admin (Auth › Settings › Rate limits: `appUserCreatesPerHour`, default 50; `appUserChangesPerHour`, default 200). Invites also count as auth emails. | `429 AUTH_RATE_LIMITED` |
| An email that already has an account in this app. | `409 EMAIL_EXISTS` |
| A user (admin or not) still on a temporary password can only change it: everything else waits until they do. | `403 PASSWORD_CHANGE_REQUIRED` |

- Every change is recorded on both accounts' security activity (`auth.app_admin.*` for the admin, `admin.app_user.*` for the user) and in your project's audit log.
- The user gets a security email when their roles change, their account is disabled or turned back on, or it is deleted (unless you turned **security notifications** off).
- Row security is unchanged: managing users gives no extra access to your tables. Rules like `auth.has_role('ADMIN')` keep deciding which rows anyone sees.
- Your team keeps the console and `auth.admin` (server code) for everything, including people the in-app admins can't touch.

> **Pick the role with care:** Anyone with a chosen role can create accounts in your app and give them your app's non-admin roles. Choose a role only trusted people have, and keep the list short.
