Skip to contentVibraUI
Part of the People dashboardinstalls at /people/directory

Team directory

Everyone with a seat in a workspace: how many people against how many the plan covers, a directory ordered by role, name or activity, an invite dialog, and removal behind a confirmation.

Open the live page

The page is a server component inside AppShell, with the People dashboard's nav.ts describing the navigation as plain data. The directory is db.members — the role, the status and the two-factor flag each person already carries — and the counts above it are that table grouped. The tier is read off the same db.subscriptions row /billing reads, and the seats sentence is built by one helper both pages render, so neither can name a different plan or a different number of filled seats: a person in this workspace and a seat on the organisation's bill are different things, and the page says both. actions.ts holds both mutations, a 'use server' file whose every export is an async function returning Result: invite writes a real db.invitations row dated from REFERENCE_DATE, which the invitations block then lists, and removeMember takes a seat back but refuses the owner's. A rejected invitation is reported on the field that caused it, with the input marked invalid; a sent one replaces the form with a confirmation whose heading takes focus. A refused removal is announced above the table rather than swallowed when the dialog closes. Composes AppShell, PageHeader, StatCardGroup, StatCard, FormSection, SortSelect, SimpleTable, UserCell, StatusBadge, BooleanCell, DateCell, ConfirmDialog, Dialog, FormRow, Input, NativeSelect and AsyncButton.

Preview

Install

npx shadcn@latest add @vibra/people-directory

Needs the @vibra registry in your components.json — set it up once.

Source

app/people/directory/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { invite, removeMember, signOut } from "./actions"
import { InvitePeople } from "./components/invite-dialog"
import { TeamDirectory } from "./components/team-directory"
import { TeamSummary } from "./components/team-summary"
import {
  INVITE_ROLES,
  currentUser,
  members,
  peopleSummary,
  seatsSummary,
  shellNotifications,
  summary,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/people/nav"

/**
 * The team directory. The page is a server component inside the shell: it
 * reads the people through `db` and hands the table the rows plus the two
 * server actions that change them, so the sort, the dialog and the removal
 * confirmation are the only client state.
 */
export default function TeamMembersPage() {
  const team = summary()

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.directory}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Team"
        description="Who has a seat in this workspace, what they may do, and who has been asked to join."
        meta={`${peopleSummary()} · ${seatsSummary()}`}
        actions={<InvitePeople roles={INVITE_ROLES} onInvite={invite} />}
      />

      <TeamSummary summary={team} />

      <TeamDirectory rows={members()} onRemove={removeMember} />
    </AppShell>
  )
}
app/people/directory/data.ts
/**
 * What this page reads, and the two things it changes.
 *
 * The directory is `db.members` — the workspace's own people, one row each,
 * with the role and status they already carry. Nothing about a person is
 * invented here: the counts under the title are that table grouped, and the
 * tier is read off the same `db.subscriptions` row `/billing` reads, so the two
 * pages cannot name different plans. Seats are the organisation's, counted
 * across the workspaces it still runs and stated in the same words `/billing`
 * uses — a person in this workspace and a seat on the bill are different
 * things, and saying both in one sentence is what keeps them from being read
 * as the same number.
 *
 * `invite` and `removeMember` are the mutations, server actions returning
 * `Result`. An invitation is a real `db.invitations` row dated from
 * `REFERENCE_DATE`, which is what the invitations page then lists.
 */
import { getInitials } from "@/lib/format"
import {
  db,
  type Invitation,
  type Member,
} from "@/lib/sample-data"

export type MemberRow = {
  id: string
  name: string
  email: string
  avatarUrl?: string
  role: Member["role"]
  status: Member["status"]
  twoFactor: boolean
  joinedAt: Date
  lastActiveAt: Date
}

/** Everyone in the workspace, in the order the table first shows them. */
export function members(): MemberRow[] {
  return db.members.all().map((member) => ({
    id: member.id,
    name: member.name,
    email: member.email,
    avatarUrl: member.avatarUrl,
    role: member.role,
    status: member.status,
    twoFactor: member.twoFactor,
    joinedAt: member.joinedAt,
    lastActiveAt: member.lastActiveAt,
  }))
}

export type TeamSummary = {
  people: number
  active: number
  awaiting: number
  seatsIncluded: number
  planName: string
}

// Every read is per call, never held at module scope: a workspace closed or a
// plan raised since the server started is what the next render shows.

/** The workspaces the organisation still runs: a closed account fills no seats. */
const liveWorkspaces = () =>
  db.customers
    .all()
    .filter((customer) => customer.company.startsWith("Northwind ") && customer.status !== "churned")

function accountRow() {
  const live = liveWorkspaces()
  return live.find((customer) => customer.company === "Northwind Analytics") ?? live[0]
}

