Role Matrix
blocksecurityRoles × permissions grid grouped by resource: checkbox cells with tri-state column headers (toggle a whole role), per-resource grant summaries, horizontal scroll for many roles, and a read-only mode. value/valueChange mirrors the full grants map; stub data covers users, billing and content.
Installation
$pnpm dlx uipkge-ng@latest add role-matrix$npx uipkge-ng@latest add role-matrix$yarn dlx uipkge-ng@latest add role-matrix$bunx uipkge-ng@latest add role-matrixManifest:
https://uipkge.dev/r/angular/role-matrix.jsonInstalls to:src/app/components/blocks/role-matrix/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
Roleinterface Role {
id: string
label: string
}Permissioninterface Permission {
id: string
label: string
}PermissionGroupinterface PermissionGroup {
resource: string
permissions: Permission[]
}npm dependencies
Includes
Files installed (2)
src/app/components/blocks/role-matrix/role-matrix.component.ts8.5 kB
// Roles × permissions grid grouped by resource: checkbox cells with tri-state column // headers (toggle a whole role), per-resource grant summaries, and a read-only mode. // Port of the React block 1:1. import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core' import { ShieldCheck, LucideAngularModule } from 'lucide-angular' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiCheckboxComponent, type CheckedState } from '@/ui/checkbox/checkbox.component' import { UiSectionCardComponent } from '@/ui/section-card/section-card.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' export interface Role { id: string label: string } export interface Permission { id: string label: string } export interface PermissionGroup { resource: string permissions: Permission[] } export const defaultRoles: Role[] = [ { id: 'admin', label: 'Admin' }, { id: 'editor', label: 'Editor' }, { id: 'viewer', label: 'Viewer' }, ] export const defaultGroups: PermissionGroup[] = [ { resource: 'Users', permissions: [ { id: 'users.create', label: 'Invite members' }, { id: 'users.read', label: 'View members' }, { id: 'users.update', label: 'Edit profiles & roles' }, { id: 'users.delete', label: 'Remove members' }, ], }, { resource: 'Billing', permissions: [ { id: 'billing.read', label: 'View invoices' }, { id: 'billing.manage', label: 'Change plan & seats' }, ], }, { resource: 'Content', permissions: [ { id: 'content.create', label: 'Create documents' }, { id: 'content.read', label: 'Read documents' }, { id: 'content.delete', label: 'Delete documents' }, ], }, ] export const defaultGrants: Record<string, string[]> = { 'users.create': ['admin'], 'users.read': ['admin', 'editor', 'viewer'], 'users.update': ['admin'], 'users.delete': ['admin'], 'billing.read': ['admin', 'editor'], 'billing.manage': ['admin'], 'content.create': ['admin', 'editor'], 'content.read': ['admin', 'editor', 'viewer'], 'content.delete': ['admin', 'editor'], } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-role-matrix, [ui-role-matrix]', standalone: true, // React renders the SectionCard itself: the host stays out of layout and `class` goes to the card. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiCheckboxComponent, UiSectionCardComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, ], template: ` <div ui-section-card data-slot="role-matrix" title="Roles & permissions" description="What each role can do, grouped by resource." [class]="className" > @if (readOnlySignal()) { <span slot="header-action" ui-badge variant="secondary"> <lucide-icon [img]="ShieldCheck" /> Read-only </span> } <div class="-mx-6 overflow-x-auto px-6"> <table ui-table class="max-w-[480px] min-w-full"> <thead ui-table-header> <tr ui-table-row> <th ui-table-head class="w-[46%]">Permission</th> @for (role of rolesSignal(); track role.id) { <th ui-table-head class="text-center"> <span class="block text-xs font-medium">{{ role.label }}</span> <ui-checkbox [checked]="roleState(role.id)" [disabled]="readOnlySignal()" size="sm" [attr.aria-label]="'Toggle all ' + role.label + ' permissions'" class="mx-auto mt-1 block" (checkedChange)="toggleRoleColumn(role.id)" /> </th> } </tr> </thead> <tbody ui-table-body> @for (group of groupsSignal(); track group.resource) { <tr ui-table-row class="hover:bg-transparent"> <td ui-table-cell [attr.colspan]="rolesSignal().length + 1" class="bg-muted/40 h-10 py-2"> <div class="flex items-center justify-between gap-x-2"> <span class="text-xs font-semibold tracking-wider uppercase">{{ group.resource }}</span> <span class="text-muted-foreground text-xs">{{ groupSummary(group) }}</span> </div> </td> </tr> @for (permission of group.permissions; track permission.id) { <tr ui-table-row> <td ui-table-cell class="py-2.5 pl-4 font-normal">{{ permission.label }}</td> @for (role of rolesSignal(); track role.id) { <td ui-table-cell class="text-center"> <ui-checkbox [checked]="isGranted(permission.id, role.id)" [disabled]="readOnlySignal()" size="sm" [attr.aria-label]="permission.label + ' — ' + role.label" class="mx-auto" (checkedChange)="toggleCell(permission.id, role.id)" /> </td> } </tr> } } </tbody> </table> </div> <p class="text-muted-foreground mt-4 text-xs"> Column headers toggle every permission for that role. Changes apply on save. </p> </div> `, }) export class UiRoleMatrixComponent { protected readonly ShieldCheck = ShieldCheck @Input() set roles(v: Role[] | undefined) { this.rolesSignal.set(v ?? defaultRoles) } @Input() set groups(v: PermissionGroup[] | undefined) { this.groupsSignal.set(v ?? defaultGroups) } @Input() set value(v: Record<string, string[]> | undefined) { this.internalGrants.set(v ? { ...v } : { ...defaultGrants }) } @Input() set readOnly(v: boolean) { this.readOnlySignal.set(!!v) } @Input('class') className?: string @Output() valueChange = new EventEmitter<Record<string, string[]>>() readonly rolesSignal = signal<Role[]>([...defaultRoles]) readonly groupsSignal = signal<PermissionGroup[]>([...defaultGroups]) readonly readOnlySignal = signal(false) readonly internalGrants = signal<Record<string, string[]>>({ ...defaultGrants }) readonly grants = computed(() => this.internalGrants()) readonly allRoleIds = computed(() => this.rolesSignal().map((r) => r.id)) setGrant(permissionId: string, roleIds: string[]): void { const next = { ...this.internalGrants(), [permissionId]: roleIds } this.internalGrants.set(next) this.valueChange.emit({ ...next }) } isGranted(permissionId: string, roleId: string): boolean { return (this.internalGrants()[permissionId] ?? []).includes(roleId) } toggleCell(permissionId: string, roleId: string): void { if (this.readOnlySignal()) return const current = this.internalGrants()[permissionId] ?? [] this.setGrant(permissionId, current.includes(roleId) ? current.filter((id) => id !== roleId) : [...current, roleId]) } roleState(roleId: string): CheckedState { const gs = this.groupsSignal() const total = gs.reduce((n, g) => n + g.permissions.length, 0) const count = gs.reduce((n, g) => n + g.permissions.filter((p) => this.isGranted(p.id, roleId)).length, 0) if (count === 0) return false if (count === total) return true return 'indeterminate' } toggleRoleColumn(roleId: string): void { if (this.readOnlySignal()) return const turnOn = this.roleState(roleId) !== true const next = { ...this.internalGrants() } for (const group of this.groupsSignal()) { for (const permission of group.permissions) { const current = next[permission.id] ?? [] const has = current.includes(roleId) if (turnOn && !has) next[permission.id] = [...current, roleId] if (!turnOn && has) next[permission.id] = current.filter((id) => id !== roleId) } } this.internalGrants.set(next) this.valueChange.emit({ ...next }) } groupSummary(group: PermissionGroup): string { const ids = this.allRoleIds() const total = group.permissions.length * ids.length const granted = group.permissions.reduce( (n, p) => n + ids.filter((roleId) => this.isGranted(p.id, roleId)).length, 0, ) return `${granted} of ${total} grants` } }src/app/components/blocks/role-matrix/index.ts0.2 kB
export { UiRoleMatrixComponent, defaultRoles, defaultGroups, defaultGrants, type Role, type Permission, type PermissionGroup, } from './role-matrix.component'
Raw manifest:https://uipkge.dev/r/angular/role-matrix.json