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

Permissions matrix

Roles down the side, resources across the top, one checkbox per pair: a panel spelling out what a ticked row actually grants, and a save bar that appears the moment a box changes.

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. db.roles is the whole page: the resources across the top are the keys those rows already use, in the order the first role names them, and the headcount beside each role is the role's own, which is what keeps this page and the directory agreeing. A box says whether the role reaches the resource at all; the detail panel says exactly what that reach is, verb by verb. actions.ts holds the mutation, a 'use server' file whose every export is an async function: savePermissions is a server action returning Result, and it refuses two things before writing anything — the role that administers everything cannot be edited at all, and a role people actually hold cannot be left with access to nothing — so a rejected save changes none of it and the edits stay on screen to fix. What is stored and what is being edited are held apart, so discarding puts every box back. Composes AppShell, PageHeader, FormSection, SimpleTable, Checkbox, Button, Widget, DescriptionList, Badge and ActionBar.

Preview

Install

npx shadcn@latest add @vibra/people-roles

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

Source

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

import { savePermissions, signOut } from "./actions"
import { RolesView } from "./components/roles-view"
import {
  currentUser,
  lockedRoleId,
  resourceLabel,
  resources,
  roles,
  shellNotifications,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/people/nav"

/**
 * What each role may reach. The page is a server component inside the shell:
 * it reads the roles and the resources they name through `db` and hands the
 * matrix the rows plus the server action that writes them back, so the draft
 * and the save bar are the only client state.
 */
export default function TeamRolesPage() {
  const rows = roles()
  const columns = resources()
  // Assignments, not people: a role like Billing cuts across the four a
  // member row carries, so the same person can hold more than one.
  const assignments = rows.reduce((total, role) => total + role.members, 0)

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.roles}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Roles"
        description="Every named permission set in this workspace, and which parts of the product each one reaches."
        meta={`${rows.length} roles · ${columns.length} resources · ${assignments} assignments`}
      />

      <RolesView
        roles={rows}
        resources={columns}
        labels={Object.fromEntries(columns.map((resource) => [resource, resourceLabel(resource)]))}
        lockedRoleId={lockedRoleId()}
        onSave={savePermissions}
      />
    </AppShell>
  )
}
app/people/roles/data.ts
/**
 * What this page reads, and the one thing it changes.
 *
 * `db.roles` is the whole page: five named permission sets, each carrying the
 * verbs it grants per resource and how many people hold it. The resources
 * along the top are not listed here — they are the keys those rows already
 * use, in the order the first role names them — and the headcounts are the
 * roles' own, which is what keeps this page and the directory agreeing.
 *
 * `savePermissions` is the mutation, a server action returning `Result`. Rows
 * handed out by a repository are the store's own objects, so the permission
 * maps below are copied on the way out and only ever written back through the
 * repository.
 */
import { getInitials } from "@/lib/format"
import { db, type Member, type Role } from "@/lib/sample-data"

export type Verb = Role["permissions"][string][number]

export type RoleRow = {
  id: string
  name: string
  description: string
  members: number
  permissions: Record<string, Verb[]>
}

/** Every role, with its own copy of the permissions the store holds. */
export function roles(): RoleRow[] {
  return db.roles.all().map((role) => ({
    id: role.id,
    name: role.name,
    description: role.description,
    members: role.members,
    permissions: Object.fromEntries(
      Object.entries(role.permissions).map(([resource, verbs]) => [resource, [...verbs]])
    ),
  }))
}

/** The resources the roles grant on, in the order they name them. */
export function resources(): string[] {
  const seen: string[] = []
  for (const role of db.roles.all()) {
    for (const resource of Object.keys(role.permissions)) {
      if (!seen.includes(resource)) seen.push(resource)
    }
  }
  return seen
}

/** "api_keys" → "API keys": a short segment is an initialism, the rest is a word. */
export function resourceLabel(resource: string): string {
  const words = resource.split("_").map((word, index) => {
    if (word.length <= 3) return word.toUpperCase()
    return index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
  })
  return words.join(" ")
}

