## 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>
209 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|