Permission Matrix
Roles across, permissions down, with inheritance, locked roles and one tab stop.
| Permission | Ownerall, locked | Admin6/7 granted | Editor3/7 granted | Viewer1/7 granted |
|---|---|---|---|---|
| Projects, all | ||||
| View projects | Owner has every permission; this cannot be changed. | via Viewer | via Viewer | |
| Edit projects | Owner has every permission; this cannot be changed. | via Editor | ||
| Delete projectsCannot be undone. | Owner has every permission; this cannot be changed. | |||
| Members, all | ||||
| Invite members | Owner has every permission; this cannot be changed. | via Editor | ||
| Remove members | Owner has every permission; this cannot be changed. | |||
| Billing, all | ||||
| View billing | Owner has every permission; this cannot be changed. | |||
| Change billing | Owner has every permission; this cannot be changed. |
Installation
pnpm dlx @dowel-ui/cli add permission-matrixInstalls checkbox as well, because this component imports it.
No npm packages are needed beyond what Dowel already requires.
Accessibility
A grid in the WAI-ARIA sense: role="grid" with one tab stop, arrow keys between cells, Home and End along a row and Ctrl with them across the whole matrix — the right call for a matrix that is operated, where it was the wrong one for a diff that is read. Every checkbox is named by both coordinates, "Delete projects for Editor", so a reader arriving by arrow key knows where they are without re-reading the headers. An inherited grant is a checked box that cannot be changed here, marked aria-disabled rather than disabled so it stays in the keyboard path, with the role it came from as its description. A locked role says in each cell's description that nothing can be changed. Group toggles are tri-state, and the column header counts each role's grants in text.
Props
PermissionMatrix
| Prop | Type | Default |
|---|---|---|
grants (required) | Grants | — |
label (required)Names the matrix: "Permissions for Acme". | string | — |
permissions (required) | Permission[] | — |
roles (required) | Role[] | — |
Plus every attribute of <table> except onChange.
Quality
8/8 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — passes
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — does not apply
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Source
This is exactly what dowel add permission-matrix writes into your project, with imports rewritten to your own path alias.
/**
* Roles, permissions and who has what — resolved, so the grid only draws.
*
* Pure, so inheritance and the group states are tested without rendering
* anything, and so a server can answer "may this role do this" from the same
* grants the matrix edits, with the same rule for inheritance.
*/
export interface Permission {
id: string;
label: string;
description?: string;
/** Permissions with the same group share a heading and an all-or-none toggle. */
group?: string;
}
export interface Role {
id: string;
label: string;
/** Roles whose grants this one includes. Resolved transitively. */
inherits?: string[];
/** Has every permission, and none of them can be changed. An Owner. */
locked?: boolean;
}
/** Explicit grants, by role id. Inherited ones are not listed here. */
export type Grants = Record<string, string[]>;
export interface CellState {
/** Whether the role can do it, by any route. */
granted: boolean;
/** The role it comes from, when it is not explicit here. */
inheritedFrom: Role | null;
/** Cannot be changed here — locked role, or inherited. */
fixed: boolean;
}
export type GroupState = "all" | "some" | "none";
function roleById(roles: Role[], id: string): Role | undefined {
return roles.find((role) => role.id === id);
}
/**
* The role a permission is inherited from, or null. Depth-first through
* `inherits`, cycle-safe, so two roles that inherit each other resolve rather
* than recurse forever — a configuration a form can produce by accident.
*/
export function inheritedFrom(
roles: Role[],
grants: Grants,
role: Role,
permissionId: string,
seen: Set<string> = new Set(),
): Role | null {
seen.add(role.id);
for (const parentId of role.inherits ?? []) {
if (seen.has(parentId)) continue;
const parent = roleById(roles, parentId);
if (!parent) continue;
if (parent.locked || (grants[parent.id] ?? []).includes(permissionId)) return parent;
const further = inheritedFrom(roles, grants, parent, permissionId, seen);
if (further) return further;
}
return null;
}
export function cellState(
roles: Role[],
grants: Grants,
role: Role,
permissionId: string,
): CellState {
if (role.locked) return { granted: true, inheritedFrom: null, fixed: true };
const explicit = (grants[role.id] ?? []).includes(permissionId);
if (explicit) return { granted: true, inheritedFrom: null, fixed: false };
const from = inheritedFrom(roles, grants, role, permissionId);
return { granted: from !== null, inheritedFrom: from, fixed: from !== null };
}
export function groupState(
roles: Role[],
grants: Grants,
role: Role,
permissionIds: string[],
): GroupState {
const granted = permissionIds.filter(
(id) => cellState(roles, grants, role, id).granted,
).length;
if (granted === 0) return "none";
return granted === permissionIds.length ? "all" : "some";
}
export function countGranted(
roles: Role[],
grants: Grants,
role: Role,
permissions: Permission[],
): number {
return permissions.filter(
(permission) => cellState(roles, grants, role, permission.id).granted,
).length;
}
export interface PermissionGroup {
/** Undefined for permissions declared without one. */
group: string | undefined;
permissions: Permission[];
}
/** Permissions by group, in the order the groups first appear. */
export function groupPermissions(permissions: Permission[]): PermissionGroup[] {
const groups: PermissionGroup[] = [];
for (const permission of permissions) {
const existing = groups.find((candidate) => candidate.group === permission.group);
if (existing) existing.permissions.push(permission);
else groups.push({ group: permission.group, permissions: [permission] });
}
return groups;
}
"use client";
import {
useId,
useMemo,
useState,
type ComponentPropsWithRef,
type KeyboardEvent,
} from "react";
import { Checkbox } from "@/components/checkbox";
import { cn } from "@/lib/utils";
import {
cellState,
countGranted,
groupPermissions,
groupState,
type Grants,
type Permission,
type Role,
} from "./permission-model";
/**
* Roles across, permissions down, a checkbox at every crossing.
*
* Every admin panel has one and every admin panel builds it, because the
* hard part is not the checkboxes. It is that a role can inherit from
* another, so a box can be ticked without anyone having ticked it; that an
* Owner has everything and none of it can be unticked; that a section of
* eight permissions wants one control for all of them; and that sixty
* checkboxes are sixty tab stops unless something is done about it.
*
* Something is done about it. This is a grid in the WAI-ARIA sense — one tab
* stop, arrow keys between cells, Home and End along a row — which is the
* right call here and was the wrong one for the diff viewer: a diff is read,
* a matrix is operated. Every checkbox is named by both coordinates, "Delete
* projects for Editor", so a reader arriving by arrow key knows where they
* are without re-reading the headers.
*
* A grant that arrived by inheritance is shown as a checked box that cannot
* be unchecked here, with the role it came from said beside it and in the
* box's description. A disabled control would be the obvious rendering, and
* it would take the box out of the tab order and the arrow-key path, so a
* keyboard user would step over the one cell whose state needs explaining.
*
* Changes are reported, never applied. A group toggle reports every
* permission it touched in one call, so an application can save it as one
* change rather than eight.
*/
export interface PermissionMatrixProps extends Omit<
ComponentPropsWithRef<"table">,
"onChange"
> {
/** Names the matrix: "Permissions for Acme". */
label: string;
roles: Role[];
permissions: Permission[];
grants: Grants;
/**
* A change to make. Several ids arrive together when a group toggle was
* used. Omit to render the matrix as a record.
*/
onChange?: (roleId: string, permissionIds: string[], granted: boolean) => void;
}
type MatrixRow =
| { kind: "group"; label: string; permissions: Permission[] }
| { kind: "permission"; permission: Permission };
const ARROWS: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1],
};
export function PermissionMatrix({
className,
label,
roles,
permissions,
grants,
onChange,
...props
}: PermissionMatrixProps) {
const uid = useId();
const rows = useMemo<MatrixRow[]>(
() =>
groupPermissions(permissions).flatMap((group): MatrixRow[] => [
...(group.group !== undefined
? [{ kind: "group" as const, label: group.group, permissions: group.permissions }]
: []),
...group.permissions.map((permission) => ({ kind: "permission" as const, permission })),
]),
[permissions],
);
// One tab stop. The cell that last had focus is the one Tab returns to.
const [active, setActive] = useState<[number, number]>([0, 0]);
const onKeyDown = (event: KeyboardEvent<HTMLTableElement>) => {
const target = event.target as HTMLElement;
const row = Number(target.dataset.row);
const col = Number(target.dataset.col);
if (Number.isNaN(row) || Number.isNaN(col)) return;
let next: [number, number] | null = null;
const arrow = ARROWS[event.key];
if (arrow) {
next = [row + arrow[0], col + arrow[1]];
} else if (event.key === "Home") {
next = event.ctrlKey ? [0, 0] : [row, 0];
} else if (event.key === "End") {
next = event.ctrlKey ? [rows.length - 1, roles.length - 1] : [row, roles.length - 1];
}
if (!next) return;
const [nextRow, nextCol] = next;
if (nextRow < 0 || nextRow >= rows.length || nextCol < 0 || nextCol >= roles.length) {
event.preventDefault();
return;
}
event.preventDefault();
const cell = event.currentTarget.querySelector<HTMLElement>(
`[data-row="${String(nextRow)}"][data-col="${String(nextCol)}"]`,
);
cell?.focus();
};
return (
<div className="relative w-full overflow-x-auto">
<table
data-slot="permission-matrix"
role="grid"
aria-label={label}
aria-readonly={onChange ? undefined : true}
onKeyDown={onKeyDown}
className={cn("w-full border-collapse text-sm", className)}
{...props}
>
<thead>
<tr role="row" className="border-b border-border">
<th
role="columnheader"
scope="col"
className="px-3 py-2 text-start text-xs font-medium"
>
Permission
</th>
{roles.map((role) => {
const granted = countGranted(roles, grants, role, permissions);
return (
<th
key={role.id}
role="columnheader"
scope="col"
className="px-3 py-2 text-center text-xs font-medium"
>
{role.label}
<span className="block font-normal text-muted-foreground">
{role.locked ? (
"all, locked"
) : (
<>
{String(granted)}/{String(permissions.length)}
<span className="sr-only"> granted</span>
</>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) =>
row.kind === "group" ? (
<tr
key={`group-${row.label}`}
role="row"
className="border-b border-border bg-muted/40"
>
<th
role="rowheader"
scope="row"
className="px-3 py-1.5 text-start text-xs font-medium"
>
{row.label}
<span className="sr-only">, all</span>
</th>
{roles.map((role, colIndex) => (
<GroupCell
key={role.id}
role={role}
roles={roles}
grants={grants}
group={row.label}
permissions={row.permissions}
position={[rowIndex, colIndex]}
active={active[0] === rowIndex && active[1] === colIndex}
onActivate={setActive}
onChange={onChange}
/>
))}
</tr>
) : (
<tr
key={row.permission.id}
role="row"
className="border-b border-border last:border-0 hover:bg-muted/30"
>
<th
role="rowheader"
scope="row"
className={cn(
"px-3 py-2 text-start font-normal",
row.permission.group !== undefined && "ps-6",
)}
>
{row.permission.label}
{row.permission.description ? (
<span className="block text-xs text-muted-foreground">
{row.permission.description}
</span>
) : null}
</th>
{roles.map((role, colIndex) => (
<PermissionCell
key={role.id}
id={`${uid}-${role.id}-${row.permission.id}`}
role={role}
roles={roles}
grants={grants}
permission={row.permission}
position={[rowIndex, colIndex]}
active={active[0] === rowIndex && active[1] === colIndex}
onActivate={setActive}
onChange={onChange}
/>
))}
</tr>
),
)}
</tbody>
</table>
</div>
);
}
interface CellProps {
role: Role;
roles: Role[];
grants: Grants;
position: [number, number];
active: boolean;
onActivate: (position: [number, number]) => void;
onChange?: (roleId: string, permissionIds: string[], granted: boolean) => void;
}
function PermissionCell({
id,
role,
roles,
grants,
permission,
position,
active,
onActivate,
onChange,
}: CellProps & { id: string; permission: Permission }) {
const state = cellState(roles, grants, role, permission.id);
const fixed = state.fixed || !onChange;
const noteId = `${id}-note`;
const note = role.locked
? `${role.label} has every permission; this cannot be changed.`
: state.inheritedFrom
? `Inherited from ${state.inheritedFrom.label}.`
: null;
return (
<td
role="gridcell"
data-slot="permission-cell"
data-granted={state.granted || undefined}
data-inherited={state.inheritedFrom ? "" : undefined}
className="px-3 py-2 text-center align-middle"
>
<Checkbox
id={id}
checked={state.granted}
aria-label={`${permission.label} for ${role.label}`}
aria-describedby={note ? noteId : undefined}
// Fixed cells stay focusable, so the arrow-key path and the tab order
// do not step over the one cell whose state needs explaining.
aria-disabled={fixed || undefined}
tabIndex={active ? 0 : -1}
data-row={position[0]}
data-col={position[1]}
className={cn(fixed && "opacity-55")}
onFocus={() => {
onActivate(position);
}}
onCheckedChange={(next) => {
if (fixed) return;
onChange?.(role.id, [permission.id], next === true);
}}
/>
{state.inheritedFrom ? (
<span id={noteId} className="mt-0.5 block text-2xs text-muted-foreground">
via {state.inheritedFrom.label}
</span>
) : note ? (
<span id={noteId} className="sr-only">
{note}
</span>
) : null}
</td>
);
}
function GroupCell({
role,
roles,
grants,
group,
permissions,
position,
active,
onActivate,
onChange,
}: CellProps & { group: string; permissions: Permission[] }) {
const ids = permissions.map((permission) => permission.id);
const state = groupState(roles, grants, role, ids);
// Only what can actually change here: inherited grants stay either way.
const changeable = permissions
.filter((permission) => !cellState(roles, grants, role, permission.id).fixed)
.map((permission) => permission.id);
const fixed = role.locked || !onChange || changeable.length === 0;
return (
<td
role="gridcell"
data-slot="permission-group-cell"
className="px-3 py-1.5 text-center align-middle"
>
<Checkbox
checked={state === "all" ? true : state === "some" ? "indeterminate" : false}
aria-label={`All ${group} for ${role.label}`}
aria-disabled={fixed || undefined}
tabIndex={active ? 0 : -1}
data-row={position[0]}
data-col={position[1]}
className={cn(fixed && "opacity-55")}
onFocus={() => {
onActivate(position);
}}
onCheckedChange={(next) => {
if (fixed) return;
onChange?.(role.id, changeable, next === true);
}}
/>
</td>
);
}