Menu

Change language

GitHub repo

Role Matrix

blocksecurity

Roles × 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

$npx uipkge-ng@latest add role-matrix
Manifest:https://uipkge.dev/r/angular/role-matrix.jsonInstalls to:src/app/components/blocks/role-matrix/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

Role
interface Role {
  id: string
  label: string
}
Permission
interface Permission {
  id: string
  label: string
}
PermissionGroup
interface PermissionGroup {
  resource: string
  permissions: Permission[]
}

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

Raw manifest:https://uipkge.dev/r/angular/role-matrix.json