Merge pull request #3324 from Infisical/fix/accessTreeImprovements

Fix/access tree improvements
This commit is contained in:
carlosmonastyrski
2025-04-01 16:35:17 -03:00
committed by GitHub
13 changed files with 715 additions and 66 deletions

View File

@ -634,10 +634,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

@ -1,7 +1,9 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { MongoAbility, MongoQuery } from "@casl/ability";
import {
faAnglesUp,
faArrowUpRightFromSquare,
faDownLeftAndUpRightToCenter,
faUpRightAndDownLeftFromCenter,
faWindowRestore
} from "@fortawesome/free-solid-svg-icons";
@ -10,6 +12,7 @@ import {
Background,
BackgroundVariant,
ConnectionLineType,
ControlButton,
Controls,
Node,
NodeMouseHandler,
@ -23,7 +26,9 @@ import { twMerge } from "tailwind-merge";
import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components";
import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput";
import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode";
import { AccessTreeErrorBoundary, AccessTreeProvider } from "./components";
import { BasePermissionEdge } from "./edges";
import { useAccessTree } from "./hooks";
import { FolderNode, RoleNode } from "./nodes";
@ -35,13 +40,30 @@ 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 [initialRender, setInitialRender] = useState(true);
const { fitView, getViewport, setCenter } = useReactFlow();
useEffect(() => {
setSelectedPath("/");
}, [environment]);
const { getViewport, setCenter, fitView } = 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) => {
@ -55,14 +77,19 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
);
useEffect(() => {
setTimeout(() => {
fitView({
padding: 0.2,
duration: 1000,
maxZoom: 1
});
}, 1);
}, [fitView, nodes, edges, getViewport()]);
setInitialRender(true);
}, [selectedPath, environment]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (initialRender) {
timer = setTimeout(() => {
goToRootNode();
setInitialRender(false);
}, 500);
}
return () => clearTimeout(timer);
}, [nodes, edges, getViewport(), initialRender, goToRootNode]);
const handleToggleModalView = () =>
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
@ -133,13 +160,13 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
edges={edges}
edgeTypes={EdgeTypes}
nodeTypes={NodeTypes}
fitView
onNodeClick={onNodeClick}
colorMode="dark"
nodesDraggable={false}
edgesReconnectable={false}
nodesConnectable={false}
connectionLineType={ConnectionLineType.SmoothStep}
minZoom={0.001}
proOptions={{
hideAttribution: false // we need pro license if we want to hide
}}
@ -151,9 +178,17 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
)}
{viewMode !== ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
{viewMode !== ViewMode.Undocked && (
<AccessTreeSecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
)}
<Tooltip position="bottom" align="center" content={undockButtonLabel}>
<IconButton
className="mr-1 rounded"
className="ml-1 w-10 rounded"
colorSchema="secondary"
variant="plain"
onClick={handleToggleUndockedView}
@ -170,7 +205,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
</Tooltip>
<Tooltip align="end" position="bottom" content={windowButtonLabel}>
<IconButton
className="rounded"
className="w-10 rounded"
colorSchema="secondary"
variant="plain"
onClick={handleToggleModalView}
@ -179,7 +214,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
<FontAwesomeIcon
icon={
viewMode === ViewMode.Modal
? faArrowUpRightFromSquare
? faDownLeftAndUpRightToCenter
: faUpRightAndDownLeftFromCenter
}
/>
@ -187,9 +222,28 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
</Tooltip>
</Panel>
)}
<PermissionSimulation {...accessTreeData} />
{viewMode === ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
<AccessTreeSecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
</Panel>
)}
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
<Controls position="bottom-left" />
<Controls
position="bottom-left"
showInteractive={false}
onFitView={() => fitView({ duration: 800 })}
>
<ControlButton onClick={goToRootNode}>
<Tooltip position="right" content="Go to root folder">
<FontAwesomeIcon icon={faAnglesUp} />
</Tooltip>
</ControlButton>
</Controls>
</ReactFlow>
</div>
</div>

View File

@ -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>

View File

@ -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,19 +44,124 @@ 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 { slug } = environmentsFolders[environment];
const roleNode = createRoleNode({
subject,
environment: name
environment: slug,
environments: environmentsFolders,
onSubjectChange: setSubject,
onEnvironmentChange: setEnvironment
});
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
const folderNodes = folders.map((folder) =>
const visibleFolders: TSecretFolderWithPath[] = [];
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,
permissions,
@ -50,10 +172,45 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
})
);
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)) {
@ -62,17 +219,55 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
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 init = positionElements([roleNode, ...folderNodes], [...folderEdges]);
const addMoreButtons: Node[] = [];
Object.entries(levelFolderMap).forEach(([parentId, levelData]) => {
if (parentId === "__rootFolderId") return;
const key = parentId === "null" ? null : parentId;
if (key && levelData.hasMore) {
const showMoreButtonNode = createShowMoreNode({
parentId: key,
onClick: () => showMoreFolders(key),
remaining: levelData.folders.length - levelData.visibleCount,
subject
});
addMoreButtons.push(showMoreButtonNode);
folderEdges.push(
createBaseEdge({
source: key,
target: showMoreButtonNode.id,
access: PermissionAccess.Partial
})
);
}
});
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 +281,11 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
secretName,
setSecretName,
viewMode,
setViewMode
setViewMode,
levelFolderMap,
showMoreFolders,
levelsWithMoreFolders,
getLevelCounts,
totalFolderCount
};
};

