# Members, org roles and per-product grants

> How org roles, per-product grants, and invites work in the Members screen, including the guard rails the server enforces.

Membership in TeleQuick has two independent layers. An **org role** is
account-wide. A **grant** says which product a member may open and at what
level. The Members screen (**Admin → Members**) edits both, plus the pending
invite list.

| Layer      | Stored in           | Scope                                          |
| ---------- | ------------------- | ---------------------------------------------- |
| Org role   | `org_member`        | One value per member, per org.                 |
| Grant      | `org_member_grant`  | One row per (member, product), or no row.      |
| Invite     | `org_invite`        | A pending role + grant set for an email.       |

## Org roles vs per-product grants

The org role is account-wide: it covers things like invites, SSO, billing and
branding, and it decides who may administer other members. It does **not**
enumerate products.

| Org role     | Value         |
| ------------ | ------------- |
| Owner        | `owner`       |
| Admin        | `admin`       |
| Supervisor   | `supervisor`  |
| Developer    | `developer`   |
| Viewer       | `viewer`      |

The member-administration procedures behind this screen (`admin.setGrant`,
`admin.updateMemberRole`, `admin.removeMember`) accept **owner and admin
only**. Owners and admins also pass every grant gate implicitly, so their rows
reach every product regardless of what the grant matrix shows for them.

For everyone else, product access comes entirely from grants. A member with no
grant rows is in the organization and can reach no product console.

`admin.listMembers` returns `userId`, `email`, `name`, `role` and `createdAt`.
Identities are resolved through the `public.user_identities` RPC rather than
read directly from the auth schema. `name` is an empty string when the account
has no name recorded — the invite flow and password signup do not set one — so
the screen falls back to the email address.

## The grant levels and which products accept them

`admin.setGrant` takes one `modality` and one `role`. Passing `role: null`
deletes the grant row, which the screen shows as **No access**.

| Grant level | Value        |
| ----------- | ------------ |
| Admin       | `admin`      |
| Operator    | `operator`   |
| Developer   | `developer`  |
| Viewer      | `viewer`     |

These are the grantable products, with the console each one opens:

| Product             | `modality`       |
| ------------------- | ---------------- |
| Voice AI            | `voice`          |
| Contact Center (agent suite) | `contactcenter` |
| Optimus (robotics fleet)     | `robotics`   |
| SapienScale (teleoperation)  | `teleop`     |
| Arena (netcode)     | `games`          |
| Stream              | `streams`        |
| Inference           | `inference`      |
| Crypto              | `crypto`         |
| Realtime            | `realtime`       |
| Tunnel              | `tunnel`         |
| QuickDesk           | `quickdesk`      |

Expand the **Product access** cell on a member row to see and edit the full
matrix. The count on the button is the number of products with a grant row.

Writes are idempotent: if the submitted level equals the stored level,
`setGrant` returns without touching the row or writing an audit entry.
Otherwise it upserts on `(org_id, user_id, modality)` and records `granted_by`.

## Access presets on the invite form

The invite form offers presets instead of an eleven-row grid, because the
common cases are few. Each preset sets both the org role and the grant map; you
can still adjust either afterwards, which flips the selector to **Custom**.

| Preset            | Org role     | Grants                                             |
| ----------------- | ------------ | -------------------------------------------------- |
| Administrator     | `admin`      | `admin` on every product.                           |
| Supervisor        | `supervisor` | `operator` on `voice` and `contactcenter`.          |
| Developer         | `developer`  | `developer` on every product.                       |
| Read-only         | `viewer`     | `viewer` on every product.                          |
| No access yet     | `viewer`     | None. Grant products later.                         |
| Custom            | `developer`  | None, and the matrix opens for you to pick.         |

The screen always sends the `grants` object explicitly, including when it is
empty. An omitted `grants` field means "older client, synthesise a grant set
from the role" on the server, which is not the same thing as the deliberate
empty set behind **No access yet**.

## Guard rails: last owner, owner-only changes, no self-edits

The server enforces these, not the UI. They return errors even if a client
calls the procedure directly.

- **No self-edits.** `setGrant` rejects a `userId` equal to the caller with
  `BAD_REQUEST` — *"Cannot change your own access — ask another admin"*. This
  stops an admin quietly widening their own product access without a second
  pair of eyes. `updateMemberRole` applies the same rule to the org role, which
  also prevents accidental self-lockout.
- **Owner changes are owner-only.** Only an owner may grant the `owner` role or
  change a member who already holds it.
