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. v-model mirrors the full grants map; stub data covers users, billing and content.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/role-matrix.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/role-matrix.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/role-matrix.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/role-matrix.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/role-matrixInstalls to:app/components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
roles | Role[] | — | optional |
groups | PermissionGroup[] | — | optional |
modelValuepermissionId -> array of granted roleIds. | Record<string, string[]> | — | optional |
readOnly | boolean | — | optional |
class | HTMLAttributes['class'] | — | optional |
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 (1)
app/components/blocks/RoleMatrix.vue7 kB
<script setup lang="ts"> import { computed, ref } from 'vue' import type { HTMLAttributes } from 'vue' import { ShieldCheck } from 'lucide-vue-next' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { SectionCard } from '@/components/ui/section-card' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' interface Role { id: string label: string } interface Permission { id: string label: string } interface PermissionGroup { resource: string permissions: Permission[] } const props = withDefaults( defineProps<{ roles?: Role[] groups?: PermissionGroup[] /** permissionId -> array of granted roleIds. */ modelValue?: Record<string, string[]> readOnly?: boolean class?: HTMLAttributes['class'] }>(), { roles: () => [ { id: 'admin', label: 'Admin' }, { id: 'editor', label: 'Editor' }, { id: 'viewer', label: 'Viewer' }, ], groups: () => [ { 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' }, ], }, ], modelValue: undefined, readOnly: false, }, ) 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'], } const emit = defineEmits<{ 'update:modelValue': [Record<string, string[]>] }>() // Internal working copy keeps the block self-contained; consumers can pass // v-model to mirror changes out (we never mutate props directly). const internalGrants = ref<Record<string, string[]>>(props.modelValue ? { ...props.modelValue } : { ...defaultGrants }) const grants = computed(() => internalGrants.value) const allRoleIds = computed(() => props.roles.map((r) => r.id)) function setGrant(permissionId: string, roleIds: string[]) { internalGrants.value = { ...internalGrants.value, [permissionId]: roleIds } if (props.modelValue !== undefined) emit('update:modelValue', { ...internalGrants.value }) } function isGranted(permissionId: string, roleId: string) { return (grants.value[permissionId] ?? []).includes(roleId) } function toggleCell(permissionId: string, roleId: string) { if (props.readOnly) return const current = grants.value[permissionId] ?? [] setGrant(permissionId, current.includes(roleId) ? current.filter((id) => id !== roleId) : [...current, roleId]) } function roleState(roleId: string): boolean | 'indeterminate' { const total = props.groups.reduce((n, g) => n + g.permissions.length, 0) const count = props.groups.reduce((n, g) => n + g.permissions.filter((p) => isGranted(p.id, roleId)).length, 0) if (count === 0) return false if (count === total) return true return 'indeterminate' } function toggleRoleColumn(roleId: string) { if (props.readOnly) return const turnOn = roleState(roleId) !== true for (const group of props.groups) { for (const permission of group.permissions) { const current = grants.value[permission.id] ?? [] const has = current.includes(roleId) if (turnOn && !has) setGrant(permission.id, [...current, roleId]) if (!turnOn && has) setGrant( permission.id, current.filter((id) => id !== roleId), ) } } } function groupSummary(group: PermissionGroup) { const total = group.permissions.length * allRoleIds.value.length const granted = group.permissions.reduce( (n, p) => n + allRoleIds.value.filter((roleId) => isGranted(p.id, roleId)).length, 0, ) return `${granted} of ${total} grants` } </script> <template> <SectionCard data-slot="role-matrix" title="Roles & permissions" description="What each role can do, grouped by resource." :class="props.class" > <template #header-action> <Badge v-if="readOnly" variant="secondary"> <ShieldCheck aria-hidden="true" /> Read-only </Badge> </template> <div class="-mx-6 overflow-x-auto px-6"> <Table class="max-w-[480px] min-w-full"> <TableHeader> <TableRow> <TableHead class="w-[46%]">Permission</TableHead> <TableHead v-for="role in roles" :key="role.id" class="text-center"> <span class="block text-xs font-medium">{{ role.label }}</span> <Checkbox :model-value="roleState(role.id)" :disabled="readOnly" size="sm" :aria-label="`Toggle all ${role.label} permissions`" class="mx-auto mt-1 block" @update:model-value="toggleRoleColumn(role.id)" /> </TableHead> </TableRow> </TableHeader> <TableBody> <TableRow v-for="group in groups" :key="group.resource" class="hover:bg-transparent"> <TableCell :colspan="roles.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> </TableCell> </TableRow> <template v-for="group in groups" :key="group.resource + '-rows'"> <TableRow v-for="permission in group.permissions" :key="permission.id"> <TableCell class="py-2.5 pl-4 font-normal">{{ permission.label }}</TableCell> <TableCell v-for="role in roles" :key="role.id" class="text-center"> <Checkbox :model-value="isGranted(permission.id, role.id)" :disabled="readOnly" size="sm" :aria-label="`${permission.label} — ${role.label}`" class="mx-auto" @update:model-value="toggleCell(permission.id, role.id)" /> </TableCell> </TableRow> </template> </TableBody> </Table> </div> <p class="text-muted-foreground mt-4 text-xs"> Column headers toggle every permission for that role. Changes apply on save. </p> </SectionCard> </template>
Raw manifest:https://uipkge.dev/r/vue/role-matrix.json