View File

@ -0,0 +1,123 @@
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";
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<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
const timeout: NodeJS.Timeout = setTimeout(() => {
setIsFocused(false);
}, 200);
return () => clearTimeout(timeout);
};
useEffect(() => {
if (!isFocused) {
setIsExpanded(false);
}
}, [isFocused]);
const focusInput = () => {
const inputElement = inputRef.current?.querySelector("input");
if (inputElement) {
inputElement.focus();
}
};
const toggleSearch = () => {
setIsExpanded(!isExpanded);
if (!isExpanded) {
const timeout: NodeJS.Timeout = setTimeout(focusInput, 300);
return () => clearTimeout(timeout);
}
return () => {};
};
return (
<div ref={wrapperRef} className="relative">
<div
className={twMerge(
"flex items-center overflow-hidden rounded transition-all duration-300 ease-in-out",
isFocused ? "bg-mineshaft-800 shadow-md" : "bg-mineshaft-700",
isExpanded ? "w-64" : "h-10 w-10"
)}
>
{isExpanded ? (
<div
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
onClick={toggleSearch}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleSearch();
}
}}
>
<FontAwesomeIcon icon={faSearch} />
</div>
) : (
<Tooltip position="bottom" content="Search paths">
<div
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
onClick={toggleSearch}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleSearch();
}
}}
>
<FontAwesomeIcon icon={faSearch} />
</div>
</Tooltip>
)}
<div
ref={inputRef}
className={twMerge(
"flex-1 transition-opacity duration-300",
isExpanded ? "opacity-100" : "hidden"
)}
onFocus={handleFocus}
onBlur={handleBlur}
role="search"
>
<div className="custom-input-wrapper">
<SecretPathInput
placeholder={placeholder}
environment={environment}
value={value}
onChange={onChange}
/>
</div>
</div>
</div>
</div>
);
};

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

@ -0,0 +1,37 @@
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { Button, Tooltip } from "@app/components/v2";
import { createShowMoreNode } from "../utils/createShowMoreNode";
export const ShowMoreButtonNode = ({
data: { onClick, remaining }
}: NodeProps & { data: ReturnType<typeof createShowMoreNode>["data"] }) => {
const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`;
return (
<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}
/>
<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

@ -7,7 +7,8 @@ export enum PermissionAccess {
export enum PermissionNode {
Role = "role",
Folder = "folder",
Environment = "environment"
Environment = "environment",
ShowMoreButton = "showMoreButton"
}
export enum PermissionEdge {

View File

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

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

@ -0,0 +1,45 @@
import { ProjectPermissionSub } from "@app/context";
import { PermissionNode } from "../types";
export const createShowMoreNode = ({
parentId,
onClick,
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,
type: PermissionNode.ShowMoreButton,
position: { x: 0, y: 0 },
data: {
parentId,
onClick,
remaining
},
width: 150,
height,
style: {
background: "transparent",
border: "none"
}
};
};

View File

@ -2,27 +2,96 @@ import Dagre from "@dagrejs/dagre";
import { Edge, Node } from "@xyflow/react";
export const positionElements = (nodes: Node[], edges: Edge[]) => {
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[]> = {};
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(() => ({}))
.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));
nodes.forEach((node) => dagre.setNode(node.id, node));
Dagre.layout(dagre, {});
return {
nodes: nodes.map((node) => {
const { x, y } = dagre.node(node.id);
const positionedNodes = nodes.map((node) => {
const { x, y } = dagre.node(node.id);
if (node.type === "role") {
return {
...node,
position: {
x: x - (node.width ? node.width / 2 : 0),
y: y - (node.height ? node.height / 2 : 0)
y: y - 150
}
};
}),
}
return {
...node,
position: {
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,
edges
};
};

View File

@ -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);
}
@ -141,7 +137,7 @@ export const SecretPathInput = ({
maxHeight: "var(--radix-select-content-available-height)"
}}
>
<div className="h-full w-full flex-col items-center justify-center rounded-md text-white">
<div className="max-h-[25vh] w-full flex-col items-center justify-center overflow-y-scroll rounded-md text-white">
{suggestions.map((suggestion, i) => (
<div
tabIndex={0}