---
path: /members
surface: tenant-admin
domain: identity-access
auth: signed-in + tenant:admin (getTenantServerSession)
source: apps/oshun/tenant-admin/src/app/members/page.tsx
status: drafted
last_walked: '—'
---

# Members and seats

## Purpose

Read-only directory of tenant membership and seat usage: lists each member's
user id, roles, groups, licenses, status, and last-seen time, with a summary
line of status counts. Backs the `members` workspace (`core` group) in
`libs/oshun/navigation/src/tenant-ia.ts`.

## Entry points

- Shell sidebar (`TenantSidebar`) — **"Members"** (`shortLabel`) under the
  **Core** group, link to `/members` (`data-workspace-id="members"`)
- Workspace grid on `/` (home) — list item `data-workspace-id="members"`
- Notifications digest deep link target `/members` (see
  [notifications.md](notifications.md))
- Direct URL `/members` (bookmarkable; middleware-gated)

## Layout regions

Inside `TenantShell`:

- **Header**: `TenantHeader` shows `tenant · <tenantId>` › **"Members and
  seats"**
- **Main**:
  `<section data-testid="tenant-members-panel" data-member-count={counts.total}>`
  - `<h1>Members and seats</h1>`
  - subtitle: "`N` member(s) · active `A` · invited `I` · suspended `S` ·
    deactivated `D`"
  - `.table` (`data-testid="tenant-members-table"`) — columns **Member**,
    **Roles**, **Groups**, **Licenses**, **Status**, **Last seen**; each `<tr>`
    has `data-member-id` and `data-member-status`

## States

- [ ] **Loading** — N/A; server component renders the fixture in one pass
- [ ] **Empty** — N/A in practice; `seedMembers()` always returns 4 members.
      (Code would render an empty `<tbody>` and "0 members" if the list were
      empty, but that path is unreachable with the fixture.)
- [ ] **Populated (short)** — 4 members render: `op-jane` (active,
      tenant-admin), `op-luis` (active, reviewer), `op-sara` (invited, analyst,
      licenses → "unallocated"), `op-alex` (suspended, reviewer)
- [ ] **Populated (long)** — N/A; fixture is fixed at 4 rows (no pagination)
- [ ] **Error (recoverable)** — N/A; no runtime query
- [ ] **Error (unrecoverable)** — no session →
      `redirect('/unauthorized?reason=missing-session&returnTo=/members')`
- [ ] **Offline** — N/A for data (SSR-rendered)
- [ ] **Gated** — `getTenantServerSession()` null → redirect to `/unauthorized`;
      sidebar renders **Locked** when `canEnterTenantWorkspace` is false
- [ ] **Standalone PWA** — verify; no manifest/SW confirmed in source

## Interactions

The `<main>` content is a static table — **no buttons, inputs, sorting, or row
actions**. All interactivity is shell chrome:

- [ ] **Skip to main content** (link, `TenantShell`) → anchors `#tenant-main`
- [ ] **Sidebar workspace links** (links, `TenantSidebar`) → navigate accessible
      workspaces; `aria-current="page"` on current; **Locked** span when gated
- [ ] **Member rows** — non-interactive; `userId` rendered as `code`, status as
      a `.statusBadge` keyed by `status_<status>` class; empty groups → "—",
      empty licenses → "unallocated", null last-seen → "—"

## Data & contracts

- **Reads**: no BFF/network read. `seedMembers(tenantId)` returns a
  deterministic in-page `readonly TenantMember[]` fixture; counts derived by
  filtering on `member.status`. `lastSeenUnixSeconds` formatted via
  `new Date(...).toLocaleString()`.
- **Writes**: _None._
- **Realtime**: _None._
- **Caching**: SSR (server component); deterministic per `tenantId`
- **Auth/role check**: `getTenantServerSession()` in page; middleware gate

Contract: `TenantMember` from `@oshun/tenant-console`
(`libs/oshun/tenant-console`). Doc comment in source states the fixture is
shaped exactly like the live `TenantMember` contract so a future BFF members
route is a single import change.

## Cross-references

- Workspace definition: `libs/oshun/navigation/src/tenant-ia.ts` (`members` —
  group `core`, `requiredScopes: ['tenant:admin:*']`, `iconToken: 'users'`)
- Shell: `TenantShell.tsx`, `TenantSidebar.tsx`, `TenantHeader.tsx`
- Sibling routes: [roles.md](roles.md) (roles assigned to members),
  [integrations.md](integrations.md), [notifications.md](notifications.md),
  [policy.md](policy.md), [status.md](status.md)
- Conventions:
  [../WALKTHROUGH/00-conventions.md](../WALKTHROUGH/00-conventions.md)

## Open questions / known gaps

- [ ] No BFF members route is wired for tenant scope yet (explicit fixture per
      source doc comment). Document the live endpoint when added.
- [ ] No member detail view, invite, suspend, or seat-reallocation actions exist
      on this route — verify whether they are planned elsewhere.
- [ ] `.toLocaleString()` renders against the server locale/timezone; verify
      intended display timezone for "Last seen".