- **The last owner cannot be demoted.** `updateMemberRole` refuses the change
  that would leave the org with no owner. `removeMember` mirrors the same
  checks.
- **The target must be a member.** `setGrant` looks the user up in `org_member`
  first and returns `NOT_FOUND` — *"Member not in this org"* — if there is no
  row. You cannot pre-grant products to someone who has not accepted an invite.

Removing a member takes effect immediately: they lose access to the
organization at once. Any pending invite for that address is unaffected, and
you can invite them again later.

## The invite lifecycle: send, resend, accept, expire, revoke

`admin.inviteMember` takes `orgId`, `email`, `role` and `grants`. The invite row
in `org_invite` carries `id`, `email`, `role`, `grants`, `created_at`,
`expires_at` and `accepted_at`.

`admin.listInvites` returns only rows where `accepted_at` is null, newest
first. So an invite leaves the **Pending invites** table as soon as it is
accepted — the member then appears in the **Members** table instead. The
**Expires** column renders the row's `expires_at`; once that moment passes the
link no longer works and you send a fresh invite.

Inviting an address that is already a member does not create a second
invitation. The mutation returns `already_member: true` together with a
`message`, and no acceptance link. The screen surfaces the message as a notice
rather than falling into the "email not delivered, copy the link" path, which
would otherwise promise a link that never renders.

`admin.revokeInvite` takes `orgId` and `inviteId`. Revoking stops the invite
link from working. You can send a new invite to the same address at any time.

## When an invite email is not delivered

The invite response includes a `mail_delivered` flag:

- `mail_delivered: true` — the invitation was emailed and nothing else is
  needed.
- `mail_delivered: false` — the invite exists but the message did not go out.
  The response then carries `accept_url` (or `magic_link`), and the screen
  renders that URL so you can hand it to the invitee out of band.

Treat the link as a credential: whoever opens it joins the org with the role and
grants recorded on the invite. If you would rather not pass it around, revoke
the invite and send a new one.

## Entitlements bound every grant

Grants only ever **narrow** access. Your plan's entitlements and the product
list your org is provisioned for still decide what the organization can reach
at all. Granting `streams` to a member of an org without the Stream entitlement
gets them nothing — the grant row exists, but the gate above it still fails.

Two consequences worth remembering when you debug a "why can't they see it"
report:

1. If nobody in the org can open a product, the problem is the entitlement, not
   the grant.
2. If one person cannot open a product that colleagues can, the problem is the
   grant — unless that colleague is an owner or admin, who reach everything
   regardless of the matrix.

## Auditing membership changes

Grant changes write an audit entry through `recordAudit`:

| Field           | Value                                                          |
| --------------- | -------------------------------------------------------------- |
| `orgId`         | The org the change applied to.                                  |
| `actorUserId`   | The admin or owner who made the change.                         |
| `action`        | `member.grant_changed`, or `member.grant_revoked` when cleared. |
| `resourceType`  | `org_member_grant`                                              |
| `resourceId`    | The target member's user id.                                    |
| `before`        | `{ modality, role }` as it stood, with `role: null` if unset.   |
| `after`         | `{ modality, role }` after the write.                           |

Because a no-op write returns early, repeated saves of the same level do not
produce duplicate audit rows. The `granted_by` column on `org_member_grant`
independently records who last set the surviving grant.

## Procedures behind this screen

| Procedure                  | Input                                       | Purpose                                |
| -------------------------- | ------------------------------------------- | -------------------------------------- |
| `admin.listMembers`        | `orgId`                                     | Members with resolved email and name.  |
| `admin.listGrants`         | `orgId`, optional `userId`                  | Every grant in the org, or one member's. |
| `admin.setGrant`           | `orgId`, `userId`, `modality`, `role \| null` | Set or clear one grant.              |
| `admin.updateMemberRole`   | `orgId`, `userId`, `role`                   | Change the account-wide org role.      |
| `admin.removeMember`       | `orgId`, `userId`                           | Remove the member from the org.        |
| `admin.inviteMember`       | `orgId`, `email`, `role`, `grants`          | Create and email an invitation.        |
| `admin.listInvites`        | `orgId`                                     | Pending (unaccepted) invites.          |
| `admin.revokeInvite`       | `orgId`, `inviteId`                         | Invalidate an invite link.             |

## Related

- [Authentication](/concepts/authentication) — API keys and relay tokens, which are scoped separately from membership