/**
 * The role nobody may edit: whichever one already administers everything.
 * Derived rather than named, so renaming the role in db does not strand this.
 */
export function lockedRoleId(): string {
  const rows = db.roles.all()
  const full = rows.find((role) =>
    Object.values(role.permissions).every((verbs) => verbs.includes("admin"))
  )
  return (full ?? rows[0]).id
}

export type PermissionChange = { roleId: string; permissions: Record<string, Verb[]> }

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/roles/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 { db, invalidInput, isRecord, oneOf, type Result } from "@/lib/sample-data"

import { lockedRoleId, resources, type PermissionChange, type Verb } from "./data"

/** What a role can be granted on a resource. */
const VERBS: readonly Verb[] = ["read", "write", "delete", "admin"]

/**
 * A change's matrix as the store keeps it — one entry per resource the roles
 * grant on, each a list of known verbs, once each — or undefined when the
 * caller sent anything else: a string, an array, a resource nobody grants on,
 * a verb that is not one. It used to be stored whatever its shape.
 */
function matrixOf(value: unknown): Record<string, Verb[]> | undefined {
  if (!isRecord(value)) return undefined
  const known = resources()
  const matrix: Record<string, Verb[]> = {}
  for (const [resource, verbs] of Object.entries(value)) {
    if (!known.includes(resource) || !Array.isArray(verbs) || !verbs.every((verb) => oneOf(verb, VERBS))) return undefined
    matrix[resource] = VERBS.filter((verb) => verbs.includes(verb))
  }
  return matrix
}

/**
 * Write the matrix back. A role people actually hold cannot be left with
 * access to nothing, and the role that administers everything cannot be
 * touched at all — both are refused before anything is written, so a rejected
 * save changes none of it.
 */
export async function savePermissions(
  changes: PermissionChange[]
): Promise<Result<{ saved: number }>> {
  if (!Array.isArray(changes) || changes.length > db.roles.all().length) {
    return invalidInput("Save the roles' permissions as the matrix sends them.")
  }
  if (changes.length === 0) {
    return { ok: false, error: { code: "nothing_to_save", message: "Nothing has changed." } }
  }

  const locked = lockedRoleId()
  const saves: { roleId: string; permissions: Record<string, Verb[]> }[] = []
  for (const change of changes) {
    const permissions = isRecord(change) ? matrixOf(change.permissions) : undefined
    if (!permissions) return invalidInput("Grant each role known actions on known resources.", "permissions")
    const role = db.roles.all().find((row) => row.id === change.roleId)
    if (!role) {
      return {
        ok: false,
        error: { code: "not_found", message: "That role no longer exists." },
      }
    }
    if (role.id === locked) {
      return {
        ok: false,
        error: {
          code: "role_locked",
          message: `${role.name} administers everything, and that cannot be taken away.`,
        },
      }
    }
    const grantsNothing = Object.values(permissions).every((verbs) => verbs.length === 0)
    if (grantsNothing && role.members > 0) {
      return {
        ok: false,
        error: {
          code: "role_needs_access",
          message: `${role.name} is held by ${role.members} ${
            role.members === 1 ? "person" : "people"
          }, so it has to keep access to something.`,
        },
      }
    }
    saves.push({ roleId: role.id, permissions })
  }

  for (const { roleId, permissions } of saves) {
    const saved = await db.roles.update(roleId, { permissions })
    if (!saved.ok) return saved
  }
  return { ok: true, data: { saved: saves.length } }
}

/** 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/roles/components/permission-matrix.tsx
"use client"

import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { FormSection } from "@/components/ui/form-section"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"

import type { RoleRow, Verb } from "../data"

const LOCK_NOTE_ID = "permission-matrix-lock-note"

/**
 * Roles down the side, resources across the top, one checkbox per pair. The
 * box says whether the role reaches the resource at all; the panel beside it
 * says exactly what that reach is.
 */
