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?: () => voidonRemoveSubject?: (subjectId) => voidrolePresets?: 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
-
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.
-
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.
-
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).
-
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".
-
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.
-
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-labeldescribing the (subject × ability) pair, e.g., "Maria — Edit event". - Implied cells have
aria-disabled="true"+aria-describedbypointing 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-test | Where | Purpose |
|---|---|---|
ui-permissions-matrix | Outer table | Component identity |
ui-permissions-matrix-row | Each subject row | data-subject-id attr |
ui-permissions-matrix-cell | Each grid cell | data-subject-id + data-ability-id; data-source=explicit|implied|preset; data-pending-change=true|false |
ui-permissions-matrix-add-subject | Add subject button | Visible when onAddSubject provided |
ui-permissions-matrix-remove-subject | Per-row remove button | Visible when subject.removable |
ui-permissions-matrix-preset | Per-row preset menu trigger | Visible when rolePresets provided |
ui-permissions-matrix-save | Save button | Visible when there are pending changes |
ui-permissions-matrix-diff-modal | Diff confirmation modal | Visible during save confirmation |
ui-permissions-matrix-discard | Discard pending changes | Visible 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