Files
skybridge/kms/web/src/components/PermissionTree.tsx
2025-08-27 12:46:16 -04:00

282 lines
7.1 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Stack,
Group,
Checkbox,
Text,
Box,
Collapse,
ActionIcon,
Paper,
} from '@mantine/core';
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
interface PermissionNode {
id: string;
label: string;
description?: string;
children?: PermissionNode[];
}
interface PermissionTreeProps {
permissions: string[];
onChange: (permissions: string[]) => void;
}
const permissionHierarchy: PermissionNode[] = [
{
id: 'app',
label: 'Application',
description: 'Access to application management',
children: [
{
id: 'app.read',
label: 'Read',
description: 'Read application information',
},
{
id: 'app.write',
label: 'Write',
description: 'Create and update applications',
},
{
id: 'app.delete',
label: 'Delete',
description: 'Delete applications',
},
],
},
{
id: 'token',
label: 'Token',
description: 'Access to token management',
children: [
{
id: 'token.read',
label: 'Read',
description: 'Read token information',
},
{
id: 'token.create',
label: 'Create',
description: 'Create new tokens',
},
{
id: 'token.revoke',
label: 'Revoke',
description: 'Revoke existing tokens',
},
],
},
{
id: 'repo',
label: 'Repository',
description: 'Access to repository operations',
children: [
{
id: 'repo.read',
label: 'Read',
description: 'Read repository data',
},
{
id: 'repo.write',
label: 'Write',
description: 'Write to repositories',
},
{
id: 'repo.admin',
label: 'Admin',
description: 'Administrative access to repositories',
},
],
},
{
id: 'permission',
label: 'Permission',
description: 'Access to permission management',
children: [
{
id: 'permission.read',
label: 'Read',
description: 'Read permission information',
},
{
id: 'permission.write',
label: 'Write',
description: 'Create and update permissions',
},
{
id: 'permission.grant',
label: 'Grant',
description: 'Grant permissions to tokens',
},
{
id: 'permission.revoke',
label: 'Revoke',
description: 'Revoke permissions from tokens',
},
],
},
];
const PermissionTree: React.FC<PermissionTreeProps> = ({ permissions, onChange }) => {
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
// Expand all nodes by default
useEffect(() => {
const allParentIds = permissionHierarchy.map(node => node.id);
setExpandedNodes(new Set(allParentIds));
}, []);
const toggleExpanded = (nodeId: string) => {
setExpandedNodes(prev => {
const newSet = new Set(prev);
if (newSet.has(nodeId)) {
newSet.delete(nodeId);
} else {
newSet.add(nodeId);
}
return newSet;
});
};
const getNodeState = (node: PermissionNode): 'checked' | 'indeterminate' | 'unchecked' => {
if (!node.children) {
// For leaf nodes, check if they're explicitly selected
return permissions.includes(node.id) ? 'checked' : 'unchecked';
}
// For parent nodes, check how many children are selected
const checkedChildren = node.children.filter(child =>
permissions.includes(child.id)
);
if (checkedChildren.length === 0) {
return 'unchecked';
} else if (checkedChildren.length === node.children.length) {
return 'checked';
} else {
return 'indeterminate';
}
};
const handleNodeChange = (node: PermissionNode, checked: boolean) => {
let newPermissions = [...permissions];
if (!node.children) {
// Leaf node
if (checked) {
if (!newPermissions.includes(node.id)) {
newPermissions.push(node.id);
}
} else {
newPermissions = newPermissions.filter(p => p !== node.id);
}
} else {
// Parent node - add/remove all child permissions individually
if (checked) {
// Add all child permissions
node.children.forEach(child => {
if (!newPermissions.includes(child.id)) {
newPermissions.push(child.id);
}
});
} else {
// Remove all child permissions
node.children.forEach(child => {
newPermissions = newPermissions.filter(p => p !== child.id);
});
}
}
onChange(newPermissions);
};
const renderNode = (node: PermissionNode, depth = 0): React.ReactNode => {
const state = getNodeState(node);
const isExpanded = expandedNodes.has(node.id);
const hasChildren = node.children && node.children.length > 0;
return (
<Box key={node.id}>
<Paper
p="xs"
radius="sm"
style={{
border: '1px solid var(--mantine-color-gray-3)',
marginBottom: '2px',
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
{hasChildren ? (
<ActionIcon
variant="subtle"
size="sm"
onClick={() => toggleExpanded(node.id)}
style={{ marginTop: '1px' }}
>
{isExpanded ? (
<IconChevronDown size={14} />
) : (
<IconChevronRight size={14} />
)}
</ActionIcon>
) : (
<Box w={28} /> // Spacer for alignment
)}
<Checkbox
checked={state === 'checked'}
indeterminate={state === 'indeterminate'}
onChange={(event) => handleNodeChange(node, event.currentTarget.checked)}
size="sm"
style={{ marginTop: '1px' }}
/>
<Box style={{ flex: 1 }}>
<Group gap="xs" wrap="nowrap">
<Text
size="sm"
fw={hasChildren ? 600 : 500}
>
{node.label}
{hasChildren && ' (all)'}
</Text>
{node.description && (
<Text
size="xs"
c="dimmed"
style={{ whiteSpace: 'nowrap' }}
>
- {node.description}
</Text>
)}
</Group>
</Box>
</Group>
</Paper>
{hasChildren && (
<Collapse in={isExpanded}>
<Box pl="lg" mt="2px">
<Stack gap="2px">
{node.children!.map(child => renderNode(child, depth + 1))}
</Stack>
</Box>
</Collapse>
)}
</Box>
);
};
return (
<Stack gap="xs">
<Text size="sm" fw={500} mb="xs">
Select permissions for this token
</Text>
{permissionHierarchy.map(node => renderNode(node))}
</Stack>
);
};
export default PermissionTree;