export function PermissionMatrix({
  roles,
  resources,
  labels,
  draft,
  lockedRoleId,
  selectedRoleId,
  onSelect,
  onToggle,
  actions,
}: {
  roles: RoleRow[]
  resources: string[]
  labels: Record<string, string>
  draft: Record<string, Record<string, Verb[]>>
  lockedRoleId: string
  selectedRoleId: string
  onSelect: (roleId: string) => void
  onToggle: (roleId: string, resource: string) => void
  actions?: React.ReactNode
}) {
  const columns: SimpleTableColumn<RoleRow>[] = [
    {
      key: "name",
      header: "Role",
      cell: (role) => (
        <Button
          variant="ghost"
          size="sm"
          aria-pressed={role.id === selectedRoleId}
          onClick={() => onSelect(role.id)}
          className="-ml-2 flex-col items-start gap-0 py-1 aria-pressed:bg-brand-muted aria-pressed:shadow-[inset_2px_0_0_var(--brand)] rtl:aria-pressed:shadow-[inset_-2px_0_0_var(--brand)]"
        >
          <span className="font-medium">{role.name}</span>
          <span className="text-xs font-normal text-muted-foreground">
            {role.members} {role.members === 1 ? "person" : "people"}
          </span>
        </Button>
      ),
    },
    ...resources.map<SimpleTableColumn<RoleRow>>((resource) => ({
      key: resource,
      header: labels[resource] ?? resource,
      align: "center",
      cell: (role) => {
        const locked = role.id === lockedRoleId
        return (
          <span className="flex justify-center">
            <Checkbox
              checked={(draft[role.id]?.[resource] ?? []).length > 0}
              disabled={locked}
              // The primitive renders a span, so its own `disabled:` styling
              // never lands; the data attribute is what a span can carry.
              className="data-disabled:opacity-50"
              aria-label={`${role.name} reaches ${labels[resource] ?? resource}`}
              aria-describedby={locked ? LOCK_NOTE_ID : undefined}
              onCheckedChange={() => onToggle(role.id, resource)}
            />
          </span>
        )
      },
    })),
  ]

  return (
    <FormSection
      title="Permissions"
      description="A ticked box means the role reaches that part of the product at all. Untick it and the role loses it entirely."
      actions={actions}
    >
      <SimpleTable size="sm" columns={columns} rows={roles} rowKey="id" hoverable />
      <p id={LOCK_NOTE_ID} className="text-xs text-muted-foreground">
        The role that administers everything is fixed: a workspace that could lock its own owner out
        would have no way back in.
      </p>
    </FormSection>
  )
}
app/people/roles/components/role-detail.tsx
"use client"

import { Badge } from "@/components/ui/badge"
import { DescriptionList } from "@/components/ui/description-list"
import { Widget } from "@/components/ui/widget"

import type { RoleRow, Verb } from "../data"

/** Exactly what the ticked boxes on the selected role's row amount to. */
export function RoleDetail({
  role,
  resources,
  labels,
  permissions,
}: {
  role: RoleRow
  resources: string[]
  labels: Record<string, string>
  permissions: Record<string, Verb[]>
}) {
  return (
    <Widget
      title="Role detail"
      description={`${role.name} · ${role.members} ${role.members === 1 ? "person" : "people"}`}
    >
      <div className="flex flex-col gap-4">
        <p className="text-sm text-muted-foreground">{role.description}</p>

        <DescriptionList
          size="sm"
          columns={3}
          items={resources.map((resource) => ({
            term: labels[resource] ?? resource,
            description: (permissions[resource] ?? []).length ? (
              <span className="flex flex-wrap gap-1">
                {(permissions[resource] ?? []).map((verb) => (
                  <Badge key={verb} variant="outline">
                    {verb}
                  </Badge>
                ))}
              </span>
            ) : (
              <span className="text-muted-foreground">No access</span>
            ),
          }))}
        />
      </div>
    </Widget>
  )
}
app/people/roles/components/roles-view.tsx
"use client"

