# Members table

Members, their role on each environment, and invites still pending. A cell the viewer can't change says why, in the app's words (`lockedCell`). Role changes the app sends for approval show as pending until decided. Who may change what is the app's policy; the table only shows it.

- Registry name: `members-table` (`@candor/members-table`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/members-table

## Install

```bash
npx candor-ui add members-table
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/members-table`.
It brings in: badge, dropdown-menu, environment-band, locale, locked, relative-time, select, team, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { MembersTable } from "@/components/members-table"
import type { Environment, Invite, Member, Role } from "@/lib/team"

const environments: Environment[] = [
  { id: "env-prod", name: "Acme API", production: true, hue: 3, connection: "connected" },
  { id: "env-staging", name: "Acme API", detail: "Staging", production: false, hue: 2, connection: "connected" },
]

const roles: Role[] = [
  { id: "contributor", label: "Contributor", rank: 1 },
  { id: "approver", label: "Approver", rank: 2 },
  { id: "admin", label: "Admin", rank: 3 },
]

const initialMembers: Member[] = [
  { id: "u-priya", name: "Priya", email: "priya@acme.example", owner: true, joinedAt: "2026-09-01T10:00:00Z", roles: {} },
  { id: "u-dana", name: "Dana", email: "dana@acme.example", joinedAt: "2026-09-20T10:00:00Z", roles: { "env-prod": "approver", "env-staging": "admin" } },
  { id: "u-jonas", name: "Jonas", email: "jonas@acme.example", joinedAt: "2026-10-28T10:00:00Z", roles: { "env-staging": "contributor" } },
  { id: "u-mei", email: "mei@acme.example", joinedAt: "2026-11-04T10:00:00Z", roles: {} },
]

const initialInvites: Invite[] = [
  { id: "inv-1", email: "sam@acme.example", invitedBy: "Dana", createdAt: "2026-11-03T10:00:00Z", expiresAt: new Date(Date.now() + 5 * 86_400_000).toISOString(), status: "pending", roles: { "env-staging": "contributor" } },
]

export default function Example() {
  const [members, setMembers] = React.useState(initialMembers)
  const [invites, setInvites] = React.useState(initialInvites)
  const [pending, setPending] = React.useState<Record<string, Record<string, string | null>>>({})
  return (
    <MembersTable
      className="w-full"
      members={members}
      invites={invites}
      environments={environments}
      roles={roles}
      ownerRole="admin"
      viewer={{ id: "u-priya", owner: true }}
      lockedCell={(m) => (m.owner ? "The owner is Admin on every environment." : undefined)}
      pending={pending}
      onSetRole={(memberId, environmentId, role) => {
        // Your policy: here, anything above Contributor waits for approval.
        if (role === "approver" || role === "admin") {
          setPending((p) => ({ ...p, [memberId]: { ...p[memberId], [environmentId]: role } }))
          return
        }
        setMembers((ms) =>
          ms.map((m) => {
            if (m.id !== memberId) return m
            const next = { ...m.roles }
            if (role) next[environmentId] = role
            else delete next[environmentId]
            return { ...m, roles: next }
          })
        )
      }}
      onRemove={(memberId) => setMembers((ms) => ms.filter((m) => m.id !== memberId))}
      onRevokeInvite={(id) => setInvites((xs) => xs.filter((x) => x.id !== id))}
    />
  )
}
```

## <MembersTable>

Props:

- `members: Member[]`: The account's members, one row each.
- `invites?: Invite[] | undefined` (default `[]`): Invites sent; pending ones are listed under the members.
- `environments: Environment[]`: The environments, one role column each.
- `roles: Role[]`: The roles a member can hold, lowest first.
- `ownerRole?: string | undefined`: The role id shown for the owner on every environment, if the owner has one implicitly.
- `viewer: Viewer`: Who's looking: marks their row as "(you)", and only an owner can remove members.
- `lockedCell?: ((member: Member, environment: Environment) => string | undefined) | undefined`: Why the viewer can't change this cell, or nothing if they can.
- `pending?: Record<string, Record<string, string | null>> | undefined` (default `{}`): Role changes waiting for approval: member id → environment id → requested role id (null = remove).
- `onSetRole?: ((memberId: string, environmentId: string, roleId: string | null) => void) | undefined`: Makes role cells editable; called with the member id, environment id and new role id (null = no role).
- `onRemove?: ((memberId: string) => void) | undefined`: Gives the owner a Remove action per member; called with the member id.
- `onRevokeInvite?: ((inviteId: string) => void) | undefined`: Shows Revoke on pending invites; called with the invite id.
- `className?: string | undefined`

## Strings (`membersTable`)

The fixed words, English by default. Override any of them under `membersTable` in `<LocaleProvider strings={{ membersTable: { … } }}>`; the table is exported as `membersTableStrings`.

- `label`: Members
- `member`: Member
- `prod`: Prod
- `actions`: Actions
- `owner`: Owner
- `you`: (you)
- `noRole`: No role
- `roleOn`: {member}’s role on {place} {detail}
- `roleOnProduction`: {member}’s role on {place} production
- `waiting`: {role} waiting for approval
- `actionsFor`: Actions for {member}
- `remove`: Remove from account
- `invitedBy`: Invited by {name} · expires {expires}
- `onJoining`: {role} on joining
- `revoke`: Revoke

## Types

### Viewer

```ts
{ id: string; owner?: boolean | undefined }
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
