mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Add horizontal limit, search bar and change icons of access tree component
This commit is contained in:
@@ -1,14 +1,11 @@
|
||||
import { TSecretFolders } from "@app/db/schemas";
|
||||
import { InternalServerError } from "@app/lib/errors";
|
||||
|
||||
export const buildFolderPath = (
|
||||
folder: TSecretFolders,
|
||||
foldersMap: Record<string, TSecretFolders>,
|
||||
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 ? "/" : "";
|
||||
}
|
||||
|
||||
@@ -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<string>("/");
|
||||
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(
|
||||
(_, 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 && (
|
||||
<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}>
|
||||
<IconButton
|
||||
className="mr-1 rounded"
|
||||
@@ -179,7 +201,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
viewMode === ViewMode.Modal
|
||||
? faArrowUpRightFromSquare
|
||||
? faDownLeftAndUpRightToCenter
|
||||
: faUpRightAndDownLeftFromCenter
|
||||
}
|
||||
/>
|
||||
@@ -187,9 +209,25 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
||||
</Tooltip>
|
||||
</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} />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,12 @@ export const PermissionSimulation = ({
|
||||
className="mr-1 rounded"
|
||||
colorSchema="secondary"
|
||||
onClick={handlePermissionSimulation}
|
||||
rightIcon={
|
||||
<FontAwesomeIcon
|
||||
className="pl-1 text-sm text-bunker-300 hover:text-primary hover:opacity-80"
|
||||
icon={faChevronDown}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Permission Simulation
|
||||
</Button>
|
||||
|
||||
@@ -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<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 { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
|
||||
const [nodes, setNodes] = useNodesState<Node>([]);
|
||||
@@ -27,10 +44,104 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
|
||||
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(() => {
|
||||
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({
|
||||
subject,
|
||||
@@ -39,7 +150,13 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
|
||||
|
||||
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({
|
||||
folder,
|
||||
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);
|
||||
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<ProjectPermissionSet, Mo
|
||||
secretName,
|
||||
setSecretName,
|
||||
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 {
|
||||
Role = "role",
|
||||
Folder = "folder",
|
||||
Environment = "environment"
|
||||
Environment = "environment",
|
||||
ShowMoreButton = "showMoreButton"
|
||||
}
|
||||
|
||||
export enum PermissionEdge {
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
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 })
|
||||
.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
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user