> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Members

> The tenant member directory: the GET /members shape, how the console displays it, and what member management does not yet do.

`services/api/members_api.py` is a deliberately small router: one endpoint, read-only. Its own module docstring says plainly what it is — "Tenant-facing member directory: real `users` rows for the current session's tenant, each with the role\_profile label(s) they hold. Read-only today." There is no tenant-facing invite, deactivate, or role-change endpoint anywhere in this router.

## `GET /members`

The only route (`members_router`, prefixed `/members`), and the only member operation in `services/api/product_app.py`'s allowlist (`MEMBER_OPERATIONS = frozenset({("GET", "/members")})`). Requires an authenticated tenant session, nothing more — no special permission beyond being logged in.

```json theme={null}
[
  {
    "id": "5e2b1c3a-...",
    "email": "jane@acme-bank.com",
    "full_name": "Jane Okafor",
    "roles": ["Risk Admin"],
    "is_active": true,
    "created_at": "2026-06-14T09:03:11.482Z"
  }
]
```

The handler (`list_members`) queries every `TenantUser` row for the tenant, ordered by `created_at`, then joins `user_roles` → `role_profiles` to build a `roles: list[str]` per user from each `RoleProfile.label` they hold — a user can hold more than one role profile, so `roles` can have more than one entry (or be empty, if no role profile has been assigned). `MemberSummary`'s fields are exactly the six above: `id`, `email`, `full_name` (nullable — falls back to email for display), `roles`, `is_active`, `created_at`. Nothing else about a member is exposed here — no password hash, no session state, no permission list, only the human-readable role labels.

## How the console shows members

`SettingsView.tsx`'s "Members" tab (`MembersPanel`) is the only consumer of `listTenantMembers()` (`src/lib/api/tenantMembers.ts`, a thin `apiGet<TenantMember[]>("/members")` wrapper). It renders one row per member:

* An **`Avatar`** with the member's initials (first letter of up to their first two name words, computed client-side from `full_name`, falling back to email when `full_name` is empty) and a rotating color variant (`[undefined, 4, 2, 3][index % 4]`) so a member list doesn't render every avatar in the same color.
* **Name** (`full_name` or email) with **email** as the subtitle underneath.
* **Role profiles** as a joined, comma-separated string (`member.roles.join(", ")`), or the literal text "No role profile" when the array is empty.
* A **status chip** — "Active" (`ok` tone) or "Inactive" (`neutral` tone) from `is_active`.

Directly beneath the list, the panel prints a fixed disclosure: *"Invitations and role changes remain staff-managed in the MVP."* That line is not a placeholder pending a future feature flag — it accurately describes the current product boundary (below).

## What doesn't exist here

There is no tenant-facing surface — API or console — for:

* **Inviting a new member.** Inviting a tenant user is exclusively a staff (employee-authenticated) action: `POST /admin/tenants/{tenant_id}/invite` (`services/api/onboarding_api.py`), which takes an `email` and a `role_profile_key` (defaulting to `org_admin`) and enqueues an invitation email; `GET /admin/tenants/{tenant_id}/invitations` lists invitations with their `status` (`pending` by default, `accepted` once redeemed — `models.py`'s `TenantInvitation` and `auth/tenant_auth.py`'s accept flow never set any other value) plus per-invitation `delivery_status`/`delivery_attempts` from the email delivery job. Both are staff portal operations, not part of the tenant console's `/members` surface — see [Invitations and impersonation](/features/invitations-and-impersonation).
* **Changing a member's role profile**, or **deactivating** a member. `list_members` never writes anything; there is no `PATCH`/`PUT`/`DELETE` in `members_api.py` at all.
* **Self-service acceptance beyond `POST /accept-invite`** — an invited user redeems their invitation token through `auth_api.py`'s accept-invite flow, which creates the `TenantUser`/`UserRole` rows this endpoint later reads, but that flow lives in authentication, not here.

In other words: today's member surface answers "who is on this tenant's roster, and what are they allowed to do" for display purposes only. Every write that changes that roster — inviting, assigning a role profile, deactivating an account — is a staff action taken from the onboarding/admin portal, on behalf of the tenant, not something a tenant user can trigger from their own console.

