Add horizontal limit, search bar and change icons of access tree component

This commit is contained in:
carlosmonastyrski
2025-03-26 11:13:12 -03:00
parent bc1b7ddcc5
commit d5741b4a72
9 changed files with 339 additions and 43 deletions
@@ -1,14 +1,11 @@
import { TSecretFolders } from "@app/db/schemas"; import { TSecretFolders } from "@app/db/schemas";
import { InternalServerError } from "@app/lib/errors";
export const buildFolderPath = ( export const buildFolderPath = (
folder: TSecretFolders, folder: TSecretFolders,
foldersMap: Record<string, TSecretFolders>, foldersMap: Record<string, TSecretFolders>,
depth: number = 0 depth: number = 0
): string => { ): string => {
if (depth > 20) { if (depth > 20) return;
throw new InternalServerError({ message: "Maximum folder depth of 20 exceeded" });
}
if (!folder.parentId) { if (!folder.parentId) {
return depth === 0 ? "/" : ""; return depth === 0 ? "/" : "";
} }
@@ -1,7 +1,9 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import { MongoAbility, MongoQuery } from "@casl/ability"; import { MongoAbility, MongoQuery } from "@casl/ability";
import { import {
faArrowUpRightFromSquare, faArrowUpRightFromSquare,
faDownLeftAndUpRightToCenter,
faUpLong,
faUpRightAndDownLeftFromCenter, faUpRightAndDownLeftFromCenter,
faWindowRestore faWindowRestore
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
@@ -10,6 +12,7 @@ import {
Background, Background,
BackgroundVariant, BackgroundVariant,
ConnectionLineType, ConnectionLineType,
ControlButton,
Controls, Controls,
Node, Node,
NodeMouseHandler, NodeMouseHandler,
@@ -21,8 +24,10 @@ import {
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2"; import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode";
import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components"; import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components";
import { BasePermissionEdge } from "./edges"; import { BasePermissionEdge } from "./edges";
import { useAccessTree } from "./hooks"; import { useAccessTree } from "./hooks";
@@ -35,13 +40,29 @@ export type AccessTreeProps = {
const EdgeTypes = { base: BasePermissionEdge }; const EdgeTypes = { base: BasePermissionEdge };
const NodeTypes = { role: RoleNode, folder: FolderNode }; const NodeTypes = { role: RoleNode, folder: FolderNode, showMoreButton: ShowMoreButtonNode };
const AccessTreeContent = ({ permissions }: AccessTreeProps) => { const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
const accessTreeData = useAccessTree(permissions); const [selectedPath, setSelectedPath] = useState<string>("/");
const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData; 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<Node> = useCallback( const onNodeClick: NodeMouseHandler<Node> = useCallback(
(_, node) => { (_, node) => {
@@ -56,13 +77,9 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
fitView({ goToRootNode();
padding: 0.2,
duration: 1000,
maxZoom: 1
});
}, 1); }, 1);
}, [fitView, nodes, edges, getViewport()]); }, [nodes, edges, getViewport()]);
const handleToggleModalView = () => const handleToggleModalView = () =>
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal)); setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
@@ -133,7 +150,6 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
edges={edges} edges={edges}
edgeTypes={EdgeTypes} edgeTypes={EdgeTypes}
nodeTypes={NodeTypes} nodeTypes={NodeTypes}
fitView
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
colorMode="dark" colorMode="dark"
nodesDraggable={false} nodesDraggable={false}
@@ -151,6 +167,12 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
)} )}
{viewMode !== ViewMode.Docked && ( {viewMode !== ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5"> <Panel position="top-right" className="flex gap-1.5">
<SecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
<Tooltip position="bottom" align="center" content={undockButtonLabel}> <Tooltip position="bottom" align="center" content={undockButtonLabel}>
<IconButton <IconButton
className="mr-1 rounded" className="mr-1 rounded"
@@ -179,7 +201,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
<FontAwesomeIcon <FontAwesomeIcon
icon={ icon={
viewMode === ViewMode.Modal viewMode === ViewMode.Modal
? faArrowUpRightFromSquare ? faDownLeftAndUpRightToCenter
: faUpRightAndDownLeftFromCenter : faUpRightAndDownLeftFromCenter
} }
/> />
@@ -187,9 +209,25 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
</Tooltip> </Tooltip>
</Panel> </Panel>
)} )}
{viewMode === ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
<SecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
</Panel>
)}
<PermissionSimulation {...accessTreeData} /> <PermissionSimulation {...accessTreeData} />
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} /> <Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
<Controls position="bottom-left" /> <Controls position="bottom-left" showFitView={false} showInteractive={false}>
<ControlButton onClick={goToRootNode}>
<Tooltip position="right" content="Go to Root Folder">
<FontAwesomeIcon icon={faUpLong} />
</Tooltip>
</ControlButton>
</Controls>
</ReactFlow> </ReactFlow>
</div> </div>
</div> </div>
@@ -46,6 +46,12 @@ export const PermissionSimulation = ({
className="mr-1 rounded" className="mr-1 rounded"
colorSchema="secondary" colorSchema="secondary"
onClick={handlePermissionSimulation} onClick={handlePermissionSimulation}
rightIcon={
<FontAwesomeIcon
className="pl-1 text-sm text-bunker-300 hover:text-primary hover:opacity-80"
icon={faChevronDown}
/>
}
> >
Permission Simulation Permission Simulation
</Button> </Button>
@@ -5,6 +5,7 @@ import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react";
import { ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries"; import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries";
import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types";
import { useAccessTreeContext } from "../components"; import { useAccessTreeContext } from "../components";
import { PermissionAccess } from "../types"; import { PermissionAccess } from "../types";
@@ -15,8 +16,24 @@ import {
getSubjectActionRuleMap, getSubjectActionRuleMap,
positionElements positionElements
} from "../utils"; } from "../utils";
import { createShowMoreNode } from "../utils/createShowMoreNode";
export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, MongoQuery>) => { 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<ProjectPermissionSet, MongoQuery>,
searchPath: string
) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext(); const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
const [nodes, setNodes] = useNodesState<Node>([]); const [nodes, setNodes] = useNodesState<Node>([]);
@@ -27,10 +44,104 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
currentWorkspace.id currentWorkspace.id
); );
const [levelFolderMap, setLevelFolderMap] = useState<LevelFolderMap>({});
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(() => { useEffect(() => {
if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return; if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return;
const { folders, name } = environmentsFolders[environment]; const { folders } = environmentsFolders[environment];
setTotalFolderCount(folders.length);
const groupedFolders: Record<string, TSecretFolderWithPath[]> = {};
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({ const roleNode = createRoleNode({
subject, subject,
@@ -39,7 +150,13 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
const actionRuleMap = getSubjectActionRuleMap(subject, permissions); const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
const folderNodes = folders.map((folder) => const visibleFolders: TSecretFolderWithPath[] = [];
Object.values(levelFolderMap).forEach((levelData) => {
visibleFolders.push(...levelData.folders.slice(0, levelData.visibleCount));
});
const folderNodes = visibleFolders.map((folder) =>
createFolderNode({ createFolderNode({
folder, folder,
permissions, permissions,
@@ -69,10 +186,44 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
}); });
}); });
const init = positionElements([roleNode, ...folderNodes], [...folderEdges]); const addMoreButtons: Node[] = [];
Object.entries(levelFolderMap).forEach(([parentId, levelData]) => {
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); setNodes(init.nodes);
setEdges(init.edges); setEdges(init.edges);
}, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]); }, [
levelFolderMap,
permissions,
environmentsFolders,
environment,
subject,
secretName,
setNodes,
setEdges
]);
return { return {
nodes, nodes,
@@ -86,6 +237,11 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
secretName, secretName,
setSecretName, setSecretName,
viewMode, viewMode,
setViewMode setViewMode,
levelFolderMap,
showMoreFolders,
levelsWithMoreFolders,
getLevelCounts,
totalFolderCount
}; };
}; };
@@ -0,0 +1,36 @@
import { faChevronRight, faFolderClosed } 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 { createShowMoreNode } from "../utils/createShowMoreNode";
export const ShowMoreButtonNode = ({
data: { onClick, remaining }
}: NodeProps & { data: ReturnType<typeof createShowMoreNode>["data"] }) => {
return (
<>
<Handle type="target" position={Position.Top} style={{ visibility: "hidden" }} />
<Tooltip position="right" content="Click to show 10 more folders">
<button
type="button"
onClick={onClick}
className="group relative flex items-center justify-between gap-3 rounded-md border border-mineshaft-600 bg-mineshaft-800/90 px-4 py-3 text-sm font-medium text-mineshaft-200 shadow-sm transition-all duration-200 hover:border-primary/50 hover:bg-mineshaft-700 hover:text-white hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary/30"
aria-label={`Show ${remaining} more folders`}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolderClosed} className="h-3.5 w-3.5 text-primary/70" />
<span>
{remaining} hidden folder{remaining !== 1 ? "s" : ""}
</span>
</div>
<div className="flex items-center justify-center rounded-full bg-mineshaft-700/80 p-1 group-hover:bg-primary/20">
<FontAwesomeIcon icon={faChevronRight} className="h-3 w-3" />
</div>
</button>
</Tooltip>
</>
);
};
@@ -7,7 +7,8 @@ export enum PermissionAccess {
export enum PermissionNode { export enum PermissionNode {
Role = "role", Role = "role",
Folder = "folder", Folder = "folder",
Environment = "environment" Environment = "environment",
ShowMoreButton = "showMoreButton"
} }
export enum PermissionEdge { export enum PermissionEdge {
@@ -5,11 +5,13 @@ import { PermissionAccess, PermissionEdge } from "../types";
export const createBaseEdge = ({ export const createBaseEdge = ({
source, source,
target, target,
access access,
hideEdge = false
}: { }: {
source: string; source: string;
target: string; target: string;
access: PermissionAccess; access: PermissionAccess;
hideEdge?: boolean;
}) => { }) => {
const color = access === PermissionAccess.None ? "#707174" : "#ccccce"; const color = access === PermissionAccess.None ? "#707174" : "#ccccce";
return { return {
@@ -17,10 +19,12 @@ export const createBaseEdge = ({
source, source,
target, target,
type: PermissionEdge.Base, type: PermissionEdge.Base,
markerEnd: { markerEnd: hideEdge
type: MarkerType.ArrowClosed, ? undefined
color : {
}, type: MarkerType.ArrowClosed,
style: { stroke: color } color
},
style: { stroke: hideEdge ? "transparent" : color }
}; };
}; };
@@ -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
};
};
@@ -2,27 +2,60 @@ import Dagre from "@dagrejs/dagre";
import { Edge, Node } from "@xyflow/react"; import { Edge, Node } from "@xyflow/react";
export const positionElements = (nodes: Node[], edges: Edge[]) => { 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<string, Node> = {};
const dagre = new Dagre.graphlib.Graph({ directed: true }) const dagre = new Dagre.graphlib.Graph({ directed: true })
.setDefaultEdgeLabel(() => ({})) .setDefaultEdgeLabel(() => ({}))
.setGraph({ rankdir: "TB" }); .setGraph({ rankdir: "TB" });
edges.forEach((edge) => dagre.setEdge(edge.source, edge.target)); 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, {}); Dagre.layout(dagre, {});
return { const positionedNodes = regularNodes.map((node) => {
nodes: nodes.map((node) => { const { x, y } = dagre.node(node.id);
const { x, y } = dagre.node(node.id);
return { const positionedNode = {
...node, ...node,
position: { position: {
x: x - (node.width ? node.width / 2 : 0), x: x - (node.width ? node.width / 2 : 0),
y: y - (node.height ? node.height / 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 edges
}; };
}; };