From d5741b4a72cef2ced6b5db86f701b2ba0b47fdfc Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 26 Mar 2025 11:13:12 -0300 Subject: [PATCH 1/8] Add horizontal limit, search bar and change icons of access tree component --- .../secret-folder/secret-folder-fns.ts | 5 +- .../permissions/AccessTree/AccessTree.tsx | 66 +++++-- .../components/PermissionSimulation.tsx | 6 + .../permissions/AccessTree/hooks/index.ts | 168 +++++++++++++++++- .../AccessTree/nodes/ShowMoreButtonNode.tsx | 36 ++++ .../permissions/AccessTree/types/index.ts | 3 +- .../AccessTree/utils/createBaseEdge.ts | 16 +- .../AccessTree/utils/createShowMoreNode.ts | 25 +++ .../AccessTree/utils/positionElements.ts | 57 ++++-- 9 files changed, 339 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx create mode 100644 frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts 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 }; }; From 95ccd35f61ebc10f912e6628689ad69d57ee8ad6 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 26 Mar 2025 15:19:42 -0300 Subject: [PATCH 2/8] Search bar improvements and position show more on top of last folder of the row --- .../permissions/AccessTree/AccessTree.tsx | 51 +++++--- .../components/AccessTreeSecretPathInput.tsx | 114 ++++++++++++++++++ .../AccessTree/nodes/ShowMoreButtonNode.tsx | 29 ++--- .../AccessTree/utils/positionElements.ts | 44 +++++-- .../v2/SecretPathInput/SecretPathInput.tsx | 2 +- 5 files changed, 198 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx index 30705570c..8376ae72f 100644 --- a/frontend/src/components/permissions/AccessTree/AccessTree.tsx +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from "react"; import { MongoAbility, MongoQuery } from "@casl/ability"; import { + faArrowsToDot, + faArrowsUpDownLeftRight, faArrowUpRightFromSquare, faDownLeftAndUpRightToCenter, - faUpLong, faUpRightAndDownLeftFromCenter, faWindowRestore } from "@fortawesome/free-solid-svg-icons"; @@ -24,9 +25,9 @@ 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 { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput"; import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode"; import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components"; import { BasePermissionEdge } from "./edges"; @@ -46,12 +47,13 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { const [selectedPath, setSelectedPath] = useState("/"); const accessTreeData = useAccessTree(permissions, selectedPath); const { edges, nodes, isLoading, viewMode, setViewMode, environment } = accessTreeData; + const [initialRender, setInitialRender] = useState(true); useEffect(() => { setSelectedPath("/"); }, [environment]); - const { getViewport, setCenter } = useReactFlow(); + const { getViewport, setCenter, fitView } = useReactFlow(); const goToRootNode = useCallback(() => { const roleNode = nodes.find((node) => node.type === "role"); @@ -76,10 +78,17 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { ); useEffect(() => { - setTimeout(() => { - goToRootNode(); - }, 1); - }, [nodes, edges, getViewport()]); + setInitialRender(true); + }, [selectedPath, environment]); + + useEffect(() => { + if (initialRender) { + setTimeout(() => { + goToRootNode(); + setInitialRender(false); + }, 500); + } + }, [nodes, edges, getViewport(), initialRender]); const handleToggleModalView = () => setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal)); @@ -156,6 +165,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { edgesReconnectable={false} nodesConnectable={false} connectionLineType={ConnectionLineType.SmoothStep} + minZoom={0.001} proOptions={{ hideAttribution: false // we need pro license if we want to hide }} @@ -167,12 +177,14 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { )} {viewMode !== ViewMode.Docked && ( - + {viewMode !== ViewMode.Undocked && ( + + )} { )} {viewMode === ViewMode.Docked && ( - { )} - + + fitView({ duration: 800 })}> + + + + - - + + diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx new file mode 100644 index 000000000..0e3c8777e --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx @@ -0,0 +1,114 @@ +import { useRef, useState } from "react"; +import { faSearch, faTimes } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Tooltip } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; + +type AccessTreeSecretPathInputProps = { + placeholder: string; + environment: string; + value: string; + onChange: (path: string) => void; +}; + +export const AccessTreeSecretPathInput = ({ + placeholder, + environment, + value, + onChange +}: AccessTreeSecretPathInputProps) => { + const [isFocused, setIsFocused] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const wrapperRef = useRef(null); + const inputRef = useRef(null); + + const handleFocus = () => { + setIsFocused(true); + }; + + const handleBlur = () => { + setTimeout(() => { + setIsFocused(false); + }, 200); + }; + + const focusInput = () => { + const inputElement = inputRef.current?.querySelector("input"); + if (inputElement) { + inputElement.focus(); + } + }; + + const toggleSearch = () => { + setIsExpanded(!isExpanded); + if (!isExpanded) { + setTimeout(focusInput, 300); + } + }; + + return ( +
+
+ {isExpanded ? ( +
{ + if (e.key === "Enter" || e.key === " ") { + toggleSearch(); + } + }} + > + +
+ ) : ( + +
{ + if (e.key === "Enter" || e.key === " ") { + toggleSearch(); + } + }} + > + +
+
+ )} + +
+
+ +
+
+
+
+ ); +}; diff --git a/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx index 02b559bf2..2a6cabf43 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx @@ -1,35 +1,30 @@ -import { faChevronRight, faFolderClosed } from "@fortawesome/free-solid-svg-icons"; +import { faChevronRight } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Handle, NodeProps, Position } from "@xyflow/react"; -import { Tooltip } from "@app/components/v2"; +import { Button, Tooltip } from "@app/components/v2"; import { createShowMoreNode } from "../utils/createShowMoreNode"; export const ShowMoreButtonNode = ({ data: { onClick, remaining } }: NodeProps & { data: ReturnType["data"] }) => { + const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`; + return ( <> - - + Show More + ); diff --git a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts index 8fd7bbbf5..8b90a752f 100644 --- a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts +++ b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts @@ -6,6 +6,14 @@ export const positionElements = (nodes: Node[], edges: Edge[]) => { const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton"); const nodeMap: Record = {}; + const childrenMap: Record = {}; + + edges.forEach((edge) => { + if (!childrenMap[edge.source]) { + childrenMap[edge.source] = []; + } + childrenMap[edge.source].push(edge.target); + }); const dagre = new Dagre.graphlib.Graph({ directed: true }) .setDefaultEdgeLabel(() => ({})) @@ -33,27 +41,49 @@ export const positionElements = (nodes: Node[], edges: Edge[]) => { return positionedNode; }); + const findLastChildNode = (parentId: string): Node | undefined => { + const childrenIds = childrenMap[parentId] || []; + if (childrenIds.length === 0) return undefined; + + const childNodes = childrenIds.map((id) => nodeMap[id]).filter(Boolean); + + if (childNodes.length === 0) return undefined; + + childNodes.sort((a, b) => { + if (a.position.y === b.position.y) { + return b.position.x - a.position.x; + } + return b.position.y - a.position.y; + }); + + return childNodes[0]; + }; + 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 lastChildNode = findLastChildNode(parentId); - const parentWidth = parentNode.width || 150; - const buttonWidth = node.width || 100; + const referenceNode = lastChildNode || parentNode; - const buttonX = isStart ? parentX - buttonWidth - 20 : parentX + parentWidth + 20; + const referenceX = referenceNode.position.x; + const referenceY = referenceNode.position.y; + + const referenceWidth = referenceNode.width || 150; + + const buttonX = referenceX + referenceWidth - 85; + const buttonY = referenceY - 25; return { ...node, position: { x: buttonX, - y: parentY + y: buttonY } }; }); + return { nodes: [...positionedNodes, ...positionedShowMoreNodes], edges diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index 8e82c2915..31d0ff72c 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -141,7 +141,7 @@ export const SecretPathInput = ({ maxHeight: "var(--radix-select-content-available-height)" }} > -
+
{suggestions.map((suggestion, i) => (
Date: Wed, 26 Mar 2025 17:30:20 -0300 Subject: [PATCH 3/8] Allow access tree relative path graph on path filter --- .../permissions/AccessTree/AccessTree.tsx | 4 +- .../permissions/AccessTree/hooks/index.ts | 93 +++++++++++++++---- .../components/AccessTreeSecretPathInput.tsx | 12 ++- .../v2/SecretPathInput/SecretPathInput.tsx | 4 - 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx index 8376ae72f..265e87623 100644 --- a/frontend/src/components/permissions/AccessTree/AccessTree.tsx +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { MongoAbility, MongoQuery } from "@casl/ability"; import { - faArrowsToDot, + faAnglesUp, faArrowsUpDownLeftRight, faArrowUpRightFromSquare, faDownLeftAndUpRightToCenter, @@ -241,7 +241,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { - + diff --git a/frontend/src/components/permissions/AccessTree/hooks/index.ts b/frontend/src/components/permissions/AccessTree/hooks/index.ts index 9a0292fb6..8651ea543 100644 --- a/frontend/src/components/permissions/AccessTree/hooks/index.ts +++ b/frontend/src/components/permissions/AccessTree/hooks/index.ts @@ -88,21 +88,18 @@ export const useAccessTree = ( if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return; const { folders } = environmentsFolders[environment]; - setTotalFolderCount(folders.length); - const groupedFolders: Record = {}; + const searchPathFolder = folders.find((folder) => folder.path === searchPath); const filteredFolders = folders.filter((folder) => { - if (folder.path.startsWith(searchPath)) { + if (folder.path === searchPath) { return true; } if ( - searchPath.startsWith(folder.path) && - (folder.path === "/" || - searchPath === folder.path || - searchPath.indexOf("/", folder.path.length) === folder.path.length) + folder.path.startsWith(searchPath) && + (searchPath === "/" || folder.path.charAt(searchPath.length) === "/") ) { return true; } @@ -110,11 +107,17 @@ export const useAccessTree = ( return false; }); + const rootFolder = searchPathFolder || filteredFolders.find((f) => f.path === "/"); + + const groupedFolders: Record = {}; + filteredFolders.forEach((folder) => { const parentId = folder.parentId || ""; + if (!groupedFolders[parentId]) { groupedFolders[parentId] = []; } + groupedFolders[parentId].push(folder); }); @@ -129,7 +132,18 @@ export const useAccessTree = ( }; }); - setLevelFolderMap(newLevelFolderMap); + if (rootFolder) { + setLevelFolderMap({ + ...newLevelFolderMap, + __rootFolderId: { + folders: [rootFolder], + visibleCount: 1, + hasMore: false + } + }); + } else { + setLevelFolderMap(newLevelFolderMap); + } }, [permissions, environmentsFolders, environment, subject, secretName, searchPath]); useEffect(() => { @@ -151,11 +165,15 @@ export const useAccessTree = ( const actionRuleMap = getSubjectActionRuleMap(subject, permissions); const visibleFolders: TSecretFolderWithPath[] = []; - - Object.values(levelFolderMap).forEach((levelData) => { - visibleFolders.push(...levelData.folders.slice(0, levelData.visibleCount)); + Object.entries(levelFolderMap).forEach(([key, levelData]) => { + if (key !== "__rootFolderId") { + visibleFolders.push(...levelData.folders.slice(0, levelData.visibleCount)); + } }); + // eslint-disable-next-line no-underscore-dangle + const rootFolder = levelFolderMap.__rootFolderId?.folders[0]; + const folderNodes = visibleFolders.map((folder) => createFolderNode({ folder, @@ -167,10 +185,45 @@ export const useAccessTree = ( }) ); - const folderEdges = folderNodes.map(({ data: folder }) => { - const actions = Object.values(folder.actions); + const folderEdges: Edge[] = []; + if (rootFolder) { + const rootFolderNode = folderNodes.find( + (node) => node.data.id === rootFolder.id || node.data.path === rootFolder.path + ); + + if (rootFolderNode) { + const rootActions = Object.values(rootFolderNode.data.actions); + let rootAccess: PermissionAccess; + + if (Object.values(rootActions).some((action) => action === PermissionAccess.Full)) { + rootAccess = PermissionAccess.Full; + } else if ( + Object.values(rootActions).some((action) => action === PermissionAccess.Partial) + ) { + rootAccess = PermissionAccess.Partial; + } else { + rootAccess = PermissionAccess.None; + } + + folderEdges.push( + createBaseEdge({ + source: roleNode.id, + target: rootFolderNode.id, + access: rootAccess + }) + ); + } + } + + folderNodes.forEach(({ data: folder }) => { + if (rootFolder && (folder.id === rootFolder.id || folder.path === rootFolder.path)) { + return; + } + + const actions = Object.values(folder.actions); let access: PermissionAccess; + if (Object.values(actions).some((action) => action === PermissionAccess.Full)) { access = PermissionAccess.Full; } else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) { @@ -179,16 +232,20 @@ export const useAccessTree = ( access = PermissionAccess.None; } - return createBaseEdge({ - source: folder.parentId ?? roleNode.id, - target: folder.id, - access - }); + folderEdges.push( + createBaseEdge({ + source: folder.parentId ?? roleNode.id, + target: folder.id, + access + }) + ); }); const addMoreButtons: Node[] = []; Object.entries(levelFolderMap).forEach(([parentId, levelData]) => { + if (parentId === "__rootFolderId") return; + const key = parentId === "null" ? null : parentId; if (key && levelData.hasMore) { diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx index 0e3c8777e..1702d1214 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx @@ -1,5 +1,5 @@ -import { useRef, useState } from "react"; -import { faSearch, faTimes } from "@fortawesome/free-solid-svg-icons"; +import { useEffect, useRef, useState } from "react"; +import { faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -34,6 +34,12 @@ export const AccessTreeSecretPathInput = ({ }, 200); }; + useEffect(() => { + if (!isFocused) { + setIsExpanded(false); + } + }, [isFocused]); + const focusInput = () => { const inputElement = inputRef.current?.querySelector("input"); if (inputElement) { @@ -69,7 +75,7 @@ export const AccessTreeSecretPathInput = ({ } }} > - +
) : ( diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index 31d0ff72c..c82e492e8 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -48,7 +48,6 @@ export const SecretPathInput = ({ }, [propValue]); useEffect(() => { - // update secret path if input is valid if ( (debouncedInputValue.length > 0 && debouncedInputValue[debouncedInputValue.length - 1] === "/") || @@ -59,7 +58,6 @@ export const SecretPathInput = ({ }, [debouncedInputValue]); useEffect(() => { - // filter suggestions based on matching const searchFragment = debouncedInputValue.split("/").pop() || ""; const filteredSuggestions = folders .filter((suggestionEntry) => @@ -78,7 +76,6 @@ export const SecretPathInput = ({ const validPaths = inputValue.split("/"); validPaths.pop(); - // removed trailing slash const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`; onChange?.(newValue); setInputValue(newValue); @@ -102,7 +99,6 @@ export const SecretPathInput = ({ }; const handleInputChange = (e: any) => { - // propagate event to react-hook-form onChange if (onChange) { onChange(e.target.value); } From 2f4c42482d10652d193ac3e7c25db31ec116e4fd Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Thu, 27 Mar 2025 16:45:33 -0300 Subject: [PATCH 4/8] Fix type issue on buildFolderPath --- backend/src/services/secret-folder/secret-folder-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/secret-folder/secret-folder-fns.ts b/backend/src/services/secret-folder/secret-folder-fns.ts index 0369c4e11..7e799a826 100644 --- a/backend/src/services/secret-folder/secret-folder-fns.ts +++ b/backend/src/services/secret-folder/secret-folder-fns.ts @@ -5,7 +5,7 @@ export const buildFolderPath = ( foldersMap: Record, depth: number = 0 ): string => { - if (depth > 20) return; + if (depth > 20) return ""; if (!folder.parentId) { return depth === 0 ? "/" : ""; } From b0cacc5a4a9dbb3fa7f2255721ca42d57421bb35 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Fri, 28 Mar 2025 11:09:56 -0300 Subject: [PATCH 5/8] General improvements to Access Tree view --- .../secret-folder/secret-folder-fns.ts | 5 +- .../secret-folder/secret-folder-service.ts | 27 +++- .../permissions/AccessTree/AccessTree.tsx | 15 +-- .../permissions/AccessTree/hooks/index.ts | 43 +++---- .../permissions/AccessTree/nodes/RoleNode.tsx | 95 ++++++++++++-- .../AccessTree/nodes/ShowMoreButtonNode.tsx | 34 ++--- .../AccessTree/utils/createRoleNode.ts | 18 ++- .../AccessTree/utils/createShowMoreNode.ts | 26 +++- .../AccessTree/utils/positionElements.ts | 116 +++++++++--------- 9 files changed, 256 insertions(+), 123 deletions(-) diff --git a/backend/src/services/secret-folder/secret-folder-fns.ts b/backend/src/services/secret-folder/secret-folder-fns.ts index 7e799a826..a3783a1b9 100644 --- a/backend/src/services/secret-folder/secret-folder-fns.ts +++ b/backend/src/services/secret-folder/secret-folder-fns.ts @@ -1,11 +1,14 @@ 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) return ""; + if (depth > 20) { + throw new InternalServerError({ message: "Maximum folder depth of 20 exceeded" }); + } if (!folder.parentId) { return depth === 0 ? "/" : ""; } diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index d4f58a1d4..96bcdd6f3 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -619,10 +619,29 @@ export const secretFolderServiceFactory = ({ const relevantFolders = folders.filter((folder) => folder.envId === env.id); const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder])); - const foldersWithPath = relevantFolders.map((folder) => ({ - ...folder, - path: buildFolderPath(folder, foldersMap) - })); + const foldersWithPath = relevantFolders + .map((folder) => { + try { + return { + ...folder, + path: buildFolderPath(folder, foldersMap) + }; + } catch (error) { + return null; + } + }) + .filter(Boolean) as { + path: string; + id: string; + createdAt: Date; + updatedAt: Date; + name: string; + envId: string; + version?: number | null | undefined; + parentId?: string | null | undefined; + isReserved?: boolean | undefined; + description?: string | undefined; + }[]; return [env.slug, { ...env, folders: foldersWithPath }]; }) diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx index 265e87623..4ba2c718b 100644 --- a/frontend/src/components/permissions/AccessTree/AccessTree.tsx +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from "react"; import { MongoAbility, MongoQuery } from "@casl/ability"; import { faAnglesUp, - faArrowsUpDownLeftRight, faArrowUpRightFromSquare, faDownLeftAndUpRightToCenter, faUpRightAndDownLeftFromCenter, @@ -29,7 +28,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput"; import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode"; -import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components"; +import { AccessTreeErrorBoundary, AccessTreeProvider } from "./components"; import { BasePermissionEdge } from "./edges"; import { useAccessTree } from "./hooks"; import { FolderNode, RoleNode } from "./nodes"; @@ -231,14 +230,12 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { /> )} - - - fitView({ duration: 800 })}> - - - - + fitView({ duration: 800 })} + > diff --git a/frontend/src/components/permissions/AccessTree/hooks/index.ts b/frontend/src/components/permissions/AccessTree/hooks/index.ts index 8651ea543..a03f8fa0f 100644 --- a/frontend/src/components/permissions/AccessTree/hooks/index.ts +++ b/frontend/src/components/permissions/AccessTree/hooks/index.ts @@ -89,17 +89,18 @@ export const useAccessTree = ( const { folders } = environmentsFolders[environment]; setTotalFolderCount(folders.length); - - const searchPathFolder = folders.find((folder) => folder.path === searchPath); + const groupedFolders: Record = {}; const filteredFolders = folders.filter((folder) => { - if (folder.path === searchPath) { + if (folder.path.startsWith(searchPath)) { return true; } if ( - folder.path.startsWith(searchPath) && - (searchPath === "/" || folder.path.charAt(searchPath.length) === "/") + searchPath.startsWith(folder.path) && + (folder.path === "/" || + searchPath === folder.path || + searchPath.indexOf("/", folder.path.length) === folder.path.length) ) { return true; } @@ -107,17 +108,11 @@ export const useAccessTree = ( return false; }); - const rootFolder = searchPathFolder || filteredFolders.find((f) => f.path === "/"); - - const groupedFolders: Record = {}; - filteredFolders.forEach((folder) => { const parentId = folder.parentId || ""; - if (!groupedFolders[parentId]) { groupedFolders[parentId] = []; } - groupedFolders[parentId].push(folder); }); @@ -132,18 +127,7 @@ export const useAccessTree = ( }; }); - if (rootFolder) { - setLevelFolderMap({ - ...newLevelFolderMap, - __rootFolderId: { - folders: [rootFolder], - visibleCount: 1, - hasMore: false - } - }); - } else { - setLevelFolderMap(newLevelFolderMap); - } + setLevelFolderMap(newLevelFolderMap); }, [permissions, environmentsFolders, environment, subject, secretName, searchPath]); useEffect(() => { @@ -155,11 +139,14 @@ export const useAccessTree = ( ) return; - const { name } = environmentsFolders[environment]; + const { slug } = environmentsFolders[environment]; const roleNode = createRoleNode({ subject, - environment: name + environment: slug, + environments: environmentsFolders, + onSubjectChange: setSubject, + onEnvironmentChange: setEnvironment }); const actionRuleMap = getSubjectActionRuleMap(subject, permissions); @@ -252,7 +239,8 @@ export const useAccessTree = ( const showMoreButtonNode = createShowMoreNode({ parentId: key, onClick: () => showMoreFolders(key), - remaining: levelData.folders.length - levelData.visibleCount + remaining: levelData.folders.length - levelData.visibleCount, + subject }); addMoreButtons.push(showMoreButtonNode); @@ -261,8 +249,7 @@ export const useAccessTree = ( createBaseEdge({ source: key, target: showMoreButtonNode.id, - access: PermissionAccess.Full, - hideEdge: true + access: PermissionAccess.Partial }) ); } diff --git a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx index d38d896f2..f6ffe212e 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx @@ -1,10 +1,42 @@ +import { Dispatch, SetStateAction } from "react"; +import { faFileImport, faFolder, faKey, faLock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Handle, NodeProps, Position } from "@xyflow/react"; +import { Select, SelectItem } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types"; + import { createRoleNode } from "../utils"; +const getSubjectIcon = (subject: ProjectPermissionSub) => { + switch (subject) { + case ProjectPermissionSub.Secrets: + return ; + case ProjectPermissionSub.SecretFolders: + return ; + case ProjectPermissionSub.DynamicSecrets: + return ; + case ProjectPermissionSub.SecretImports: + return ; + default: + return ; + } +}; + +const formatLabel = (text: string) => { + return text.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +}; + export const RoleNode = ({ - data: { subject, environment } -}: NodeProps & { data: ReturnType["data"] }) => { + data: { subject, environment, onSubjectChange, onEnvironmentChange, environments } +}: NodeProps & { + data: ReturnType["data"] & { + onSubjectChange: Dispatch>; + onEnvironmentChange: (value: string) => void; + environments: TProjectEnvironmentsFolders; + }; +}) => { return ( <> -
-
- {subject.replace("-", " ")} Access -
-

{environment}

+
+
+
+
Subject
+ +
+ +
+
Environment
+
diff --git a/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx index 2a6cabf43..dc412d8ca 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx @@ -12,20 +12,26 @@ export const ShowMoreButtonNode = ({ const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`; return ( - <> - +
+ - - - - +
+ + + +
+
); }; diff --git a/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts b/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts index 354a69482..69ec60896 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts @@ -1,17 +1,31 @@ +import { Dispatch, SetStateAction } from "react"; + +import { ProjectPermissionSub } from "@app/context"; +import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types"; + import { PermissionNode } from "../types"; export const createRoleNode = ({ subject, - environment + environment, + environments, + onSubjectChange, + onEnvironmentChange }: { subject: string; environment: string; + environments: TProjectEnvironmentsFolders; + onSubjectChange: Dispatch>; + onEnvironmentChange: (value: string) => void; }) => ({ id: `role-${subject}-${environment}`, position: { x: 0, y: 0 }, data: { subject, - environment + environment, + environments, + onSubjectChange, + onEnvironmentChange }, type: PermissionNode.Role, height: 48, diff --git a/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts b/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts index e5758ffc9..f12cd598e 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts @@ -1,14 +1,30 @@ +import { ProjectPermissionSub } from "@app/context"; + import { PermissionNode } from "../types"; export const createShowMoreNode = ({ parentId, onClick, - remaining + remaining, + subject }: { parentId: string | null; onClick: () => void; remaining: number; + subject: ProjectPermissionSub; }) => { + let height: number; + + switch (subject) { + case ProjectPermissionSub.DynamicSecrets: + height = 130; + break; + case ProjectPermissionSub.Secrets: + height = 85; + break; + default: + height = 64; + } const id = `show-more-${parentId || "root"}`; return { id, @@ -19,7 +35,11 @@ export const createShowMoreNode = ({ onClick, remaining }, - width: 100, - height: 40 + width: 150, + height, + style: { + background: "transparent", + border: "none" + } }; }; diff --git a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts index 8b90a752f..c4daf6317 100644 --- a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts +++ b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts @@ -2,8 +2,10 @@ 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 showMoreParentIds = new Set( + showMoreNodes.map((node) => node.data.parentId).filter(Boolean) + ); const nodeMap: Record = {}; const childrenMap: Record = {}; @@ -17,75 +19,79 @@ export const positionElements = (nodes: Node[], edges: Edge[]) => { const dagre = new Dagre.graphlib.Graph({ directed: true }) .setDefaultEdgeLabel(() => ({})) - .setGraph({ rankdir: "TB" }); + .setGraph({ + rankdir: "TB", + nodesep: 50, + ranksep: 70 + }); + + nodes.forEach((node) => { + dagre.setNode(node.id, { + width: node.width || 150, + height: node.height || 40 + }); + }); edges.forEach((edge) => dagre.setEdge(edge.source, edge.target)); - regularNodes.forEach((node) => dagre.setNode(node.id, node)); - Dagre.layout(dagre, {}); - const positionedNodes = regularNodes.map((node) => { + const positionedNodes = nodes.map((node) => { const { x, y } = dagre.node(node.id); - 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 findLastChildNode = (parentId: string): Node | undefined => { - const childrenIds = childrenMap[parentId] || []; - if (childrenIds.length === 0) return undefined; - - const childNodes = childrenIds.map((id) => nodeMap[id]).filter(Boolean); - - if (childNodes.length === 0) return undefined; - - childNodes.sort((a, b) => { - if (a.position.y === b.position.y) { - return b.position.x - a.position.x; - } - return b.position.y - a.position.y; - }); - - return childNodes[0]; - }; - - const positionedShowMoreNodes = showMoreNodes.map((node) => { - const parentId = node.data.parentId as string; - - const parentNode = nodeMap[parentId] || positionedNodes[0]; - const lastChildNode = findLastChildNode(parentId); - - const referenceNode = lastChildNode || parentNode; - - const referenceX = referenceNode.position.x; - const referenceY = referenceNode.position.y; - - const referenceWidth = referenceNode.width || 150; - - const buttonX = referenceX + referenceWidth - 85; - const buttonY = referenceY - 25; + if (node.type === "role") { + return { + ...node, + position: { + x: x - (node.width ? node.width / 2 : 0), + y: y - 150 + } + }; + } return { ...node, position: { - x: buttonX, - y: buttonY - } + x: x - (node.width ? node.width / 2 : 0), + y: y - (node.height ? node.height / 2 : 0) + }, + style: node.type === "showMoreButton" ? { ...node.style, zIndex: 10 } : node.style }; }); + positionedNodes.forEach((node) => { + nodeMap[node.id] = node; + }); + + Array.from(showMoreParentIds).forEach((parentId) => { + const showMoreNodeIndex = positionedNodes.findIndex( + (node) => node.type === "showMoreButton" && node.data.parentId === parentId + ); + + if (showMoreNodeIndex !== -1) { + const siblings = positionedNodes.filter( + (node) => node.data?.parentId === parentId && node.type !== "showMoreButton" + ); + + if (siblings.length > 0) { + const rightmostSibling = siblings.reduce( + (rightmost, current) => (current.position.x > rightmost.position.x ? current : rightmost), + siblings[0] + ); + + positionedNodes[showMoreNodeIndex] = { + ...positionedNodes[showMoreNodeIndex], + position: { + x: rightmostSibling.position.x + (rightmostSibling.width || 150) + 30, + y: rightmostSibling.position.y + } + }; + } + } + }); + return { - nodes: [...positionedNodes, ...positionedShowMoreNodes], + nodes: positionedNodes, edges }; }; From 74200bf860ea3f32373732705709835f76fded6e Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Mon, 31 Mar 2025 18:20:01 -0300 Subject: [PATCH 6/8] Add clearTimeout on setTimeouts --- .../src/components/permissions/AccessTree/AccessTree.tsx | 4 +++- .../FolderNode/components/AccessTreeSecretPathInput.tsx | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx index 4ba2c718b..ee0667582 100644 --- a/frontend/src/components/permissions/AccessTree/AccessTree.tsx +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -81,12 +81,14 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { }, [selectedPath, environment]); useEffect(() => { + let timer: NodeJS.Timeout; if (initialRender) { - setTimeout(() => { + timer = setTimeout(() => { goToRootNode(); setInitialRender(false); }, 500); } + return () => clearTimeout(timer); }, [nodes, edges, getViewport(), initialRender]); const handleToggleModalView = () => diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx index 1702d1214..76a098ed3 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx @@ -29,9 +29,10 @@ export const AccessTreeSecretPathInput = ({ }; const handleBlur = () => { - setTimeout(() => { + const timeout: NodeJS.Timeout = setTimeout(() => { setIsFocused(false); }, 200); + return () => clearTimeout(timeout); }; useEffect(() => { @@ -50,8 +51,10 @@ export const AccessTreeSecretPathInput = ({ const toggleSearch = () => { setIsExpanded(!isExpanded); if (!isExpanded) { - setTimeout(focusInput, 300); + const timeout: NodeJS.Timeout = setTimeout(focusInput, 300); + return () => clearTimeout(timeout); } + return () => {}; }; return ( From 47dc4f0c47660be51cc5bcdefb361f1940860a25 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Tue, 1 Apr 2025 09:27:23 -0300 Subject: [PATCH 7/8] Fix top right icons to dock/undock view --- frontend/src/components/permissions/AccessTree/AccessTree.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx index ee0667582..7efe7889f 100644 --- a/frontend/src/components/permissions/AccessTree/AccessTree.tsx +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -188,7 +188,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { )} { Date: Tue, 1 Apr 2025 11:01:03 -0300 Subject: [PATCH 8/8] Add goToRootNode to effect dependencies --- frontend/src/components/permissions/AccessTree/AccessTree.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx index 7efe7889f..76e80d8b6 100644 --- a/frontend/src/components/permissions/AccessTree/AccessTree.tsx +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -89,7 +89,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { }, 500); } return () => clearTimeout(timer); - }, [nodes, edges, getViewport(), initialRender]); + }, [nodes, edges, getViewport(), initialRender, goToRootNode]); const handleToggleModalView = () => setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));