## Underlying schema

`/members` is a straightforward join across three tenant-schema tables (`services/api/db/models.py`), all reachable only inside a `tenant_uow()` session (see [Tenancy and data model](/architecture/tenancy-and-data-model) for how schema-per-tenant plus row-level security keeps one tenant's roster from ever leaking into another's query):

| Table                           | Relevant columns                                                                     | Notes                                                                                                                                                                                                                                                                  |
| ------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `users` (`TenantUser`)          | `id`, `email`, `password_hash`, `full_name`, `is_active`, `created_at`, `updated_at` | `UNIQUE(tenant_id, email)`. `password_hash` and every session/token detail are never serialized into `MemberSummary`.                                                                                                                                                  |
| `role_profiles` (`RoleProfile`) | `id`, `key`, `label`, `permissions` (JSONB array), `is_default`                      | `UNIQUE(tenant_id, key)`. `key` (`org_admin`, `risk_admin`, `analyst`, `viewer`) is the stable identifier used by the invite endpoint; `label` is the human-readable string `/members` actually returns. Seeded per tenant during provisioning (`seed_role_profiles`). |
| `user_roles` (`UserRole`)       | `user_id`, `role_profile_id`                                                         | Join table — `UNIQUE(tenant_id, user_id, role_profile_id)`, so a user can hold more than one role profile at once.                                                                                                                                                     |

`list_members` reads all three in one `tenant_uow()` block: every `TenantUser` ordered by `created_at`, then a single joined `user_roles` → `role_profiles` query for every `(user_id, label)` pair in the tenant, folded client-side into a `dict[UUID, list[str]]` before the response is built — not one role query per user.

## How a member gets here

A row only appears in `/members` once a real `TenantUser` exists, and that only happens through the staff-driven invite → accept flow:

```mermaid theme={null}
sequenceDiagram
    participant Staff as Staff (employee portal)
    participant API as onboarding_api.py
    participant Mail as Email delivery job
    participant User as Invited user
    participant Auth as auth_api.py

    Staff->>API: POST /admin/tenants/{tenant_id}/invite<br/>{email, role_profile_key}
    API->>Mail: enqueue delivery job (invite_token_hash)
    Mail-->>User: invitation email
    User->>Auth: POST /accept-invite<br/>{workspace, token, password, full_name?}
    Auth->>Auth: creates users + user_roles rows
    Auth-->>User: session cookie + CSRF cookie
    Note over API,Auth: GET /members now includes this user
```

`POST /admin/tenants/{tenant_id}/invite` (staff/employee-authenticated) takes `email` and `role_profile_key` (defaulting to `"org_admin"`), and enqueues a delivery job rather than sending mail inline. `POST /accept-invite` (`auth_api.py`'s `tenant_accept_invite`) is the only route that actually inserts the `users`/`user_roles` rows this directory reads — it takes the workspace slug, the raw invite token, a new password (minimum 8 characters), and an optional `full_name`, and on success sets both the session cookie and the CSRF cookie so the newly-created member is immediately signed in.

## Role profiles

`roles` on a `MemberSummary` are `RoleProfile.label` values — human-readable labels for the tenant's seeded `role_profiles` (`org_admin`, `risk_admin`, `analyst`, `viewer`). The console's own client-side permission gates (`canReview`/`canOperate` in `src/lib/session.ts`) operate on a separate `WorkspaceRole` rank (`Auditor < Analyst < Loop Owner < Risk Admin`) tied to the authenticated session, not directly on this endpoint's `roles` array — `/members` is a read-only directory view of the roster, not the mechanism that gates what the current user can do in the console. `RoleProfile.permissions` (the JSONB array backing server-side checks like `caps.write`, `sources.manage`, `exports.create`) is likewise never serialized into `MemberSummary` — the directory shows *who has which named role*, not the raw permission strings behind it.

## Related

* [Invitations and impersonation](/features/invitations-and-impersonation)
* [Tenancy and data model](/architecture/tenancy-and-data-model)
* [Security model](/architecture/security)
* [Insights console](/features/insights-console)
