> ## Documentation index
> Fetch the complete documentation index at: https://rolter-ai-rolter.docs7.io/llms.txt
> Ask it with GET https://rolter-ai-rolter.docs7.io/ask?q=<keywords or question>. Short keywords rank best.
> Use these to discover all available pages before exploring further.

# Managing members with the Rolter control plane API

> Change a role grant in one request with PUT /api/v1/memberships/{id}, read who sent each invitation from invited_by_email, and reset a member's password with a one-time link.

Three control-plane calls manage people who already have an account. Each is
published in `GET /openapi.json` and in the capability matrix at
`GET /api/v1/rbac/matrix`.

## Change a role grant

`PUT /api/v1/memberships/{id}` changes the role of one grant in a single
statement.

```bash
curl -X PUT "$ROLTER_URL/api/v1/memberships/$GRANT_ID" \
  -H "Authorization: Bearer $ROLTER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "viewer"}'
```

| Field  | Type   | Meaning                                      |
| ------ | ------ | -------------------------------------------- |
| `role` | string | one of `admin`, `member`, `viewer`; required |

No other field is accepted. The scope of a grant cannot change: revoke it and
grant a new one for that.

- Authorized as `membership:update` (admin) at the grant's own scope, the way a
  revoke is. A caller who is admin only of a team cannot change a grant on the
  organization.
- Answers `200` with the updated grant, `400` for an unknown role, `404` for an
  unknown grant.
- Demoting the organization's last active org-scoped `admin` grant is refused
  with `409` and the stable code `last_org_admin`, exactly as revoking it is. A
  superadmin is exempt.
- Writes one `membership.update` audit entry carrying `user_id`, `old_role` and
  `new_role`. Sending the grant's current role changes nothing and writes no
  entry.
- A grant created by an SSO group mapping or SCIM becomes `manual`, so the next
  reconcile does not undo the edit.

## See who sent an invitation

`GET /api/v1/orgs/{org_id}/invitations` returns `invited_by_email` on each row
next to `invited_by`. The control plane resolves it, so it is present whichever
users the caller may list: a team admin sees who sent an invitation into their
team even when the sender is an org admin they cannot list. It is `null` when
the invitation was sent with the admin token or its sender's account was deleted
(in which case `invited_by` is `null` too). Only the list carries it; creating or
revoking an invitation returns it as `null`.

## See whether an invitation was emailed

Each invitation row carries `mail_status` (`sent`, `failed`, or `not_configured`
when the control plane has no mail relay), `mail_attempted_at` and, for a
failure, `mail_error`, a short reason that is safe to show. Creating an
invitation always returns the link, whatever the status says. See
[Emailing invitations](/security/rbac#emailing-invitations).

## Send an invitation again

`POST /api/v1/invitations/{id}/resend` emails a pending invitation's link again.
It is the same link, so the earlier message still works, and the expiry does not
move. The request has no body and the response is the invitation, with
`mail_status`, `mail_attempted_at` and `mail_error` updated. It needs the same
role as sending the invitation, and is audited as `invitation.resend` followed
by `invitation.mail_sent` or `invitation.mail_failed`. A resend is refused with `429` and a `Retry-After` header while the invitation's last mail attempt, the first send included, is under a minute old.

```bash
curl -X POST "$ROLTER_URL/api/v1/invitations/$INVITATION_ID/resend" \
  -H "Authorization: Bearer $ROLTER_SESSION_TOKEN"
```

It answers `409` when the invitation was accepted, revoked or has expired, when
the control plane has no mail relay, and for an invitation whose link was not
kept: one created before resending existed, with no relay, or on a control plane
without `ROLTER_KEK`. Revoke that one and invite the address again.

## Check the mail relay

`POST /api/v1/orgs/{org_id}/invitations/mail-check` connects to the relay,
negotiates TLS and authenticates, without sending anything. It needs the role
that sends invitations in the organization.

```json
{ "ok": false, "stage": "auth", "reason": "the mail server refused the credential in the relay url" }
```

The check is not rate limited. `stage` is `connect` (unreachable or no answer in time), `tls`, `auth` or
`relay` (it answered, but not as a mail server does). The reason never contains
the relay's address, its reply or the credential. A control plane without
`ROLTER_SMTP_URL` answers `409`. `rolter check --connect` runs the same check
from the command line.

## Reset a member's password

`POST /api/v1/orgs/{org_id}/users/{id}/password-reset` issues a one-time link
for a member. The request has no body.

```bash
curl -X POST "$ROLTER_URL/api/v1/orgs/$ORG_ID/users/$MEMBER_ID/password-reset" \
  -H "Authorization: Bearer $ROLTER_SESSION_TOKEN"
```

The response carries `token` and `reset_url` **once**; only a peppered digest of
the token is stored. `reset` is the row (`id`, `user_id`, `created_by`,
`expires_at`, `created_at`) and `sessions_revoked` is how many of the member's
sessions ended. rolter does not send email: hand the link to the member.

An administrator never sees or chooses the password. The member opens
`reset_url`, a page of the dashboard, and sets their own:

- `GET /api/v1/password-reset/{token}` previews the address the link is for;
- `POST /api/v1/password-reset/{token}` with `{"password": "..."}` stores it.

Both are unauthenticated (the token is the credential), throttled like a
password sign-in, and answer `401` for a link that is spent, replaced, expired
or wrong. Redeeming is single-use even under a race, ends every session of the
account again and **signs nobody in**: the member signs in normally, so an
armed second factor still applies. A reset never clears a second factor.

Issuing a link:

- needs `password_reset:create` (admin) at the organization in the path. Below a
  superadmin the member must belong to that organization alone and must not be a
  superadmin, otherwise `403`: the link lets its holder choose a password for the
  whole account;
- is refused with `400` for your own account (use [Change your
  password](/api/change-password)), an account with no local password (single
  sign-on, LDAP) and a deactivated account; `404` when the member holds no role
  in the organization;
- ends the member's sessions and pending second-factor sign-ins at once, and
  replaces the account's earlier link;
- leaves the old password valid until the link is used; deactivate the account
  to cut it off sooner;
- writes a `user.password_reset` audit entry in the organization (issuer,
  member, sessions ended, the replaced link; never the token) and, when the link
  is used, a `user.password_reset_complete` entry against the account;
- expires after 24 hours.

See [Roles and permissions](/security/rbac#resetting-a-password) for the
dashboard side.