import * as React from "react"

import { ActionBar } from "@/components/ui/action-bar"

import type { PermissionChange, RoleRow, Verb } from "../data"
import { PermissionMatrix } from "./permission-matrix"
import { RoleDetail } from "./role-detail"

export type PermissionsSaved =
  | { ok: true; data: { saved: number } }
  | { ok: false; error: { code: string; message: string; field?: string } }

type Draft = Record<string, Record<string, Verb[]>>

/** The permissions as the page is editing them, keyed role then resource. */
function toDraft(roles: RoleRow[]): Draft {
  return Object.fromEntries(roles.map((role) => [role.id, { ...role.permissions }]))
}

const key = (verbs: Verb[] | undefined) => (verbs ?? []).join(",")

/**
 * The matrix, the panel that explains a row of it, and the save bar that
 * appears the moment a box changes. What is stored and what is being edited
 * are held apart, so discarding puts every box back and a refused save leaves
 * the edits on screen to fix.
 */
export function RolesView({
  roles,
  resources,
  labels,
  lockedRoleId,
  onSave,
}: {
  roles: RoleRow[]
  resources: string[]
  labels: Record<string, string>
  lockedRoleId: string
  onSave: (changes: PermissionChange[]) => Promise<PermissionsSaved>
}) {
  const [saved, setSaved] = React.useState<Draft>(() => toDraft(roles))
  const [draft, setDraft] = React.useState<Draft>(() => toDraft(roles))
  const [selectedId, setSelectedId] = React.useState(roles[0]?.id ?? "")
  const [error, setError] = React.useState<string | null>(null)
  const [note, setNote] = React.useState<string | null>(null)
  const [saving, setSaving] = React.useState(false)

  const changed = roles.filter((role) =>
    resources.some((resource) => key(saved[role.id]?.[resource]) !== key(draft[role.id]?.[resource]))
  )
  const selected = roles.find((role) => role.id === selectedId) ?? roles[0]

  function toggle(roleId: string, resource: string) {
    setNote(null)
    setDraft((current) => {
      const permissions = current[roleId] ?? {}
      // A box that is on says the role reaches the resource; turning it on
      // gives the least that can mean, and turning it off takes all of it.
      const next: Verb[] = (permissions[resource] ?? []).length ? [] : ["read"]
      return { ...current, [roleId]: { ...permissions, [resource]: next } }
    })
  }

  async function save() {
    setError(null)
    setNote(null)
    setSaving(true)
    const result = await onSave(
      changed.map((role) => ({ roleId: role.id, permissions: draft[role.id] }))
    )
    setSaving(false)
    if (!result.ok) {
      setError(result.error.message)
      return
    }
    setSaved(draft)
    setNote(`Saved ${result.data.saved} ${result.data.saved === 1 ? "role" : "roles"}.`)
  }

  function discard() {
    setDraft(saved)
    setError(null)
    setNote(null)
  }

  return (
    <>
      <PermissionMatrix
        roles={roles}
        resources={resources}
        labels={labels}
        draft={draft}
        lockedRoleId={lockedRoleId}
        selectedRoleId={selected?.id ?? ""}
        onSelect={setSelectedId}
        onToggle={toggle}
        actions={
          // Always in the page, empty until a save lands: a live region that
          // arrives with its message is one a screen reader may never read.
          <span role="status" aria-live="polite" className="text-xs text-muted-foreground">
            {note}
          </span>
        }
      />

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

      {selected ? (
        <RoleDetail
          role={selected}
          resources={resources}
          labels={labels}
          permissions={draft[selected.id] ?? {}}
        />
      ) : null}

      <ActionBar
        open={changed.length > 0}
        message={`${changed.length} ${changed.length === 1 ? "role has" : "roles have"} unsaved changes.`}
        saving={saving}
        onSave={save}
        onDiscard={discard}
      />
    </>
  )
}