-
This commit is contained in:
302
kms/web/src/components/PermissionTree.tsx
Normal file
302
kms/web/src/components/PermissionTree.tsx
Normal file
@ -0,0 +1,302 @@
|
||||
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: 'Full control of applications',
|
||||
children: [
|
||||
{
|
||||
id: 'app.read',
|
||||
label: 'Read',
|
||||
description: 'View application details',
|
||||
},
|
||||
{
|
||||
id: 'app.write',
|
||||
label: 'Write',
|
||||
description: 'Create and update applications',
|
||||
},
|
||||
{
|
||||
id: 'app.delete',
|
||||
label: 'Delete',
|
||||
description: 'Delete applications',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'token',
|
||||
label: 'Token',
|
||||
description: 'Full control of tokens',
|
||||
children: [
|
||||
{
|
||||
id: 'token.read',
|
||||
label: 'Read',
|
||||
description: 'View token details',
|
||||
},
|
||||
{
|
||||
id: 'token.create',
|
||||
label: 'Create',
|
||||
description: 'Create new tokens',
|
||||
},
|
||||
{
|
||||
id: 'token.revoke',
|
||||
label: 'Revoke',
|
||||
description: 'Revoke existing tokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'repo',
|
||||
label: 'Repository',
|
||||
description: 'Full control of repositories',
|
||||
children: [
|
||||
{
|
||||
id: 'repo.read',
|
||||
label: 'Read',
|
||||
description: 'Read repository contents',
|
||||
},
|
||||
{
|
||||
id: 'repo.write',
|
||||
label: 'Write',
|
||||
description: 'Push to repositories',
|
||||
},
|
||||
{
|
||||
id: 'repo.admin',
|
||||
label: 'Admin',
|
||||
description: 'Full repository administration',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'permission',
|
||||
label: 'Permission',
|
||||
description: 'Full control of permissions',
|
||||
children: [
|
||||
{
|
||||
id: 'permission.read',
|
||||
label: 'Read',
|
||||
description: 'View permission details',
|
||||
},
|
||||
{
|
||||
id: 'permission.write',
|
||||
label: 'Write',
|
||||
description: 'Modify permissions',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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 isChildDisabled = (childId: string): boolean => {
|
||||
// Find the parent of this child
|
||||
for (const parent of permissionHierarchy) {
|
||||
if (parent.children?.some(child => child.id === childId)) {
|
||||
const wildcardPermission = `${parent.id}.*`;
|
||||
return permissions.includes(wildcardPermission);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getNodeState = (node: PermissionNode): 'checked' | 'indeterminate' | 'unchecked' => {
|
||||
if (!node.children) {
|
||||
// For leaf nodes, check if they're explicitly selected OR their parent wildcard is selected
|
||||
const isExplicitlySelected = permissions.includes(node.id);
|
||||
const isParentWildcardSelected = isChildDisabled(node.id);
|
||||
return (isExplicitlySelected || isParentWildcardSelected) ? 'checked' : 'unchecked';
|
||||
}
|
||||
|
||||
// Check if parent wildcard permission exists
|
||||
const wildcardPermission = `${node.id}.*`;
|
||||
if (permissions.includes(wildcardPermission)) {
|
||||
return 'checked';
|
||||
}
|
||||
|
||||
// Check children states
|
||||
const checkedChildren = node.children.filter(child => {
|
||||
const isExplicitlySelected = permissions.includes(child.id);
|
||||
const isParentWildcardSelected = permissions.includes(wildcardPermission);
|
||||
return isExplicitlySelected || isParentWildcardSelected;
|
||||
});
|
||||
|
||||
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
|
||||
const wildcardPermission = `${node.id}.*`;
|
||||
|
||||
if (checked) {
|
||||
// Add wildcard permission and remove specific child permissions
|
||||
if (!newPermissions.includes(wildcardPermission)) {
|
||||
newPermissions.push(wildcardPermission);
|
||||
}
|
||||
// Remove specific child permissions as they're covered by wildcard
|
||||
node.children.forEach(child => {
|
||||
newPermissions = newPermissions.filter(p => p !== child.id);
|
||||
});
|
||||
} else {
|
||||
// Remove wildcard permission and all child permissions
|
||||
newPermissions = newPermissions.filter(p =>
|
||||
p !== wildcardPermission && !node.children!.some(child => child.id === p)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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' }}
|
||||
disabled={!node.children && isChildDisabled(node.id)}
|
||||
/>
|
||||
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="sm"
|
||||
fw={hasChildren ? 600 : 500}
|
||||
c={!node.children && isChildDisabled(node.id) ? 'dimmed' : undefined}
|
||||
>
|
||||
{node.label}
|
||||
{hasChildren && ' (all)'}
|
||||
</Text>
|
||||
{node.description && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: !node.children && isChildDisabled(node.id) ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
- {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;
|
||||
@ -18,6 +18,7 @@ import {
|
||||
Loader,
|
||||
JsonInput,
|
||||
} from '@mantine/core';
|
||||
import PermissionTree from './PermissionTree';
|
||||
import {
|
||||
IconTestPipe,
|
||||
IconCheck,
|
||||
@ -171,17 +172,18 @@ const TokenTester: React.FC = () => {
|
||||
{...form.getInputProps('token')}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="Required Permissions (Optional)"
|
||||
placeholder="Select permissions to test"
|
||||
description="Leave empty to skip permission checks"
|
||||
data={availablePermissions.map(perm => ({
|
||||
value: perm,
|
||||
label: perm,
|
||||
}))}
|
||||
{...form.getInputProps('permissions')}
|
||||
searchable
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Required Permissions (Optional)
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Leave empty to skip permission checks
|
||||
</Text>
|
||||
<PermissionTree
|
||||
permissions={form.values.permissions}
|
||||
onChange={(permissions) => form.setFieldValue('permissions', permissions)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
Code,
|
||||
Divider,
|
||||
} from '@mantine/core';
|
||||
import PermissionTree from './PermissionTree';
|
||||
import {
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
@ -321,7 +322,7 @@ const Tokens: React.FC = () => {
|
||||
form.reset();
|
||||
}}
|
||||
title="Create New Token"
|
||||
size="md"
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
@ -336,16 +337,9 @@ const Tokens: React.FC = () => {
|
||||
{...form.getInputProps('app_id')}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="Permissions"
|
||||
placeholder="Select permissions for this token"
|
||||
required
|
||||
data={availablePermissions.map(perm => ({
|
||||
value: perm,
|
||||
label: perm,
|
||||
}))}
|
||||
{...form.getInputProps('permissions')}
|
||||
searchable
|
||||
<PermissionTree
|
||||
permissions={form.values.permissions}
|
||||
onChange={(permissions) => form.setFieldValue('permissions', permissions)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
|
||||
Reference in New Issue
Block a user