This commit is contained in:
2025-08-27 12:32:11 -04:00
parent 7e584ba53b
commit a641b4e2d1
3 changed files with 320 additions and 22 deletions

View 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;

View File

@ -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

View File

@ -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