/** The organisation, as both `/billing` and `/people/directory` state it, counted per call. */
export function organisation(): { name: string; workspaces: number; seatsFilled: number } {
  const live = liveWorkspaces()
  return {
    name: "Northwind",
    workspaces: live.length,
    seatsFilled: live.reduce((total, customer) => total + customer.seats, 0),
  }
}

/**
 * The seats sentence, written once. `/billing` and `/people/directory` both render it, so
 * neither can drift from the other about how many seats are filled.
 */
export function seatsSummary(): string {
  const { seatsFilled, workspaces } = organisation()
  return `${seatsFilled} seats filled across the organisation's ${workspaces} workspaces`
}

/** The other half of it: the people this workspace holds, not the organisation. */
export function peopleSummary(): string {
  return `${db.members.all().length} people in this workspace`
}

// The tier is read off the subscription, the same row `/billing` reads, rather
// than guessed from headcount — so the two pages cannot name different plans.
// Resolved per call: the repository replaces a row rather than editing it.
function planRow() {
  const subscription =
    db.subscriptions.all().find((row) => row.customerId === accountRow().id) ?? db.subscriptions.all()[0]
  return db.plans.all().find((plan) => plan.id === subscription.planId) ?? db.plans.all()[0]
}

/** The line under the title: how many people, how many seats the tier covers. */
export function summary(): TeamSummary {
  const rows = db.members.all()
  const plan = planRow()

  return {
    people: rows.length,
    active: rows.filter((row) => row.status === "active").length,
    // A seat that has been offered and not taken: the members table records
    // one, and db.invitations records the other.
    awaiting:
      rows.filter((row) => row.status === "invited").length +
      db.invitations.all().filter((row) => row.status === "pending").length,
    seatsIncluded: plan.seatsIncluded,
    planName: plan.name,
  }
}

/** The roles an invitation may grant. There is only ever one owner. */
export const INVITE_ROLES: Invitation["role"][] = ["admin", "member", "viewer"]

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/people/directory/actions.ts
"use server"

/**
 * Everything this page changes. A `"use server"` file rather than a directive
 * inside each function: an inline one is only legal in a module the bundler
 * knows is server-only, and `data.ts` is imported for its types by components
 * that are not. Every export here is an async function returning `Result`.
 */
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { asString, db, fields, REFERENCE_DATE, type Result } from "@/lib/sample-data"
import { isEmail } from "@/lib/validation"

import { INVITE_ROLES } from "./data"

const DAY_MS = 86_400_000

/** How long an invitation stays good for, in days. */
const INVITE_WINDOW = 14

/**
 * Offer a seat. The invitation is a real row, dated from REFERENCE_DATE, so
 * the invitations page lists it the moment this returns.
 */
export async function invite(sent: {
  email: string
  role: string
}): Promise<Result<{ id: string; email: string; role: string }>> {
  const input = fields(sent)
  const email = asString(input.email).trim().toLowerCase()
  if (!isEmail(email)) {
    return {
      ok: false,
      error: { code: "invalid_email", message: "That is not an email address.", field: "email" },
    }
  }
  if (db.members.all().some((member) => member.email.toLowerCase() === email)) {
    return {
      ok: false,
      error: {
        code: "already_a_member",
        message: `${email} is already on the team.`,
        field: "email",
      },
    }
  }
  if (
    db.invitations.all().some((row) => row.email.toLowerCase() === email && row.status === "pending")
  ) {
    return {
      ok: false,
      error: {
        code: "already_invited",
        message: `${email} already has an invitation waiting.`,
        field: "email",
      },
    }
  }

  const role = INVITE_ROLES.find((option) => option === input.role)
  if (!role) {
    return {
      ok: false,
      error: { code: "unknown_role", message: "Pick the access this seat grants.", field: "role" },
    }
  }

  const owner = db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
  const created = await db.invitations.create({
    email,
    role,
    invitedBy: owner.id,
    sentAt: REFERENCE_DATE,
    expiresAt: new Date(REFERENCE_DATE.getTime() + INVITE_WINDOW * DAY_MS),
    status: "pending",
  })
  if (!created.ok) return created

  return { ok: true, data: { id: created.data.id, email, role } }
}

/** Take a seat back. The owner's is the one seat nobody can take. */
export async function removeMember(id: string): Promise<Result<{ id: string; name: string }>> {
  const member = db.members.all().find((row) => row.id === id)
  if (!member) {
    return { ok: false, error: { code: "not_found", message: "That person has already gone." } }
  }
  if (member.role === "owner") {
    return {
      ok: false,
      error: {
        code: "owner_stays",
        message: `${member.name} owns this workspace. Transfer it before removing them.`,
      },
    }
  }

  const removed = await db.members.remove(id)
  if (!removed.ok) return removed
  return { ok: true, data: { id, name: member.name } }
}

