mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-05 09:26:37 +00:00
Merge pull request #3324 from Infisical/fix/accessTreeImprovements
Fix/access tree improvements
This commit is contained in:
@@ -634,10 +634,29 @@ export const secretFolderServiceFactory = ({
|
|||||||
const relevantFolders = folders.filter((folder) => folder.envId === env.id);
|
const relevantFolders = folders.filter((folder) => folder.envId === env.id);
|
||||||
const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder]));
|
const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder]));
|
||||||
|
|
||||||
const foldersWithPath = relevantFolders.map((folder) => ({
|
const foldersWithPath = relevantFolders
|
||||||
...folder,
|
.map((folder) => {
|
||||||
path: buildFolderPath(folder, foldersMap)
|
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 }];
|
return [env.slug, { ...env, folders: foldersWithPath }];
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
faAnglesUp,
|
||||||
faArrowUpRightFromSquare,
|
faArrowUpRightFromSquare,
|
||||||
|
faDownLeftAndUpRightToCenter,
|
||||||
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,
|
||||||
@@ -23,7 +26,9 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2";
|
import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2";
|
||||||
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
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 { BasePermissionEdge } from "./edges";
|
||||||
import { useAccessTree } from "./hooks";
|
import { useAccessTree } from "./hooks";
|
||||||
import { FolderNode, RoleNode } from "./nodes";
|
import { FolderNode, RoleNode } from "./nodes";
|
||||||
@@ -35,13 +40,30 @@ 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 [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(
|
const onNodeClick: NodeMouseHandler<Node> = useCallback(
|
||||||
(_, node) => {
|
(_, node) => {
|
||||||
@@ -55,14 +77,19 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setInitialRender(true);
|
||||||
fitView({
|
}, [selectedPath, environment]);
|
||||||
padding: 0.2,
|
|
||||||
duration: 1000,
|
useEffect(() => {
|
||||||
maxZoom: 1
|
let timer: NodeJS.Timeout;
|
||||||
});
|
if (initialRender) {
|
||||||
}, 1);
|
timer = setTimeout(() => {
|
||||||
}, [fitView, nodes, edges, getViewport()]);
|
goToRootNode();
|
||||||
|
setInitialRender(false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [nodes, edges, getViewport(), initialRender, goToRootNode]);
|
||||||
|
|
||||||
const handleToggleModalView = () =>
|
const handleToggleModalView = () =>
|
||||||
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
|
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
|
||||||
@@ -133,13 +160,13 @@ 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}
|
||||||
edgesReconnectable={false}
|
edgesReconnectable={false}
|
||||||
nodesConnectable={false}
|
nodesConnectable={false}
|
||||||
connectionLineType={ConnectionLineType.SmoothStep}
|
connectionLineType={ConnectionLineType.SmoothStep}
|
||||||
|
minZoom={0.001}
|
||||||
proOptions={{
|
proOptions={{
|
||||||
hideAttribution: false // we need pro license if we want to hide
|
hideAttribution: false // we need pro license if we want to hide
|
||||||
}}
|
}}
|
||||||
@@ -151,9 +178,17 @@ 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">
|
||||||
|
{viewMode !== ViewMode.Undocked && (
|
||||||
|
<AccessTreeSecretPathInput
|
||||||
|
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="ml-1 w-10 rounded"
|
||||||
colorSchema="secondary"
|
colorSchema="secondary"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
onClick={handleToggleUndockedView}
|
onClick={handleToggleUndockedView}
|
||||||
@@ -170,7 +205,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip align="end" position="bottom" content={windowButtonLabel}>
|
<Tooltip align="end" position="bottom" content={windowButtonLabel}>
|
||||||
<IconButton
|
<IconButton
|
||||||
className="rounded"
|
className="w-10 rounded"
|
||||||
colorSchema="secondary"
|
colorSchema="secondary"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
onClick={handleToggleModalView}
|
onClick={handleToggleModalView}
|
||||||
@@ -179,7 +214,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
|||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={
|
icon={
|
||||||
viewMode === ViewMode.Modal
|
viewMode === ViewMode.Modal
|
||||||
? faArrowUpRightFromSquare
|
? faDownLeftAndUpRightToCenter
|
||||||
: faUpRightAndDownLeftFromCenter
|
: faUpRightAndDownLeftFromCenter
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -187,9 +222,28 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Panel>
|
</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} />
|
<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>
|
</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,19 +44,124 @@ 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 { slug } = environmentsFolders[environment];
|
||||||
|
|
||||||
const roleNode = createRoleNode({
|
const roleNode = createRoleNode({
|
||||||
subject,
|
subject,
|
||||||
environment: name
|
environment: slug,
|
||||||
|
environments: environmentsFolders,
|
||||||
|
onSubjectChange: setSubject,
|
||||||
|
onEnvironmentChange: setEnvironment
|
||||||
});
|
});
|
||||||
|
|
||||||
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
|
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({
|
createFolderNode({
|
||||||
folder,
|
folder,
|
||||||
permissions,
|
permissions,
|
||||||
@@ -50,10 +172,45 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const folderEdges = folderNodes.map(({ data: folder }) => {
|
const folderEdges: Edge[] = [];
|
||||||
const actions = Object.values(folder.actions);
|
|
||||||
|
|
||||||
|
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;
|
let access: PermissionAccess;
|
||||||
|
|
||||||
if (Object.values(actions).some((action) => action === PermissionAccess.Full)) {
|
if (Object.values(actions).some((action) => action === PermissionAccess.Full)) {
|
||||||
access = PermissionAccess.Full;
|
access = PermissionAccess.Full;
|
||||||
} else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) {
|
} else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) {
|
||||||
@@ -62,17 +219,55 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
|
|||||||
access = PermissionAccess.None;
|
access = PermissionAccess.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
return createBaseEdge({
|
folderEdges.push(
|
||||||
source: folder.parentId ?? roleNode.id,
|
createBaseEdge({
|
||||||
target: folder.id,
|
source: folder.parentId ?? roleNode.id,
|
||||||
access
|
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);
|
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 +281,11 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
|
|||||||
secretName,
|
secretName,
|
||||||
setSecretName,
|
setSecretName,
|
||||||
viewMode,
|
viewMode,
|
||||||
setViewMode
|
setViewMode,
|
||||||
|
levelFolderMap,
|
||||||
|
showMoreFolders,
|
||||||
|
levelsWithMoreFolders,
|
||||||
|
getLevelCounts,
|
||||||
|
totalFolderCount
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+123
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 { 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";
|
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 = ({
|
export const RoleNode = ({
|
||||||
data: { subject, environment }
|
data: { subject, environment, onSubjectChange, onEnvironmentChange, environments }
|
||||||
}: NodeProps & { data: ReturnType<typeof createRoleNode>["data"] }) => {
|
}: NodeProps & {
|
||||||
|
data: ReturnType<typeof createRoleNode>["data"] & {
|
||||||
|
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
|
||||||
|
onEnvironmentChange: (value: string) => void;
|
||||||
|
environments: TProjectEnvironmentsFolders;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Handle
|
<Handle
|
||||||
@@ -12,11 +44,60 @@ export const RoleNode = ({
|
|||||||
className="pointer-events-none !cursor-pointer opacity-0"
|
className="pointer-events-none !cursor-pointer opacity-0"
|
||||||
position={Position.Top}
|
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 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 max-w-[14rem] flex-col items-center text-xs text-mineshaft-200">
|
<div className="flex w-full min-w-[240px] flex-col gap-4">
|
||||||
<span className="capitalize">{subject.replace("-", " ")} Access</span>
|
<div className="flex w-full flex-col gap-1.5">
|
||||||
<div className="max-w-[14rem] whitespace-nowrap text-xs text-mineshaft-300">
|
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Subject</div>
|
||||||
<p className="truncate capitalize">{environment}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 }
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
import { PermissionNode } from "../types";
|
||||||
|
|
||||||
export const createRoleNode = ({
|
export const createRoleNode = ({
|
||||||
subject,
|
subject,
|
||||||
environment
|
environment,
|
||||||
|
environments,
|
||||||
|
onSubjectChange,
|
||||||
|
onEnvironmentChange
|
||||||
}: {
|
}: {
|
||||||
subject: string;
|
subject: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
environments: TProjectEnvironmentsFolders;
|
||||||
|
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
|
||||||
|
onEnvironmentChange: (value: string) => void;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: `role-${subject}-${environment}`,
|
id: `role-${subject}-${environment}`,
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
subject,
|
subject,
|
||||||
environment
|
environment,
|
||||||
|
environments,
|
||||||
|
onSubjectChange,
|
||||||
|
onEnvironmentChange
|
||||||
},
|
},
|
||||||
type: PermissionNode.Role,
|
type: PermissionNode.Role,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,27 +2,96 @@ 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 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 })
|
const dagre = new Dagre.graphlib.Graph({ directed: true })
|
||||||
.setDefaultEdgeLabel(() => ({}))
|
.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));
|
edges.forEach((edge) => dagre.setEdge(edge.source, edge.target));
|
||||||
nodes.forEach((node) => dagre.setNode(node.id, node));
|
|
||||||
|
|
||||||
Dagre.layout(dagre, {});
|
Dagre.layout(dagre, {});
|
||||||
|
|
||||||
return {
|
const positionedNodes = nodes.map((node) => {
|
||||||
nodes: nodes.map((node) => {
|
const { x, y } = dagre.node(node.id);
|
||||||
const { x, y } = dagre.node(node.id);
|
|
||||||
|
|
||||||
|
if (node.type === "role") {
|
||||||
return {
|
return {
|
||||||
...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 - 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
|
edges
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export const SecretPathInput = ({
|
|||||||
}, [propValue]);
|
}, [propValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// update secret path if input is valid
|
|
||||||
if (
|
if (
|
||||||
(debouncedInputValue.length > 0 &&
|
(debouncedInputValue.length > 0 &&
|
||||||
debouncedInputValue[debouncedInputValue.length - 1] === "/") ||
|
debouncedInputValue[debouncedInputValue.length - 1] === "/") ||
|
||||||
@@ -59,7 +58,6 @@ export const SecretPathInput = ({
|
|||||||
}, [debouncedInputValue]);
|
}, [debouncedInputValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// filter suggestions based on matching
|
|
||||||
const searchFragment = debouncedInputValue.split("/").pop() || "";
|
const searchFragment = debouncedInputValue.split("/").pop() || "";
|
||||||
const filteredSuggestions = folders
|
const filteredSuggestions = folders
|
||||||
.filter((suggestionEntry) =>
|
.filter((suggestionEntry) =>
|
||||||
@@ -78,7 +76,6 @@ export const SecretPathInput = ({
|
|||||||
const validPaths = inputValue.split("/");
|
const validPaths = inputValue.split("/");
|
||||||
validPaths.pop();
|
validPaths.pop();
|
||||||
|
|
||||||
// removed trailing slash
|
|
||||||
const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`;
|
const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`;
|
||||||
onChange?.(newValue);
|
onChange?.(newValue);
|
||||||
setInputValue(newValue);
|
setInputValue(newValue);
|
||||||
@@ -102,7 +99,6 @@ export const SecretPathInput = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: any) => {
|
const handleInputChange = (e: any) => {
|
||||||
// propagate event to react-hook-form onChange
|
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(e.target.value);
|
onChange(e.target.value);
|
||||||
}
|
}
|
||||||
@@ -141,7 +137,7 @@ export const SecretPathInput = ({
|
|||||||
maxHeight: "var(--radix-select-content-available-height)"
|
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) => (
|
{suggestions.map((suggestion, i) => (
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|||||||
Reference in New Issue
Block a user