diff --git a/backend/src/services/secret-folder/secret-folder-fns.ts b/backend/src/services/secret-folder/secret-folder-fns.ts index a3783a1b9..0369c4e11 100644 --- a/backend/src/services/secret-folder/secret-folder-fns.ts +++ b/backend/src/services/secret-folder/secret-folder-fns.ts @@ -1,14 +1,11 @@ import { TSecretFolders } from "@app/db/schemas"; -import { InternalServerError } from "@app/lib/errors"; export const buildFolderPath = ( folder: TSecretFolders, foldersMap: Record, depth: number = 0 ): string => { - if (depth > 20) { - throw new InternalServerError({ message: "Maximum folder depth of 20 exceeded" }); - } + if (depth > 20) return; if (!folder.parentId) { return depth === 0 ? "/" : ""; } diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx index 609a39c4b..30705570c 100644 --- a/frontend/src/components/permissions/AccessTree/AccessTree.tsx +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { MongoAbility, MongoQuery } from "@casl/ability"; import { faArrowUpRightFromSquare, + faDownLeftAndUpRightToCenter, + faUpLong, faUpRightAndDownLeftFromCenter, faWindowRestore } from "@fortawesome/free-solid-svg-icons"; @@ -10,6 +12,7 @@ import { Background, BackgroundVariant, ConnectionLineType, + ControlButton, Controls, Node, NodeMouseHandler, @@ -21,8 +24,10 @@ import { import { twMerge } from "tailwind-merge"; import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode"; import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components"; import { BasePermissionEdge } from "./edges"; import { useAccessTree } from "./hooks"; @@ -35,13 +40,29 @@ export type AccessTreeProps = { const EdgeTypes = { base: BasePermissionEdge }; -const NodeTypes = { role: RoleNode, folder: FolderNode }; +const NodeTypes = { role: RoleNode, folder: FolderNode, showMoreButton: ShowMoreButtonNode }; const AccessTreeContent = ({ permissions }: AccessTreeProps) => { - const accessTreeData = useAccessTree(permissions); - const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData; + const [selectedPath, setSelectedPath] = useState("/"); + const accessTreeData = useAccessTree(permissions, selectedPath); + const { edges, nodes, isLoading, viewMode, setViewMode, environment } = accessTreeData; - const { fitView, getViewport, setCenter } = useReactFlow(); + useEffect(() => { + setSelectedPath("/"); + }, [environment]); + + const { getViewport, setCenter } = useReactFlow(); + + const goToRootNode = useCallback(() => { + const roleNode = nodes.find((node) => node.type === "role"); + if (roleNode) { + setCenter( + roleNode.position.x + (roleNode.width ? roleNode.width / 2 : 0), + roleNode.position.y + (roleNode.height ? roleNode.height / 2 : 0), + { duration: 800, zoom: 1 } + ); + } + }, [nodes, setCenter]); const onNodeClick: NodeMouseHandler = useCallback( (_, node) => { @@ -56,13 +77,9 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { useEffect(() => { setTimeout(() => { - fitView({ - padding: 0.2, - duration: 1000, - maxZoom: 1 - }); + goToRootNode(); }, 1); - }, [fitView, nodes, edges, getViewport()]); + }, [nodes, edges, getViewport()]); const handleToggleModalView = () => setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal)); @@ -133,7 +150,6 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { edges={edges} edgeTypes={EdgeTypes} nodeTypes={NodeTypes} - fitView onNodeClick={onNodeClick} colorMode="dark" nodesDraggable={false} @@ -151,6 +167,12 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { )} {viewMode !== ViewMode.Docked && ( + { @@ -187,9 +209,25 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { )} + {viewMode === ViewMode.Docked && ( + + + + )} - + + + + + + + diff --git a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx index 476f2d27c..6e3790b3c 100644 --- a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx +++ b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx @@ -46,6 +46,12 @@ export const PermissionSimulation = ({ className="mr-1 rounded" colorSchema="secondary" onClick={handlePermissionSimulation} + rightIcon={ + + } > Permission Simulation diff --git a/frontend/src/components/permissions/AccessTree/hooks/index.ts b/frontend/src/components/permissions/AccessTree/hooks/index.ts index 5672230c8..9a0292fb6 100644 --- a/frontend/src/components/permissions/AccessTree/hooks/index.ts +++ b/frontend/src/components/permissions/AccessTree/hooks/index.ts @@ -5,6 +5,7 @@ import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries"; +import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types"; import { useAccessTreeContext } from "../components"; import { PermissionAccess } from "../types"; @@ -15,8 +16,24 @@ import { getSubjectActionRuleMap, positionElements } from "../utils"; +import { createShowMoreNode } from "../utils/createShowMoreNode"; -export const useAccessTree = (permissions: MongoAbility) => { +const INITIAL_FOLDERS_PER_LEVEL = 10; +const FOLDERS_INCREMENT = 10; + +type LevelFolderMap = Record< + string, + { + folders: TSecretFolderWithPath[]; + visibleCount: number; + hasMore: boolean; + } +>; + +export const useAccessTree = ( + permissions: MongoAbility, + searchPath: string +) => { const { currentWorkspace } = useWorkspace(); const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext(); const [nodes, setNodes] = useNodesState([]); @@ -27,10 +44,104 @@ export const useAccessTree = (permissions: MongoAbility({}); + const [totalFolderCount, setTotalFolderCount] = useState(0); + + const showMoreFolders = (parentId: string) => { + setLevelFolderMap((prevMap) => { + const level = prevMap[parentId]; + if (!level) return prevMap; + + const newVisibleCount = Math.min( + level.visibleCount + FOLDERS_INCREMENT, + level.folders.length + ); + + return { + ...prevMap, + [parentId]: { + ...level, + visibleCount: newVisibleCount, + hasMore: newVisibleCount < level.folders.length + } + }; + }); + }; + + const levelsWithMoreFolders = Object.entries(levelFolderMap) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([_, level]) => level.hasMore) + .map(([parentId]) => parentId); + + const getLevelCounts = (parentId: string) => { + const level = levelFolderMap[parentId]; + if (!level) return { visibleCount: 0, totalCount: 0, hasMore: false }; + + return { + visibleCount: level.visibleCount, + totalCount: level.folders.length, + hasMore: level.hasMore + }; + }; + useEffect(() => { if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return; - const { folders, name } = environmentsFolders[environment]; + const { folders } = environmentsFolders[environment]; + + setTotalFolderCount(folders.length); + + const groupedFolders: Record = {}; + + const filteredFolders = folders.filter((folder) => { + if (folder.path.startsWith(searchPath)) { + return true; + } + + if ( + searchPath.startsWith(folder.path) && + (folder.path === "/" || + searchPath === folder.path || + searchPath.indexOf("/", folder.path.length) === folder.path.length) + ) { + return true; + } + + return false; + }); + + filteredFolders.forEach((folder) => { + const parentId = folder.parentId || ""; + if (!groupedFolders[parentId]) { + groupedFolders[parentId] = []; + } + groupedFolders[parentId].push(folder); + }); + + const newLevelFolderMap: LevelFolderMap = {}; + + Object.entries(groupedFolders).forEach(([parentId, folderList]) => { + const key = parentId; + newLevelFolderMap[key] = { + folders: folderList, + visibleCount: Math.min(INITIAL_FOLDERS_PER_LEVEL, folderList.length), + hasMore: folderList.length > INITIAL_FOLDERS_PER_LEVEL + }; + }); + + setLevelFolderMap(newLevelFolderMap); + }, [permissions, environmentsFolders, environment, subject, secretName, searchPath]); + + useEffect(() => { + if ( + !environmentsFolders || + !permissions || + !environmentsFolders[environment] || + Object.keys(levelFolderMap).length === 0 + ) + return; + + const { name } = environmentsFolders[environment]; const roleNode = createRoleNode({ subject, @@ -39,7 +150,13 @@ export const useAccessTree = (permissions: MongoAbility + const visibleFolders: TSecretFolderWithPath[] = []; + + Object.values(levelFolderMap).forEach((levelData) => { + visibleFolders.push(...levelData.folders.slice(0, levelData.visibleCount)); + }); + + const folderNodes = visibleFolders.map((folder) => createFolderNode({ folder, permissions, @@ -69,10 +186,44 @@ export const useAccessTree = (permissions: MongoAbility { + const key = parentId === "null" ? null : parentId; + + if (key && levelData.hasMore) { + const showMoreButtonNode = createShowMoreNode({ + parentId: key, + onClick: () => showMoreFolders(key), + remaining: levelData.folders.length - levelData.visibleCount + }); + + addMoreButtons.push(showMoreButtonNode); + + folderEdges.push( + createBaseEdge({ + source: key, + target: showMoreButtonNode.id, + access: PermissionAccess.Full, + hideEdge: true + }) + ); + } + }); + + const init = positionElements([roleNode, ...folderNodes, ...addMoreButtons], [...folderEdges]); setNodes(init.nodes); setEdges(init.edges); - }, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]); + }, [ + levelFolderMap, + permissions, + environmentsFolders, + environment, + subject, + secretName, + setNodes, + setEdges + ]); return { nodes, @@ -86,6 +237,11 @@ export const useAccessTree = (permissions: MongoAbility["data"] }) => { + return ( + <> + + + + + + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/types/index.ts b/frontend/src/components/permissions/AccessTree/types/index.ts index 9536d7f86..c5da34915 100644 --- a/frontend/src/components/permissions/AccessTree/types/index.ts +++ b/frontend/src/components/permissions/AccessTree/types/index.ts @@ -7,7 +7,8 @@ export enum PermissionAccess { export enum PermissionNode { Role = "role", Folder = "folder", - Environment = "environment" + Environment = "environment", + ShowMoreButton = "showMoreButton" } export enum PermissionEdge { diff --git a/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts b/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts index cc53360c8..0e4aa0c9d 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts @@ -5,11 +5,13 @@ import { PermissionAccess, PermissionEdge } from "../types"; export const createBaseEdge = ({ source, target, - access + access, + hideEdge = false }: { source: string; target: string; access: PermissionAccess; + hideEdge?: boolean; }) => { const color = access === PermissionAccess.None ? "#707174" : "#ccccce"; return { @@ -17,10 +19,12 @@ export const createBaseEdge = ({ source, target, type: PermissionEdge.Base, - markerEnd: { - type: MarkerType.ArrowClosed, - color - }, - style: { stroke: color } + markerEnd: hideEdge + ? undefined + : { + type: MarkerType.ArrowClosed, + color + }, + style: { stroke: hideEdge ? "transparent" : color } }; }; diff --git a/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts b/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts new file mode 100644 index 000000000..e5758ffc9 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts @@ -0,0 +1,25 @@ +import { PermissionNode } from "../types"; + +export const createShowMoreNode = ({ + parentId, + onClick, + remaining +}: { + parentId: string | null; + onClick: () => void; + remaining: number; +}) => { + const id = `show-more-${parentId || "root"}`; + return { + id, + type: PermissionNode.ShowMoreButton, + position: { x: 0, y: 0 }, + data: { + parentId, + onClick, + remaining + }, + width: 100, + height: 40 + }; +}; diff --git a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts index 523b402d1..8fd7bbbf5 100644 --- a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts +++ b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts @@ -2,27 +2,60 @@ import Dagre from "@dagrejs/dagre"; import { Edge, Node } from "@xyflow/react"; export const positionElements = (nodes: Node[], edges: Edge[]) => { + const regularNodes = nodes.filter((node) => node.type !== "showMoreButton"); + const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton"); + + const nodeMap: Record = {}; + const dagre = new Dagre.graphlib.Graph({ directed: true }) .setDefaultEdgeLabel(() => ({})) .setGraph({ rankdir: "TB" }); edges.forEach((edge) => dagre.setEdge(edge.source, edge.target)); - nodes.forEach((node) => dagre.setNode(node.id, node)); + + regularNodes.forEach((node) => dagre.setNode(node.id, node)); Dagre.layout(dagre, {}); - return { - nodes: nodes.map((node) => { - const { x, y } = dagre.node(node.id); + const positionedNodes = regularNodes.map((node) => { + const { x, y } = dagre.node(node.id); - return { - ...node, - position: { - x: x - (node.width ? node.width / 2 : 0), - y: y - (node.height ? node.height / 2 : 0) - } - }; - }), + const positionedNode = { + ...node, + position: { + x: x - (node.width ? node.width / 2 : 0), + y: y - (node.height ? node.height / 2 : 0) + } + }; + + nodeMap[node.id] = positionedNode; + + return positionedNode; + }); + + const positionedShowMoreNodes = showMoreNodes.map((node) => { + const parentId = node.data.parentId as string; + const { isStart } = node.data; + + const parentNode = nodeMap[parentId] || positionedNodes[0]; + const parentX = parentNode.position.x; + const parentY = parentNode.position.y; + + const parentWidth = parentNode.width || 150; + const buttonWidth = node.width || 100; + + const buttonX = isStart ? parentX - buttonWidth - 20 : parentX + parentWidth + 20; + + return { + ...node, + position: { + x: buttonX, + y: parentY + } + }; + }); + return { + nodes: [...positionedNodes, ...positionedShowMoreNodes], edges }; };