/** Signing out is the shell's one action, and a server action for the same reason. */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/people/directory/components/invite-dialog.tsx
"use client"

import * as React from "react"
import { UserRoundPlusIcon } from "lucide-react"

import { AsyncButton } from "@/components/ui/async-button"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import { FormRow } from "@/components/ui/form-section"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"

export type InviteSent =
  | { ok: true; data: { id: string; email: string; role: string } }
  | { ok: false; error: { code: string; message: string; field?: string } }

type InviteInput = { email: string; role: string }

/** The header control: the button, and the form it opens. */
export function InvitePeople({
  roles,
  onInvite,
}: {
  roles: string[]
  onInvite: (input: InviteInput) => Promise<InviteSent>
}) {
  const [open, setOpen] = React.useState(false)

  return (
    <>
      <Button size="sm" onClick={() => setOpen(true)}>
        <UserRoundPlusIcon data-icon="inline-start" />
        Invite people
      </Button>
      {open ? <InviteForm roles={roles} onInvite={onInvite} onClose={() => setOpen(false)} /> : null}
    </>
  )
}

/**
 * Mounted only while it is open, so every field is re-armed by construction.
 * A rejected invitation is reported on the field that caused it; a sent one
 * replaces the form with a confirmation whose heading takes focus.
 */
function InviteForm({
  roles,
  onInvite,
  onClose,
}: {
  roles: string[]
  onInvite: (input: InviteInput) => Promise<InviteSent>
  onClose: () => void
}) {
  const id = React.useId()
  const [email, setEmail] = React.useState("")
  const [role, setRole] = React.useState(roles[1] ?? roles[0])
  const [pending, setPending] = React.useState(false)
  const [error, setError] = React.useState<{ message: string; field?: string } | null>(null)
  const [sent, setSent] = React.useState<string | null>(null)
  const headingRef = React.useRef<HTMLHeadingElement>(null)

  React.useEffect(() => {
    if (sent) headingRef.current?.focus()
  }, [sent])

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setError(null)
    setPending(true)
    const result = await onInvite({ email, role })
    setPending(false)
    if (!result.ok) {
      setError(result.error)
      return
    }
    setSent(result.data.email)
  }

  const fieldError = (field: string) => (error?.field === field ? error.message : undefined)

  return (
    <Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle ref={headingRef} tabIndex={-1} className="outline-none">
            {sent ? "Invitation sent" : "Invite someone"}
          </DialogTitle>
          <DialogDescription>
            {sent
              ? `${sent} has fourteen days to accept. Until then it sits on the invitations page, where it can be resent or revoked.`
              : "They get an email with a link. The seat is only billed once they accept."}
          </DialogDescription>
        </DialogHeader>

        {sent ? (
          <DialogFooter>
            <Button onClick={onClose}>Done</Button>
          </DialogFooter>
        ) : (
          // noValidate: the action is what decides whether an address is
          // usable, and the browser refusing to submit would hide its answer.
          <form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
            <FormRow label="Email address" htmlFor={`${id}-email`} error={fieldError("email")}>
              <Input
                id={`${id}-email`}
                type="email"
                value={email}
                onChange={(event) => setEmail(event.target.value)}
                autoComplete="off"
                placeholder="name@example.com"
              />
            </FormRow>

            <FormRow
              label="Access"
              htmlFor={`${id}-role`}
              description="What they may do once they accept. It can be changed later."
              error={fieldError("role")}
            >
              <NativeSelect
                id={`${id}-role`}
                className="w-full"
                value={role}
                onChange={(event) => setRole(event.target.value)}
              >
                {roles.map((option) => (
                  <NativeSelectOption key={option} value={option} className="capitalize">
                    {option}
                  </NativeSelectOption>
                ))}
              </NativeSelect>
            </FormRow>

            {error && !error.field ? (
              <p role="alert" className="text-sm text-danger">
                {error.message}
              </p>
            ) : null}

            <DialogFooter>
              <Button type="button" variant="outline" onClick={onClose}>
                Cancel
              </Button>
              <AsyncButton type="submit" loading={pending}>
                Send invitation
              </AsyncButton>
            </DialogFooter>
          </form>
        )}
      </DialogContent>
    </Dialog>
  )
}
app/people/directory/components/team-directory.tsx
"use client"

