← All stories

COMPONENT · ui-permissions-matrix

ui-permissions-matrix

Component Tier 1 (primitive) Used by collaborators (EF-013), ticket block delegates (EF-023), invitation transfers (EF-022), admin permissions (EF-001)

A who-can-do-what grid: rows are subjects (people, roles), columns are abilities, cells are checkboxes (or tri-state). The contracts that hide bugs: implied permissions chained correctly (granting "delete" implies "read"), revocation cascades, role presets that don't lie about what they grant, and visible diff before save.

Component contract

Renders a grid of subjects × abilities with toggleable cells. The parent supplies the schema; the component manages local edit state and emits the diff on save.

  • subjects: SubjectDef[] — id, name, type ("user", "role", "delegate"), avatar?, removable?: boolean.
  • abilities: AbilityDef[] — id, label, group?, description?, implies?: string[] (transitive).
  • grants: Record<subjectId, abilityId[]> — current state.
  • onChange: (next) => void — fires on commit, NOT on every cell click.
  • onAddSubject?: () => void
  • onRemoveSubject?: (subjectId) => void
  • rolePresets?: PresetDef[] — preset bundles that grant a known set of abilities to a subject. e.g., "Organizer" preset grants [event.read, event.edit, guests.read, guests.edit].
  • readOnly?: boolean

