From a641b4e2d1095e3054d85bf2f9e17dd97bbf8de0 Mon Sep 17 00:00:00 2001 From: Ryan Copley Date: Wed, 27 Aug 2025 12:32:11 -0400 Subject: [PATCH] - --- kms/web/src/components/PermissionTree.tsx | 302 ++++++++++++++++++++++ kms/web/src/components/TokenTester.tsx | 24 +- kms/web/src/components/Tokens.tsx | 16 +- 3 files changed, 320 insertions(+), 22 deletions(-) create mode 100644 kms/web/src/components/PermissionTree.tsx diff --git a/kms/web/src/components/PermissionTree.tsx b/kms/web/src/components/PermissionTree.tsx new file mode 100644 index 0000000..7b00d95 --- /dev/null +++ b/kms/web/src/components/PermissionTree.tsx @@ -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 = ({ permissions, onChange }) => { + const [expandedNodes, setExpandedNodes] = useState>(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 ( + + + + {hasChildren ? ( + toggleExpanded(node.id)} + style={{ marginTop: '1px' }} + > + {isExpanded ? ( + + ) : ( + + )} + + ) : ( + // Spacer for alignment + )} + + handleNodeChange(node, event.currentTarget.checked)} + size="sm" + style={{ marginTop: '1px' }} + disabled={!node.children && isChildDisabled(node.id)} + /> + + + + + {node.label} + {hasChildren && ' (all)'} + + {node.description && ( + + - {node.description} + + )} + + + + + + {hasChildren && ( + + + + {node.children!.map(child => renderNode(child, depth + 1))} + + + + )} + + ); + }; + + return ( + + + Select permissions for this token + + {permissionHierarchy.map(node => renderNode(node))} + + ); +}; + +export default PermissionTree; \ No newline at end of file diff --git a/kms/web/src/components/TokenTester.tsx b/kms/web/src/components/TokenTester.tsx index 0a5a1b7..e7c6024 100644 --- a/kms/web/src/components/TokenTester.tsx +++ b/kms/web/src/components/TokenTester.tsx @@ -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')} /> - ({ - value: perm, - label: perm, - }))} - {...form.getInputProps('permissions')} - searchable - /> +
+ + Required Permissions (Optional) + + + Leave empty to skip permission checks + + form.setFieldValue('permissions', permissions)} + /> +