import * as React from "react"
import { UserRoundMinusIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { FormSection } from "@/components/ui/form-section"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { SortSelect, type SortValue } from "@/components/ui/sort-select"
import { StatusBadge } from "@/components/ui/status-badge"
import { BooleanCell, DateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"

import type { MemberRow } from "../data"

export type MemberRemoved =
  | { ok: true; data: { id: string; name: string } }
  | { ok: false; error: { code: string; message: string; field?: string } }

// Two of the three member states are not in StatusBadge's own vocabulary.
const STATUS_MAP = { invited: "warning", deactivated: "neutral" } as const

// Most powerful first, which is what "role" means as a sort.
const ROLE_ORDER: MemberRow["role"][] = ["owner", "admin", "member", "viewer"]

const SORT_OPTIONS = [
  { value: "role", label: "Role" },
  { value: "name", label: "Name" },
  { value: "lastActiveAt", label: "Last active" },
  { value: "joinedAt", label: "Joined" },
]

function compare(a: MemberRow, b: MemberRow, key: string): number {
  if (key === "name") return a.name.localeCompare(b.name)
  if (key === "lastActiveAt") return a.lastActiveAt.getTime() - b.lastActiveAt.getTime()
  if (key === "joinedAt") return a.joinedAt.getTime() - b.joinedAt.getTime()
  return ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role)
}

/**
 * The directory, the control that orders it, and the one destructive action on
 * the page. A refused removal is said out loud above the table rather than
 * swallowed when the dialog closes.
 */
export function TeamDirectory({
  rows,
  onRemove,
}: {
  rows: MemberRow[]
  onRemove: (id: string) => Promise<MemberRemoved>
}) {
  const [people, setPeople] = React.useState(rows)
  const [sort, setSort] = React.useState<SortValue>({ key: "role", direction: "asc" })
  const [error, setError] = React.useState<string | null>(null)
  const [removed, setRemoved] = React.useState<string | null>(null)

  async function remove(member: MemberRow) {
    setError(null)
    const result = await onRemove(member.id)
    if (!result.ok) {
      setRemoved(null)
      setError(result.error.message)
      return
    }
    setPeople((current) => current.filter((row) => row.id !== member.id))
    setRemoved(result.data.name)
  }

  const columns: SimpleTableColumn<MemberRow>[] = [
    {
      key: "name",
      header: "Member",
      cell: (member) => <UserCell size="sm" name={member.name} email={member.email} src={member.avatarUrl} />,
    },
    {
      key: "role",
      header: "Role",
      cell: (member) => <span className="text-muted-foreground capitalize">{member.role}</span>,
    },
    {
      key: "status",
      header: "Status",
      cell: (member) => <StatusBadge status={member.status} map={STATUS_MAP} />,
    },
    {
      key: "twoFactor",
      header: "Two-factor",
      cell: (member) => <BooleanCell value={member.twoFactor} trueLabel="On" falseLabel="Off" />,
    },
    {
      key: "lastActiveAt",
      header: "Last active",
      align: "right",
      cell: (member) => (
        <DateCell date={member.lastActiveAt} relative className="text-muted-foreground" />
      ),
    },
    {
      key: "actions",
      header: <span className="sr-only">Actions</span>,
      align: "right",
      width: "56px",
      cell: (member) => (
        <ConfirmDialog
          variant="destructive"
          title={`Remove ${member.name}?`}
          description="They lose access to this workspace at once, and the seat goes back to the plan."
          confirmText="Remove"
          onConfirm={() => remove(member)}
          trigger={
            <Button variant="ghost" size="icon-sm" aria-label={`Remove ${member.name}`}>
              <UserRoundMinusIcon />
            </Button>
          }
        />
      ),
    },
  ]

  const sorted = [...people].sort(
    (a, b) => compare(a, b, sort.key) * (sort.direction === "asc" ? 1 : -1)
  )

  return (
    <FormSection
      title="Directory"
      description="Everyone with a seat in this workspace, and what each of them may do."
      actions={
        <SortSelect
          size="sm"
          aria-label="Sort the directory by"
          options={SORT_OPTIONS}
          value={sort}
          onValueChange={setSort}
        />
      }
    >
      {error ? (
        <p role="alert" className="text-sm text-danger">
          {error}
        </p>
      ) : null}
      {removed ? (
        <p role="status" className="text-sm text-muted-foreground">
          {removed} no longer has access.
        </p>
      ) : null}

      <SimpleTable size="sm" columns={columns} rows={sorted} rowKey="id" hoverable />
    </FormSection>
  )
}
app/people/directory/components/team-summary.tsx
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import type { TeamSummary as Summary } from "../data"

/** Nothing on this row holds state, so it stays a plain function component. */
export function TeamSummary({ summary }: { summary: Summary }) {
  return (
    <StatCardGroup data-widget="widget-people-directory-team-summary" columns={3} divided>
      <StatCard
        label="People"
        value={String(summary.people)}
        description={`${summary.active} of them active`}
      />
      <StatCard
        label="Awaiting acceptance"
        value={String(summary.awaiting)}
        description="Seats offered and not yet taken"
      />
      <StatCard
        label="Seats on the plan"
        value={String(summary.seatsIncluded)}
        description={`Included with ${summary.planName}`}
      />
    </StatCardGroup>
  )
}