General improvements to Access Tree view

This commit is contained in:
carlosmonastyrski
2025-03-28 11:09:56 -03:00
parent 2f4c42482d
commit b0cacc5a4a
9 changed files with 256 additions and 123 deletions

View File

@@ -1,11 +1,14 @@
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) return "";
if (depth > 20) {
throw new InternalServerError({ message: "Maximum folder depth of 20 exceeded" });
}
if (!folder.parentId) {
return depth === 0 ? "/" : "";
}

View File

@@ -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 }];
})

View File

@@ -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) => {
/>
</Panel>
)}
<PermissionSimulation {...accessTreeData} />
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
<Controls position="bottom-left" showInteractive={false} showFitView={false}>
<ControlButton onClick={() => fitView({ duration: 800 })}>
<Tooltip position="right" content="Set view to fit all nodes">
<FontAwesomeIcon icon={faArrowsUpDownLeftRight} />
</Tooltip>
</ControlButton>
<Controls
position="bottom-left"
showInteractive={false}
onFitView={() => fitView({ duration: 800 })}
>
<ControlButton onClick={goToRootNode}>
<Tooltip position="right" content="Go to root folder">
<FontAwesomeIcon icon={faAnglesUp} />

View File

@@ -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<string, TSecretFolderWithPath[]> = {};
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<string, TSecretFolderWithPath[]> = {};
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
})
);
}

View File

@@ -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 <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.SecretFolders:
return <FontAwesomeIcon icon={faFolder} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.DynamicSecrets:
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.SecretImports:
return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-yellow-700" />;
default:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
}
};
const formatLabel = (text: string) => {
return text.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
};
export const RoleNode = ({
data: { subject, environment }
}: NodeProps & { data: ReturnType<typeof createRoleNode>["data"] }) => {
data: { subject, environment, onSubjectChange, onEnvironmentChange, environments }
}: NodeProps & {
data: ReturnType<typeof createRoleNode>["data"] & {
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
onEnvironmentChange: (value: string) => void;
environments: TProjectEnvironmentsFolders;
};
}) => {
return (
<>
<Handle
@@ -12,11 +44,60 @@ export const RoleNode = ({
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-3 py-2 font-inter shadow-lg">
<div className="flex max-w-[14rem] flex-col items-center text-xs text-mineshaft-200">
<span className="capitalize">{subject.replace("-", " ")} Access</span>
<div className="max-w-[14rem] whitespace-nowrap text-xs text-mineshaft-300">
<p className="truncate capitalize">{environment}</p>
<div className="flex w-full flex-col items-center justify-center rounded-md border-2 border-mineshaft-500 bg-gradient-to-b from-mineshaft-700 to-mineshaft-800 px-5 py-4 font-inter shadow-2xl">
<div className="flex w-full min-w-[240px] flex-col gap-4">
<div className="flex w-full flex-col gap-1.5">
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Subject</div>
<Select
value={subject}
onValueChange={(value) => onSubjectChange(value as ProjectPermissionSub)}
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
position="popper"
dropdownContainerClassName="max-w-none"
aria-label="Subject"
>
{[
ProjectPermissionSub.Secrets,
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.DynamicSecrets,
ProjectPermissionSub.SecretImports
].map((sub) => {
return (
<SelectItem
className="relative flex items-center gap-2 py-2 pl-8 pr-8 text-sm capitalize hover:bg-mineshaft-700"
value={sub}
key={sub}
>
<div className="flex items-center gap-3">
{getSubjectIcon(sub)}
<span className="font-medium">{formatLabel(sub)}</span>
</div>
</SelectItem>
);
})}
</Select>
</div>
<div className="flex w-full flex-col gap-1.5">
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Environment</div>
<Select
value={environment}
onValueChange={onEnvironmentChange}
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
position="popper"
dropdownContainerClassName="max-w-none"
aria-label="Environment"
>
{Object.values(environments).map((env) => (
<SelectItem
key={env.slug}
value={env.slug}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{env.name}</div>
</SelectItem>
))}
</Select>
</div>
</div>
</div>

View File

@@ -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 (
<>
<Handle type="target" position={Position.Top} style={{ visibility: "hidden" }} />
<div className="flex h-full w-full items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-2">
<Handle
type="target"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<Tooltip position="right" content={tooltipText}>
<Button
colorSchema="secondary"
variant="plain"
size="xs"
onClick={onClick}
rightIcon={<FontAwesomeIcon icon={faChevronRight} className="ml-1" />}
>
Show More
</Button>
</Tooltip>
</>
<div className="flex items-center justify-center">
<Tooltip position="right" content={tooltipText}>
<Button
colorSchema="secondary"
variant="plain"
size="xs"
onClick={onClick}
rightIcon={<FontAwesomeIcon icon={faChevronRight} className="ml-1" />}
>
Show More
</Button>
</Tooltip>
</div>
</div>
);
};

View File

@@ -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<SetStateAction<ProjectPermissionSub>>;
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,

View File

@@ -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"
}
};
};

View File

@@ -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<string, Node> = {};
const childrenMap: Record<string, string[]> = {};
@@ -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
};
};