Interaction surface

  1. Grid renders subjects as rows, abilities as columns.

    Sticky first column (subject names), sticky header row (ability labels). Abilities can group; group headers span their columns.

  2. Cell click toggles permission.

    Click cell to toggle. The component tracks an "uncommitted state" — visual indicator on changed cells (e.g., dot in corner). onChange fires only on Save, with the diff.

  3. Implied abilities cascade automatically.

    Granting an ability that implies others sets the implied abilities visibly checked but greyed (you cannot uncheck "read" while "edit" is granted). Removing the granting ability releases the implied (back to user's previous explicit state).

  4. Role presets apply a bundle.

    "Apply preset: Organizer" on a subject sets the bundle's abilities. The bundle's abilities are visually marked as "from preset"; if the user later customizes, the cell's source becomes "custom".

  5. Save shows a diff summary first.

    Save button opens a confirmation modal listing changes: "Granting: A, B. Revoking: C". User confirms before onChange fires. This prevents accidental bulk changes.

  6. Remove subject prompts confirmation.

    Removing a subject (when removable=true) opens ui-destructive-confirmation: "Remove [name] from this event? They'll lose all access." On confirm, subject is removed from the grid AND the diff.

Failure modes

Implied abilities not granted

Trigger: user grants "edit" but the implied "read" stays unchecked; backend later rejects "edit" because it lacks "read" prerequisite.

Implied abilities are computed transitively at commit time. Harness: ability.edit.implies=["read"], grant edit, assert grants array contains both "edit" and "read".

Revoking edit doesn't return implied read to its prior state

Trigger: user had "read" granted explicitly; granted "edit" (which implied read); now revokes "edit"; "read" stays granted but no longer marked as implied — fine. But if user only had "read" via implication, revoking "edit" should remove "read".

The component tracks per-cell source: explicit, implied, or preset. Revoking the parent ability removes the implication; if no other source, the implied ability is also revoked. Harness: subject has only "edit"-implied "read", revoke "edit", assert "read" no longer in grants.

Preset application doesn't show what it changed

Trigger: clicking "Apply Organizer preset" silently sets 12 abilities; user didn't see a preview.

Preset application opens a confirmation modal listing the abilities being granted/revoked relative to the subject's current state. Harness: apply preset, modal lists the changes.

onChange fires on every cell click

Trigger: each cell toggle calls onChange; parent re-renders 50 times during a configuration session.

onChange fires only on Save. Cell clicks update local "pending" state. Harness: click 5 cells, assert onChange called 0 times. Click Save, confirm, onChange called once with the diff.

Diff modal lists abilities by ID, not label

Trigger: confirmation modal says "Granting: event.edit, guests.export"; user can't parse those.

Diff modal uses the human label of each ability ("Edit event", "Export guest list"). Harness: open diff, text contains the labels not the dot-notation IDs.

Implied ability shown as user-toggleable

Trigger: implied "read" cell looks like a normal checked cell; user clicks it, expects to revoke just read, ends up confused when nothing changes.

Implied cells render with a distinct visual (lock icon or different shade) AND have a tooltip: "Granted by Edit. Revoke Edit to remove." Harness: hover/focus implied cell, tooltip visible explaining the source.

Remove subject doesn't actually remove from diff

Trigger: removing a subject hides the row but the diff still includes their ability changes.

Removed subjects produce a diff entry "Remove [name]". Their previous abilities are not in the new state. Harness: remove subject, save, diff includes "Remove [name]" AND the subject's ability changes are not separately listed.

Sticky header shadows blur the first row of cells

Trigger: sticky header drop-shadow overlaps the first content row, making the first row's checkboxes hard to see.

Sticky shadow stops at the header boundary; row content has its own background opacity. Harness: scroll grid, screenshot first content row, assert checkbox is visible without color-contrast violation.

Accessibility

  • Outer element is <table> with proper <th scope> on subject rows AND ability columns.
  • Each cell checkbox has aria-label describing the (subject × ability) pair, e.g., "Maria — Edit event".
  • Implied cells have aria-disabled="true" + aria-describedby pointing at the implication explanation.
  • Diff modal inherits ui-destructive-confirmation a11y.
  • Color contrast: cell labels ≥ 4.5:1, checkbox state indicators ≥ 3:1.
  • axe-clean at severity ≥ serious.

Stable test attributes

data-testWherePurpose
ui-permissions-matrixOuter tableComponent identity
ui-permissions-matrix-rowEach subject rowdata-subject-id attr
ui-permissions-matrix-cellEach grid celldata-subject-id + data-ability-id; data-source=explicit|implied|preset; data-pending-change=true|false
ui-permissions-matrix-add-subjectAdd subject buttonVisible when onAddSubject provided
ui-permissions-matrix-remove-subjectPer-row remove buttonVisible when subject.removable
ui-permissions-matrix-presetPer-row preset menu triggerVisible when rolePresets provided
ui-permissions-matrix-saveSave buttonVisible when there are pending changes
ui-permissions-matrix-diff-modalDiff confirmation modalVisible during save confirmation
ui-permissions-matrix-discardDiscard pending changesVisible when there are pending changes

Agent test plan

Standalone probes against /admin-test/ui-permissions-matrix-fixture with variants: 3-subjects-by-5-abilities, with-implications, with-presets, with-add-and-remove, read-only.

Probe list
- click-toggles-cell-pending: click cell, data-pending-change=true, onChange not called
- save-shows-diff-modal: pending changes + click save, ui-permissions-matrix-diff-modal visible
- diff-uses-labels-not-ids: open diff, text contains ability labels not dot-IDs
- diff-confirm-fires-onChange: confirm diff, onChange called once with full new grants
- diff-cancel-keeps-pending: cancel diff, pending still visible, onChange not called
- implied-grant-cascades: ability.edit.implies=[read], grant edit, save, grants includes read
- implied-cell-disabled: granting edit shows read cell as data-source=implied + aria-disabled=true
- implied-cell-tooltip: hover implied cell, tooltip explains source
- revoke-implied-via-parent: revoke edit, read released (when no other source)
- explicit-read-survives-edit-revoke: subject had explicit read + edit; revoke edit; read still granted
- preset-application-shows-changes: apply preset, confirmation lists granted/revoked abilities
- remove-subject-prompts-destructive: click remove, ui-destructive-confirmation visible
- remove-subject-confirmed-adds-diff: confirm remove, diff includes "Remove [name]"
- discard-clears-pending: click discard, all data-pending-change=false
- read-only-cells-no-toggle: readOnly=true, click cell, no state change
- sticky-header-no-shadow-overlap: scroll, screenshot first row checkbox, no contrast issue
- aria-label-cell: each cell has aria-label "subject — ability"
- color-contrast-cell-label: ≥ 4.5:1
- axe-clean-serious: no serious violations