Members table
ProMembers, 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.
Preview
Install
With the Candor CLI
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/members-table
Brings in: badge, dropdown-menu, environment-band, locale, locked, relative-time, select, team, utils · npm: lucide-react@^1.67.0
Usage
"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>
| Prop | Type | Default | Description |
|---|---|---|---|
| membersrequired | Member[] | — | The account's members, one row each. |
| invites | Invite[] | undefined | [] | Invites sent; pending ones are listed under the members. |
| environmentsrequired | Environment[] | — | The environments, one role column each. |
| rolesrequired | 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. |
| viewerrequired | 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 | {} | 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 | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under membersTable in <LocaleProvider strings={{ … }}>; the table is exported as membersTableStrings.
| Key | English |
|---|---|
| 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
{ id: string; owner?: boolean | undefined }