mirror of
https://github.com/Infisical/infisical.git
synced 2025-04-13 01:49:57 +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 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 }];
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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,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
|
||||
};
|
||||
};
|
||||
|
@ -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 { 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>
|
||||
|
@ -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 {
|
||||
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 }
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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";
|
||||
|
||||
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
|
||||
};
|
||||
};
|
||||
|
@ -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}
|
||||
|
Reference in New Issue
Block a user