-
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,
|
Loader,
|
||||||
JsonInput,
|
JsonInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import PermissionTree from './PermissionTree';
|
||||||
import {
|
import {
|
||||||
IconTestPipe,
|
IconTestPipe,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
@ -171,17 +172,18 @@ const TokenTester: React.FC = () => {
|
|||||||
{...form.getInputProps('token')}
|
{...form.getInputProps('token')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MultiSelect
|
<div>
|
||||||
label="Required Permissions (Optional)"
|
<Text size="sm" fw={500} mb="xs">
|
||||||
placeholder="Select permissions to test"
|
Required Permissions (Optional)
|
||||||
description="Leave empty to skip permission checks"
|
</Text>
|
||||||
data={availablePermissions.map(perm => ({
|
<Text size="xs" c="dimmed" mb="md">
|
||||||
value: perm,
|
Leave empty to skip permission checks
|
||||||
label: perm,
|
</Text>
|
||||||
}))}
|
<PermissionTree
|
||||||
{...form.getInputProps('permissions')}
|
permissions={form.values.permissions}
|
||||||
searchable
|
onChange={(permissions) => form.setFieldValue('permissions', permissions)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
Code,
|
Code,
|
||||||
Divider,
|
Divider,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import PermissionTree from './PermissionTree';
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@ -321,7 +322,7 @@ const Tokens: React.FC = () => {
|
|||||||
form.reset();
|
form.reset();
|
||||||
}}
|
}}
|
||||||
title="Create New Token"
|
title="Create New Token"
|
||||||
size="md"
|
size="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@ -336,16 +337,9 @@ const Tokens: React.FC = () => {
|
|||||||
{...form.getInputProps('app_id')}
|
{...form.getInputProps('app_id')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MultiSelect
|
<PermissionTree
|
||||||
label="Permissions"
|
permissions={form.values.permissions}
|
||||||
placeholder="Select permissions for this token"
|
onChange={(permissions) => form.setFieldValue('permissions', permissions)}
|
||||||
required
|
|
||||||
data={availablePermissions.map(perm => ({
|
|
||||||
value: perm,
|
|
||||||
label: perm,
|
|
||||||
}))}
|
|
||||||
{...form.getInputProps('permissions')}
|
|
||||||
searchable
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
Reference in New Issue
Block a user