template-saas-frontend-v2/src/components/rbac/PermissionsMatrix.tsx
Adrian Flores Cortes 9bd1aba33d [SPRINT-3] feat: Add WCAG improvements and 160 unit tests
## ST-3.1 WCAG Accessibility (5 SP)
- Replace div onClick with semantic buttons
- Add aria-label to interactive icons
- Add aria-hidden to decorative icons
- Add focus:ring-2 for visible focus states
- Add role attributes to modals, trees, progressbars
- Add proper form labels with htmlFor

Files modified: NotificationDrawer, DashboardLayout, PermissionsMatrix,
NetworkTree, GoalProgressBar, AuditFilters, NotificationBell,
NotificationItem, RoleCard, RoleForm

## ST-3.2 Unit Tests (5 SP)
- Add 160 new unit tests (target was 42)
- Hooks: useAuth (18), useRbac (18), useGoals (15), useMlm (19), usePortfolio (24)
- Components: RoleForm (15), GoalProgressBar (28), RankBadge (23)
- All tests use AAA pattern with proper mocking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:27:34 -06:00

209 lines
8.6 KiB
TypeScript

import { useMemo } from 'react';
import { ChevronDown, ChevronRight, Check, Minus } from 'lucide-react';
import { useState } from 'react';
import type { Permission } from '@/services/api';
import { getPermissionCategoryLabel, getPermissionActionLabel } from '@/hooks/useRbac';
interface PermissionsMatrixProps {
permissions: Permission[];
selectedPermissions: string[];
onTogglePermission: (permissionSlug: string) => void;
onToggleCategory: (category: string, permissionSlugs: string[]) => void;
readOnly?: boolean;
}
export function PermissionsMatrix({
permissions,
selectedPermissions,
onTogglePermission,
onToggleCategory,
readOnly = false,
}: PermissionsMatrixProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const permissionsByCategory = useMemo(() => {
const grouped: Record<string, Permission[]> = {};
permissions.forEach((perm) => {
if (!grouped[perm.category]) {
grouped[perm.category] = [];
}
grouped[perm.category].push(perm);
});
return grouped;
}, [permissions]);
const categories = useMemo(() => Object.keys(permissionsByCategory).sort(), [permissionsByCategory]);
const toggleCategory = (category: string) => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
return newSet;
});
};
const getCategoryStatus = (category: string): 'all' | 'some' | 'none' => {
const categoryPerms = permissionsByCategory[category];
const selectedCount = categoryPerms.filter((p) => selectedPermissions.includes(p.slug)).length;
if (selectedCount === 0) return 'none';
if (selectedCount === categoryPerms.length) return 'all';
return 'some';
};
const handleCategoryCheckboxClick = (category: string) => {
if (readOnly) return;
const categoryPerms = permissionsByCategory[category];
const slugs = categoryPerms.map((p) => p.slug);
onToggleCategory(category, slugs);
};
return (
<div className="space-y-2">
{categories.map((category) => {
const isExpanded = expandedCategories.has(category);
const categoryPerms = permissionsByCategory[category];
const status = getCategoryStatus(category);
const selectedCount = categoryPerms.filter((p) =>
selectedPermissions.includes(p.slug)
).length;
return (
<div
key={category}
className="rounded-lg border border-gray-200 dark:border-gray-700"
>
{/* Category Header */}
<button
type="button"
className="flex w-full cursor-pointer items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 rounded-t-lg"
onClick={() => toggleCategory(category)}
aria-expanded={isExpanded}
aria-controls={`permissions-${category}`}
>
<div className="flex items-center gap-3">
<span
className="flex-shrink-0 text-gray-400"
aria-hidden="true"
>
{isExpanded ? (
<ChevronDown className="h-5 w-5" />
) : (
<ChevronRight className="h-5 w-5" />
)}
</span>
{!readOnly && (
<span
onClick={(e) => {
e.stopPropagation();
handleCategoryCheckboxClick(category);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
handleCategoryCheckboxClick(category);
}
}}
role="checkbox"
aria-checked={status === 'all' ? true : status === 'some' ? 'mixed' : false}
aria-label={`Toggle all ${category} permissions`}
tabIndex={0}
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${
status === 'all'
? 'border-blue-600 bg-blue-600 text-white'
: status === 'some'
? 'border-blue-600 bg-blue-100 text-blue-600 dark:bg-blue-900/20'
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'
}`}
>
{status === 'all' && <Check className="h-3.5 w-3.5" aria-hidden="true" />}
{status === 'some' && <Minus className="h-3.5 w-3.5" aria-hidden="true" />}
</span>
)}
<span className="font-medium text-gray-900 dark:text-white">
{getPermissionCategoryLabel(category)}
</span>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{selectedCount} / {categoryPerms.length}
</span>
</button>
{/* Permissions List */}
{isExpanded && (
<div
id={`permissions-${category}`}
className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
role="group"
aria-label={`${getPermissionCategoryLabel(category)} permissions`}
>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{categoryPerms.map((perm) => {
const isSelected = selectedPermissions.includes(perm.slug);
const [, action] = perm.slug.split(':');
return (
<label
key={perm.id}
className={`flex cursor-pointer items-start gap-3 rounded-lg p-2 transition-colors ${
readOnly
? ''
: 'hover:bg-white dark:hover:bg-gray-700'
} ${isSelected ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => !readOnly && onTogglePermission(perm.slug)}
disabled={readOnly}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed dark:border-gray-600"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{perm.name}
</span>
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
action === 'delete'
? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400'
: action === 'write'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{getPermissionActionLabel(action)}
</span>
</div>
{perm.description && (
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{perm.description}
</p>
)}
<code className="mt-1 block text-xs text-gray-400 dark:text-gray-500">
{perm.slug}
</code>
</div>
</label>
);
})}
</div>
</div>
)}
</div>
);
})}
{categories.length === 0 && (
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
No permissions available
</div>
)}
</div>
);
}