mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 16:40:30 +00:00
Merge branch 'main' into secret-rotations-v2-feature
This commit is contained in:
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
+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 { 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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { faCheck, faCopy, faKey, faRefresh } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button, Checkbox, IconButton, Slider } from "@app/components/v2";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
|
||||
type PasswordOptionsType = {
|
||||
length: number;
|
||||
useUppercase: boolean;
|
||||
useLowercase: boolean;
|
||||
useNumbers: boolean;
|
||||
useSpecialChars: boolean;
|
||||
};
|
||||
|
||||
type PasswordGeneratorModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUsePassword?: (password: string) => void;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
const PasswordGeneratorModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onUsePassword,
|
||||
minLength = 12,
|
||||
maxLength = 64
|
||||
}: PasswordGeneratorModalProps) => {
|
||||
const [copyText, isCopying, setCopyText] = useTimedReset<string>({
|
||||
initialState: "Copy"
|
||||
});
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [passwordOptions, setPasswordOptions] = useState<PasswordOptionsType>({
|
||||
length: minLength,
|
||||
useUppercase: true,
|
||||
useLowercase: true,
|
||||
useNumbers: true,
|
||||
useSpecialChars: true
|
||||
});
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const generatePassword = () => {
|
||||
const charset = {
|
||||
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
lowercase: "abcdefghijklmnopqrstuvwxyz",
|
||||
numbers: "0123456789",
|
||||
specialChars: "-_.~!*"
|
||||
};
|
||||
|
||||
let availableChars = "";
|
||||
if (passwordOptions.useUppercase) availableChars += charset.uppercase;
|
||||
if (passwordOptions.useLowercase) availableChars += charset.lowercase;
|
||||
if (passwordOptions.useNumbers) availableChars += charset.numbers;
|
||||
if (passwordOptions.useSpecialChars) availableChars += charset.specialChars;
|
||||
|
||||
if (availableChars === "") availableChars = charset.lowercase + charset.numbers;
|
||||
|
||||
let newPassword = "";
|
||||
for (let i = 0; i < passwordOptions.length; i += 1) {
|
||||
const randomIndex = Math.floor(Math.random() * availableChars.length);
|
||||
newPassword += availableChars[randomIndex];
|
||||
}
|
||||
|
||||
return newPassword;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => {};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const password = useMemo(() => {
|
||||
return generatePassword();
|
||||
}, [passwordOptions, refresh]);
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard
|
||||
.writeText(password)
|
||||
.then(() => {
|
||||
setCopyText("Copied");
|
||||
})
|
||||
.catch(() => {
|
||||
setCopyText("Copy failed");
|
||||
});
|
||||
};
|
||||
|
||||
const usePassword = () => {
|
||||
if (onUsePassword) {
|
||||
onUsePassword(password);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="w-full max-w-lg rounded-lg border border-mineshaft-600 bg-mineshaft-800 shadow-xl"
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="mb-1 text-xl font-semibold text-bunker-200">Password Generator</h2>
|
||||
<p className="mb-6 text-sm text-bunker-400">Generate strong unique passwords</p>
|
||||
|
||||
<div className="relative mb-4 rounded-md bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-4/5 select-all break-all pr-2 font-mono text-lg">{password}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
size="xs"
|
||||
colorSchema="secondary"
|
||||
variant="outline_bg"
|
||||
onClick={() => setRefresh((prev) => !prev)}
|
||||
className="w-full text-bunker-300 hover:text-bunker-100"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRefresh} className="mr-1 h-3 w-3" />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
colorSchema="secondary"
|
||||
variant="outline_bg"
|
||||
onClick={copyToClipboard}
|
||||
className="w-full text-bunker-300 hover:text-bunker-100"
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopying ? faCheck : faCopy} className="mr-1 h-3 w-3" />
|
||||
{copyText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label htmlFor="password-length" className="text-sm text-bunker-300">
|
||||
Password length: {passwordOptions.length}
|
||||
</label>
|
||||
</div>
|
||||
<Slider
|
||||
id="password-length"
|
||||
min={minLength}
|
||||
max={maxLength}
|
||||
value={passwordOptions.length}
|
||||
onChange={(value) => setPasswordOptions({ ...passwordOptions, length: value })}
|
||||
className="mb-1"
|
||||
aria-labelledby="password-length-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-row justify-between gap-2">
|
||||
<Checkbox
|
||||
id="useUppercase"
|
||||
className="mr-2 data-[state=checked]:bg-primary"
|
||||
isChecked={passwordOptions.useUppercase}
|
||||
onCheckedChange={(checked) =>
|
||||
setPasswordOptions({ ...passwordOptions, useUppercase: checked as boolean })
|
||||
}
|
||||
>
|
||||
A-Z
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
id="useLowercase"
|
||||
className="mr-2 data-[state=checked]:bg-primary"
|
||||
isChecked={passwordOptions.useLowercase}
|
||||
onCheckedChange={(checked) =>
|
||||
setPasswordOptions({ ...passwordOptions, useLowercase: checked as boolean })
|
||||
}
|
||||
>
|
||||
a-z
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
id="useNumbers"
|
||||
className="mr-2 data-[state=checked]:bg-primary"
|
||||
isChecked={passwordOptions.useNumbers}
|
||||
onCheckedChange={(checked) =>
|
||||
setPasswordOptions({ ...passwordOptions, useNumbers: checked as boolean })
|
||||
}
|
||||
>
|
||||
0-9
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
id="useSpecialChars"
|
||||
className="mr-2 data-[state=checked]:bg-primary"
|
||||
isChecked={passwordOptions.useSpecialChars}
|
||||
onCheckedChange={(checked) =>
|
||||
setPasswordOptions({ ...passwordOptions, useSpecialChars: checked as boolean })
|
||||
}
|
||||
>
|
||||
-_.~!*
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" colorSchema="primary" variant="outline_bg" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{onUsePassword && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={usePassword}
|
||||
className="ml-2"
|
||||
>
|
||||
Use Password
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type PasswordGeneratorProps = {
|
||||
onUsePassword?: (password: string) => void;
|
||||
isDisabled?: boolean;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
export const PasswordGenerator = ({
|
||||
onUsePassword,
|
||||
isDisabled = false,
|
||||
minLength = 12,
|
||||
maxLength = 64
|
||||
}: PasswordGeneratorProps) => {
|
||||
const [showGenerator, setShowGenerator] = useState(false);
|
||||
|
||||
const toggleGenerator = () => {
|
||||
setShowGenerator(!showGenerator);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ariaLabel="generate password"
|
||||
colorSchema="secondary"
|
||||
size="sm"
|
||||
onClick={toggleGenerator}
|
||||
isDisabled={isDisabled}
|
||||
className="rounded text-bunker-400 transition-colors duration-150 hover:bg-mineshaft-700 hover:text-bunker-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} />
|
||||
</IconButton>
|
||||
|
||||
<PasswordGeneratorModal
|
||||
isOpen={showGenerator}
|
||||
onClose={() => setShowGenerator(false)}
|
||||
onUsePassword={onUsePassword}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { PasswordGeneratorProps } from "./PasswordGenerator";
|
||||
export { PasswordGenerator } from "./PasswordGenerator";
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
forwardRef,
|
||||
InputHTMLAttributes,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { cva, VariantProps } from "cva";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
isDisabled?: boolean;
|
||||
isRequired?: boolean;
|
||||
showValue?: boolean;
|
||||
valuePosition?: "top" | "right";
|
||||
containerClassName?: string;
|
||||
trackClassName?: string;
|
||||
fillClassName?: string;
|
||||
thumbClassName?: string;
|
||||
onChange?: (value: number) => void;
|
||||
onChangeComplete?: (value: number) => void;
|
||||
};
|
||||
|
||||
const sliderTrackVariants = cva("h-1 w-full bg-mineshaft-600 rounded-full relative", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
thin: "h-0.5",
|
||||
thick: "h-1.5"
|
||||
},
|
||||
isDisabled: {
|
||||
true: "opacity-50 cursor-not-allowed",
|
||||
false: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sliderFillVariants = cva("absolute h-full rounded-full", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary-500",
|
||||
secondary: "bg-secondary-500",
|
||||
danger: "bg-red-500"
|
||||
},
|
||||
isDisabled: {
|
||||
true: "opacity-50",
|
||||
false: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sliderThumbVariants = cva(
|
||||
"absolute w-4 h-4 rounded-full shadow transform -translate-x-1/2 -mt-1.5 focus:outline-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary-500 focus:ring-2 focus:ring-primary-400/50",
|
||||
secondary: "bg-secondary-500 focus:ring-2 focus:ring-secondary-400/50",
|
||||
danger: "bg-red-500 focus:ring-2 focus:ring-red-400/50"
|
||||
},
|
||||
isDisabled: {
|
||||
true: "opacity-50 cursor-not-allowed",
|
||||
false: "cursor-pointer"
|
||||
},
|
||||
size: {
|
||||
sm: "w-3 h-3 -mt-1",
|
||||
md: "w-4 h-4 -mt-1.5",
|
||||
lg: "w-5 h-5 -mt-2"
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const sliderContainerVariants = cva("relative inline-flex font-inter", {
|
||||
variants: {
|
||||
isFullWidth: {
|
||||
true: "w-full",
|
||||
false: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type SliderProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "onChange"> &
|
||||
VariantProps<typeof sliderTrackVariants> &
|
||||
VariantProps<typeof sliderThumbVariants> &
|
||||
VariantProps<typeof sliderContainerVariants> &
|
||||
Props;
|
||||
|
||||
export const Slider = forwardRef<HTMLInputElement, SliderProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
containerClassName,
|
||||
trackClassName,
|
||||
fillClassName,
|
||||
thumbClassName,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
value,
|
||||
defaultValue,
|
||||
isDisabled = false,
|
||||
isFullWidth = true,
|
||||
isRequired = false,
|
||||
showValue = false,
|
||||
valuePosition = "top",
|
||||
variant = "default",
|
||||
size = "md",
|
||||
onChange,
|
||||
onChangeComplete,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
): JSX.Element => {
|
||||
let initialValue = min;
|
||||
if (value !== undefined) {
|
||||
initialValue = Number(value);
|
||||
} else if (defaultValue !== undefined) {
|
||||
initialValue = Number(defaultValue);
|
||||
}
|
||||
|
||||
const [currentValue, setCurrentValue] = useState<number>(initialValue);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const percentage = Math.max(0, Math.min(100, ((currentValue - min) / (max - min)) * 100));
|
||||
|
||||
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined && Number(value) !== currentValue) {
|
||||
setCurrentValue(Number(value));
|
||||
}
|
||||
}, [value, currentValue]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = Number(e.target.value);
|
||||
setCurrentValue(newValue);
|
||||
onChange?.(newValue);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!isDisabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [isDisabled]);
|
||||
|
||||
const handleChangeComplete = useCallback(() => {
|
||||
if (isDragging) {
|
||||
onChangeComplete?.(currentValue);
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, [isDragging, currentValue, onChangeComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
const handleGlobalMouseUp = () => handleChangeComplete();
|
||||
|
||||
document.addEventListener("mouseup", handleGlobalMouseUp);
|
||||
document.addEventListener("touchend", handleGlobalMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mouseup", handleGlobalMouseUp);
|
||||
document.removeEventListener("touchend", handleGlobalMouseUp);
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
}, [isDragging, handleChangeComplete]);
|
||||
|
||||
const ValueDisplay = showValue ? (
|
||||
<div className="text-xs text-bunker-300">{currentValue}</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
sliderContainerVariants({ isFullWidth, className: containerClassName }),
|
||||
"my-2"
|
||||
)}
|
||||
>
|
||||
{showValue && valuePosition === "top" && ValueDisplay}
|
||||
|
||||
<div className="relative flex w-full items-center">
|
||||
<div
|
||||
className={twMerge(
|
||||
sliderTrackVariants({ variant, isDisabled, className: trackClassName })
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
sliderFillVariants({ variant, isDisabled, className: fillClassName }),
|
||||
"left-0"
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
sliderThumbVariants({ variant, isDisabled, size, className: thumbClassName })
|
||||
)}
|
||||
style={{ left: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleMouseDown}
|
||||
disabled={isDisabled}
|
||||
required={isRequired}
|
||||
ref={inputRef}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{showValue && valuePosition === "right" && (
|
||||
<div className="ml-2 text-xs text-bunker-300">{currentValue}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Slider.displayName = "Slider";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { SliderProps } from "./Slider";
|
||||
export { Slider } from "./Slider";
|
||||
@@ -26,10 +26,12 @@ export * from "./NoticeBanner";
|
||||
export * from "./NoticeBannerV2";
|
||||
export * from "./PageHeader";
|
||||
export * from "./Pagination";
|
||||
export * from "./PasswordGenerator";
|
||||
export * from "./Popoverv2";
|
||||
export * from "./SecretInput";
|
||||
export * from "./Select";
|
||||
export * from "./Skeleton";
|
||||
export * from "./Slider";
|
||||
export * from "./Spinner";
|
||||
export * from "./Stepper";
|
||||
export * from "./Switch";
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { EventType, UserAgentType } from "./enums";
|
||||
|
||||
export const secretEvents: EventType[] = [
|
||||
EventType.GET_SECRETS,
|
||||
EventType.GET_SECRET,
|
||||
EventType.DELETE_SECRETS,
|
||||
EventType.CREATE_SECRETS,
|
||||
EventType.UPDATE_SECRETS,
|
||||
EventType.CREATE_SECRET,
|
||||
EventType.UPDATE_SECRET,
|
||||
EventType.DELETE_SECRET
|
||||
];
|
||||
|
||||
export const eventToNameMap: { [K in EventType]: string } = {
|
||||
[EventType.GET_SECRETS]: "List secrets",
|
||||
[EventType.GET_SECRET]: "Read secret",
|
||||
|
||||
@@ -12,8 +12,8 @@ export enum UserAgentType {
|
||||
CLI = "cli",
|
||||
K8_OPERATOR = "k8-operator",
|
||||
TERRAFORM = "terraform",
|
||||
NODE_SDK = "node-sdk",
|
||||
PYTHON_SDK = "python-sdk",
|
||||
NODE_SDK = "InfisicalNodeSDK",
|
||||
PYTHON_SDK = "InfisicalPythonSDK",
|
||||
OTHER = "other"
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ export type TGetAuditLogsFilter = {
|
||||
eventMetadata?: Record<string, string>;
|
||||
actorType?: ActorType;
|
||||
projectId?: string;
|
||||
environment?: string;
|
||||
actor?: string; // user ID format
|
||||
secretPath?: string;
|
||||
secretKey?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
limit: number;
|
||||
|
||||
@@ -352,12 +352,13 @@ const fetchAccessibleSecrets = async ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction
|
||||
filterByAction,
|
||||
recursive = false
|
||||
}: TGetAccessibleSecretsDTO) => {
|
||||
const { data } = await apiRequest.get<{ secrets: SecretV3Raw[] }>(
|
||||
"/api/v1/dashboard/accessible-secrets",
|
||||
{
|
||||
params: { projectId, secretPath, environment, filterByAction }
|
||||
params: { projectId, secretPath, environment, filterByAction, recursive }
|
||||
}
|
||||
);
|
||||
|
||||
@@ -437,7 +438,8 @@ export const useGetAccessibleSecrets = ({
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction,
|
||||
options
|
||||
options,
|
||||
recursive = false
|
||||
}: TGetAccessibleSecretsDTO & {
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
@@ -455,8 +457,10 @@ export const useGetAccessibleSecrets = ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction
|
||||
filterByAction,
|
||||
recursive
|
||||
}),
|
||||
queryFn: () => fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction })
|
||||
queryFn: () =>
|
||||
fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction, recursive })
|
||||
});
|
||||
};
|
||||
|
||||
@@ -130,6 +130,7 @@ export type TGetAccessibleSecretsDTO = {
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
recursive?: boolean;
|
||||
filterByAction:
|
||||
| ProjectPermissionSecretActions.DescribeSecret
|
||||
| ProjectPermissionSecretActions.ReadValue;
|
||||
|
||||
@@ -125,6 +125,7 @@ export type TGetProjectSecretsKey = {
|
||||
includeImports?: boolean;
|
||||
viewSecretValue?: boolean;
|
||||
expandSecretReferences?: boolean;
|
||||
recursive?: boolean;
|
||||
};
|
||||
|
||||
export type TGetProjectSecretsDTO = TGetProjectSecretsKey;
|
||||
|
||||
@@ -18,7 +18,7 @@ export const AuditLogsPage = () => {
|
||||
title="Audit logs"
|
||||
description="Audit logs for security and compliance teams to monitor information access."
|
||||
/>
|
||||
<LogsSection filterClassName="static py-2" showFilters />
|
||||
<LogsSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Button, Tooltip } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
hoverTooltip?: string;
|
||||
className?: string;
|
||||
label: string;
|
||||
onClear: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LogFilterItem = ({ label, onClear, hoverTooltip, children, className }: Props) => {
|
||||
return (
|
||||
<Tooltip className="relative top-4" content={hoverTooltip} isDisabled={!hoverTooltip}>
|
||||
<div className={twMerge("flex flex-col justify-between", className)}>
|
||||
<div className="flex items-center justify-between pr-1">
|
||||
<p className="text-xs opacity-60">{label}</p>
|
||||
<Button
|
||||
onClick={() => onClear()}
|
||||
variant="link"
|
||||
className="font-normal text-mineshaft-400 transition-all duration-75 hover:text-mineshaft-300"
|
||||
size="xs"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,26 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useState } from "react";
|
||||
import { Control, Controller, UseFormReset, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
import { faCaretDown, faCheckCircle, faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Control,
|
||||
Controller,
|
||||
UseFormGetFieldState,
|
||||
UseFormReset,
|
||||
UseFormResetField,
|
||||
UseFormSetValue,
|
||||
UseFormWatch
|
||||
} from "react-hook-form";
|
||||
import {
|
||||
faArrowRight,
|
||||
faCaretDown,
|
||||
faCheckCircle,
|
||||
faFilterCircleXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DatePicker,
|
||||
DropdownMenu,
|
||||
@@ -19,13 +34,17 @@ import {
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useGetAuditLogActorFilterOpts, useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants";
|
||||
import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { Actor } from "@app/hooks/api/auditLogs/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import {
|
||||
eventToNameMap,
|
||||
secretEvents,
|
||||
userAgentTTypeoNameMap
|
||||
} from "@app/hooks/api/auditLogs/constants";
|
||||
import { EventType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { UserAgentType } from "@app/hooks/api/auth/types";
|
||||
|
||||
import { AuditLogFilterFormData } from "./types";
|
||||
import { LogFilterItem } from "./LogFilterItem";
|
||||
import { AuditLogFilterFormData, Presets } from "./types";
|
||||
|
||||
const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value }));
|
||||
const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label]) => ({
|
||||
@@ -34,26 +53,70 @@ const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
presets?: {
|
||||
actorId?: string;
|
||||
eventType?: EventType[];
|
||||
};
|
||||
className?: string;
|
||||
isOrgAuditLogs?: boolean;
|
||||
setValue: UseFormSetValue<AuditLogFilterFormData>;
|
||||
presets?: Presets;
|
||||
control: Control<AuditLogFilterFormData>;
|
||||
reset: UseFormReset<AuditLogFilterFormData>;
|
||||
resetField: UseFormResetField<AuditLogFilterFormData>;
|
||||
watch: UseFormWatch<AuditLogFilterFormData>;
|
||||
getFieldState: UseFormGetFieldState<AuditLogFilterFormData>;
|
||||
setValue: UseFormSetValue<AuditLogFilterFormData>;
|
||||
};
|
||||
|
||||
const getActiveFilterCount = (
|
||||
getFieldState: UseFormGetFieldState<AuditLogFilterFormData>,
|
||||
watch: UseFormWatch<AuditLogFilterFormData>
|
||||
) => {
|
||||
const fields = [
|
||||
"actor",
|
||||
"project",
|
||||
"eventType",
|
||||
"startDate",
|
||||
"endDate",
|
||||
"environment",
|
||||
"secretPath",
|
||||
"userAgentType",
|
||||
"secretKey"
|
||||
] as Partial<keyof AuditLogFilterFormData>[];
|
||||
|
||||
let filterCount = 0;
|
||||
|
||||
// either start or end date should only be counted as one filter
|
||||
let dateProcessed = false;
|
||||
|
||||
fields.forEach((field) => {
|
||||
const fieldState = getFieldState(field);
|
||||
|
||||
if (
|
||||
field === "userAgentType" ||
|
||||
field === "environment" ||
|
||||
field === "secretKey" ||
|
||||
field === "secretPath"
|
||||
) {
|
||||
const value = watch(field);
|
||||
|
||||
if (value !== undefined && value !== "") {
|
||||
filterCount += 1;
|
||||
}
|
||||
} else if (fieldState.isDirty && !dateProcessed) {
|
||||
filterCount += 1;
|
||||
|
||||
if (field === "startDate" || field === "endDate") {
|
||||
dateProcessed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return filterCount;
|
||||
};
|
||||
|
||||
export const LogsFilter = ({
|
||||
presets,
|
||||
isOrgAuditLogs,
|
||||
className,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
watch
|
||||
resetField,
|
||||
watch,
|
||||
getFieldState,
|
||||
setValue
|
||||
}: Props) => {
|
||||
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
|
||||
const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false);
|
||||
@@ -63,288 +126,423 @@ export const LogsFilter = ({
|
||||
|
||||
const workspacesInOrg = workspaces.filter((ws) => ws.orgId === currentOrg?.id);
|
||||
|
||||
const { data, isPending } = useGetAuditLogActorFilterOpts(workspaces?.[0]?.id ?? "");
|
||||
|
||||
const renderActorSelectItem = (actor: Actor) => {
|
||||
switch (actor.type) {
|
||||
case ActorType.USER:
|
||||
return (
|
||||
<SelectItem
|
||||
value={`${actor.type}-${actor.metadata.userId}`}
|
||||
key={`user-actor-filter-${actor.metadata.userId}`}
|
||||
>
|
||||
{actor.metadata.email}
|
||||
</SelectItem>
|
||||
);
|
||||
case ActorType.SERVICE:
|
||||
return (
|
||||
<SelectItem
|
||||
value={`${actor.type}-${actor.metadata.serviceId}`}
|
||||
key={`service-actor-filter-${actor.metadata.serviceId}`}
|
||||
>
|
||||
{actor.metadata.name}
|
||||
</SelectItem>
|
||||
);
|
||||
case ActorType.IDENTITY:
|
||||
return (
|
||||
<SelectItem
|
||||
value={`${actor.type}-${actor.metadata.identityId}`}
|
||||
key={`identity-filter-${actor.metadata.identityId}`}
|
||||
>
|
||||
{actor.metadata.name}
|
||||
</SelectItem>
|
||||
);
|
||||
case ActorType.KMIP_CLIENT:
|
||||
return (
|
||||
<SelectItem
|
||||
value={`${actor.type}-${actor.metadata.clientId}`}
|
||||
key={`kmip-client-filter-${actor.metadata.clientId}`}
|
||||
>
|
||||
{actor.metadata.name}
|
||||
</SelectItem>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<SelectItem value="actor-none" key="actor-none">
|
||||
N/A
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedEventTypes = watch("eventType") as EventType[] | undefined;
|
||||
const selectedProject = watch("project");
|
||||
|
||||
const showSecretsSection =
|
||||
selectedEventTypes?.some(
|
||||
(eventType) => secretEvents.includes(eventType) && eventType !== EventType.GET_SECRETS
|
||||
) || selectedEventTypes?.length === 0;
|
||||
|
||||
const availableEnvironments = useMemo(() => {
|
||||
if (!selectedProject) return [];
|
||||
|
||||
return workspacesInOrg.find((ws) => ws.id === selectedProject.id)?.environments ?? [];
|
||||
}, [selectedProject, workspacesInOrg]);
|
||||
|
||||
const activeFilterCount = getActiveFilterCount(getFieldState, watch);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"sticky top-20 z-10 flex flex-wrap items-center justify-between bg-bunker-800",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{isOrgAuditLogs && workspacesInOrg.length > 0 && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Project"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-64"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
isClearable
|
||||
onChange={(e) => {
|
||||
if (e === null) {
|
||||
setValue("secretPath", "");
|
||||
}
|
||||
onChange(e);
|
||||
}}
|
||||
placeholder="Select a project..."
|
||||
options={workspacesInOrg.map(({ name, id, type }) => ({ name, id, type }))}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{selectedProject?.type === ProjectType.SecretManager && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormControl label="Secret path" className="w-40">
|
||||
<Input {...field} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="eventType"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Events">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="inline-flex w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{selectedEventTypes?.length === 1
|
||||
? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0])
|
||||
?.label
|
||||
: selectedEventTypes?.length === 0
|
||||
? "All events"
|
||||
: `${selectedEventTypes?.length} events selected`}
|
||||
<FontAwesomeIcon icon={faCaretDown} className="ml-2 text-xs" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-[100] max-h-80 overflow-hidden">
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{eventTypes && eventTypes.length > 0 ? (
|
||||
eventTypes.map((eventType) => {
|
||||
const isSelected = selectedEventTypes?.includes(
|
||||
eventType.value as EventType
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => eventTypes.length > 1 && event.preventDefault()}
|
||||
onClick={() => {
|
||||
if (selectedEventTypes?.includes(eventType.value as EventType)) {
|
||||
field.onChange(
|
||||
selectedEventTypes?.filter((e: string) => e !== eventType.value)
|
||||
);
|
||||
} else {
|
||||
field.onChange([...(selectedEventTypes || []), eventType.value]);
|
||||
}
|
||||
}}
|
||||
key={`event-type-${eventType.value}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{eventType.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline_bg" colorSchema="primary">
|
||||
<FontAwesomeIcon icon={faFilterCircleXmark} className="mr-3 px-[0.1rem]" />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge className="ml-2 px-1.5 py-0.5" variant="primary">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isPending && data && data.length > 0 && !presets?.actorId && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="actor"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Actor"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-40"
|
||||
>
|
||||
<Select
|
||||
{...(field.value ? { value: field.value } : { placeholder: "Select" })}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
|
||||
>
|
||||
{data.map((actor) => renderActorSelectItem(actor))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="userAgentType"
|
||||
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Source"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-40"
|
||||
>
|
||||
<Select
|
||||
value={value === undefined ? "all" : value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
if (e === "all") onChange(undefined);
|
||||
else onChange(e);
|
||||
}}
|
||||
className={twMerge("w-full border border-mineshaft-500 bg-mineshaft-700")}
|
||||
>
|
||||
<SelectItem value="all" key="all">
|
||||
All sources
|
||||
</SelectItem>
|
||||
{userAgentTypes.map(({ label, value: userAgent }) => (
|
||||
<SelectItem value={userAgent} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl label="Start date" errorText={error?.message} isError={Boolean(error)}>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
onChange={onChange}
|
||||
dateFormat="P"
|
||||
popUpProps={{
|
||||
open: isStartDatePickerOpen,
|
||||
onOpenChange: setIsStartDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="endDate"
|
||||
control={control}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl label="End date" errorText={error?.message} isError={Boolean(error)}>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
onChange={onChange}
|
||||
dateFormat="P"
|
||||
popUpProps={{
|
||||
open: isEndDatePickerOpen,
|
||||
onOpenChange: setIsEndDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
isLoading={false}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
className="mt-[0.45rem]"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faFilterCircleXmark} />}
|
||||
onClick={() =>
|
||||
reset({
|
||||
eventType: presets?.eventType || [],
|
||||
actor: presets?.actorId,
|
||||
userAgentType: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
project: null
|
||||
})
|
||||
}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="mt-4 py-4">
|
||||
<div className="flex min-w-64 flex-col font-inter">
|
||||
<div className="mb-3 flex items-center border-b border-b-mineshaft-500 px-3 pb-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Filters</span>
|
||||
<Badge className="px-1.5 py-0.5" variant="primary">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
reset({
|
||||
eventType: presets?.eventType || [],
|
||||
actor: presets?.actorId,
|
||||
userAgentType: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
project: null,
|
||||
secretPath: undefined,
|
||||
secretKey: undefined
|
||||
});
|
||||
}}
|
||||
variant="link"
|
||||
className="text-mineshaft-400"
|
||||
size="xs"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
<LogFilterItem
|
||||
label="Events"
|
||||
onClear={() => {
|
||||
resetField("eventType");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="eventType"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="thin-scrollbar inline-flex w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{selectedEventTypes?.length === 1
|
||||
? eventTypes.find(
|
||||
(eventType) => eventType.value === selectedEventTypes[0]
|
||||
)?.label
|
||||
: selectedEventTypes?.length === 0
|
||||
? "All events"
|
||||
: `${selectedEventTypes?.length} events selected`}
|
||||
<FontAwesomeIcon icon={faCaretDown} className="ml-2 text-xs" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="thin-scrollbar z-[100] max-h-80 overflow-hidden"
|
||||
>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{eventTypes && eventTypes.length > 0 ? (
|
||||
eventTypes.map((eventType) => {
|
||||
const isSelected = selectedEventTypes?.includes(
|
||||
eventType.value as EventType
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) =>
|
||||
eventTypes.length > 1 && event.preventDefault()
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
selectedEventTypes?.includes(eventType.value as EventType)
|
||||
) {
|
||||
field.onChange(
|
||||
selectedEventTypes?.filter(
|
||||
(e: string) => e !== eventType.value
|
||||
)
|
||||
);
|
||||
} else {
|
||||
field.onChange([
|
||||
...(selectedEventTypes || []),
|
||||
eventType.value
|
||||
]);
|
||||
}
|
||||
}}
|
||||
key={`event-type-${eventType.value}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{eventType.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
<LogFilterItem
|
||||
label="Source"
|
||||
onClear={() => {
|
||||
resetField("userAgentType");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="userAgentType"
|
||||
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<Select
|
||||
value={value === undefined ? "all" : value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
if (e === "all") onChange(undefined);
|
||||
else setValue("userAgentType", e as UserAgentType, { shouldDirty: true });
|
||||
}}
|
||||
className={twMerge("w-full border border-mineshaft-500 bg-mineshaft-700")}
|
||||
>
|
||||
<SelectItem value="all" key="all">
|
||||
All sources
|
||||
</SelectItem>
|
||||
{userAgentTypes.map(({ label, value: userAgent }) => (
|
||||
<SelectItem value={userAgent} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
|
||||
<LogFilterItem
|
||||
label="Date"
|
||||
onClear={() => {
|
||||
resetField("startDate");
|
||||
resetField("endDate");
|
||||
}}
|
||||
>
|
||||
<div className="flex h-10 w-full items-center justify-between gap-2">
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
className="relative top-2"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
onChange={onChange}
|
||||
dateFormat="P"
|
||||
popUpProps={{
|
||||
open: isStartDatePickerOpen,
|
||||
onOpenChange: setIsStartDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center -space-x-3">
|
||||
<div className="h-[2px] w-[20px] rounded-full bg-mineshaft-500" />
|
||||
<FontAwesomeIcon icon={faArrowRight} className="text-mineshaft-500" />
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="endDate"
|
||||
control={control}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
className="relative top-2"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
onChange={onChange}
|
||||
dateFormat="P"
|
||||
popUpProps={{
|
||||
open: isEndDatePickerOpen,
|
||||
onOpenChange: setIsEndDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</LogFilterItem>
|
||||
<AnimatePresence initial={false}>
|
||||
{showSecretsSection && (
|
||||
<motion.div
|
||||
className="mt-2 overflow-hidden"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="mb-3 mt-2">
|
||||
<p className="text-xs opacity-60">Secrets</p>
|
||||
<div className="h-[1px] w-full rounded-full bg-mineshaft-500" />
|
||||
</div>
|
||||
|
||||
<LogFilterItem
|
||||
label="Project"
|
||||
onClear={() => {
|
||||
resetField("project");
|
||||
resetField("environment");
|
||||
setValue("secretPath", "");
|
||||
setValue("secretKey", "");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
isClearable
|
||||
onChange={(e) => {
|
||||
if (e === null) {
|
||||
setValue("secretPath", "");
|
||||
setValue("secretKey", "");
|
||||
}
|
||||
resetField("environment");
|
||||
onChange(e);
|
||||
}}
|
||||
placeholder="All projects"
|
||||
options={workspacesInOrg.map(({ name, id, type }) => ({
|
||||
name,
|
||||
id,
|
||||
type
|
||||
}))}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
|
||||
<LogFilterItem
|
||||
label="Environment"
|
||||
hoverTooltip={
|
||||
!selectedProject
|
||||
? "Select a project before filtering by environment."
|
||||
: undefined
|
||||
}
|
||||
className={twMerge(!selectedProject && "opacity-50")}
|
||||
onClear={() => {
|
||||
resetField("environment");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
key={value?.name || "filter-environment"}
|
||||
isClearable
|
||||
isDisabled={!selectedProject}
|
||||
onChange={(e) => onChange(e)}
|
||||
placeholder="All environments"
|
||||
options={availableEnvironments.map(({ name, slug }) => ({
|
||||
name,
|
||||
slug
|
||||
}))}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
<LogFilterItem
|
||||
label="Secret Path"
|
||||
hoverTooltip={
|
||||
!selectedProject
|
||||
? "Select a project before filtering by secret path."
|
||||
: undefined
|
||||
}
|
||||
className={twMerge(!selectedProject && "opacity-50")}
|
||||
onClear={() => {
|
||||
setValue("secretPath", "");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormControl
|
||||
tooltipText="Filter audit logs related to events that occurred on a specific secret path."
|
||||
className="w-full"
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter secret path"
|
||||
className="disabled:cursor-not-allowed"
|
||||
isDisabled={!selectedProject}
|
||||
{...field}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
|
||||
<LogFilterItem
|
||||
hoverTooltip={
|
||||
!selectedProject
|
||||
? "Select a project before filtering by secret key."
|
||||
: undefined
|
||||
}
|
||||
className={twMerge(!selectedProject && "opacity-50")}
|
||||
label="Secret Key"
|
||||
onClear={() => {
|
||||
setValue("secretKey", "");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretKey"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormControl
|
||||
tooltipText="Filter audit logs related to a specific secret."
|
||||
className="w-full"
|
||||
>
|
||||
<Input
|
||||
isDisabled={!selectedProject}
|
||||
{...field}
|
||||
placeholder="Enter secret key"
|
||||
className="disabled:cursor-not-allowed"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
setValue("secretKey", e.target.value, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,46 +6,40 @@ import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { LogsFilter } from "./LogsFilter";
|
||||
import { LogsTable } from "./LogsTable";
|
||||
import { AuditLogFilterFormData, auditLogFilterFormSchema } from "./types";
|
||||
import { AuditLogFilterFormData, auditLogFilterFormSchema, Presets } from "./types";
|
||||
|
||||
type Props = {
|
||||
presets?: {
|
||||
actorId?: string;
|
||||
eventType?: EventType[];
|
||||
actorType?: ActorType;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
eventMetadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
showFilters?: boolean;
|
||||
filterClassName?: string;
|
||||
presets?: Presets;
|
||||
refetchInterval?: number;
|
||||
showFilters?: boolean;
|
||||
};
|
||||
|
||||
export const LogsSection = withPermission(
|
||||
({ presets, filterClassName, refetchInterval, showFilters }: Props) => {
|
||||
({ presets, refetchInterval, showFilters = true }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
|
||||
|
||||
const { control, reset, watch, setValue } = useForm<AuditLogFilterFormData>({
|
||||
resolver: zodResolver(auditLogFilterFormSchema),
|
||||
defaultValues: {
|
||||
project: null,
|
||||
actor: presets?.actorId,
|
||||
eventType: presets?.eventType || [],
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), // day before today
|
||||
endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today
|
||||
}
|
||||
});
|
||||
const { control, reset, watch, getFieldState, resetField, setValue } =
|
||||
useForm<AuditLogFilterFormData>({
|
||||
resolver: zodResolver(auditLogFilterFormSchema),
|
||||
defaultValues: {
|
||||
project: null,
|
||||
environment: undefined,
|
||||
secretKey: "",
|
||||
secretPath: "",
|
||||
actor: presets?.actorId,
|
||||
eventType: presets?.eventType || [],
|
||||
userAgentType: undefined,
|
||||
startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)),
|
||||
endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999))
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (subscription && !subscription.auditLogs) {
|
||||
@@ -57,30 +51,37 @@ export const LogsSection = withPermission(
|
||||
const userAgentType = watch("userAgentType") as UserAgentType | undefined;
|
||||
const actor = watch("actor");
|
||||
const projectId = watch("project")?.id;
|
||||
const environment = watch("environment")?.slug;
|
||||
const secretPath = watch("secretPath");
|
||||
const secretKey = watch("secretKey");
|
||||
|
||||
const startDate = watch("startDate");
|
||||
const endDate = watch("endDate");
|
||||
|
||||
const [debouncedSecretPath] = useDebounce<string>(secretPath!, 500);
|
||||
const [debouncedSecretKey] = useDebounce<string>(secretKey!, 500);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showFilters && (
|
||||
<LogsFilter
|
||||
isOrgAuditLogs
|
||||
className={filterClassName}
|
||||
presets={presets}
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
reset={reset}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex w-full justify-end">
|
||||
{showFilters && (
|
||||
<LogsFilter
|
||||
presets={presets}
|
||||
control={control}
|
||||
watch={watch}
|
||||
reset={reset}
|
||||
resetField={resetField}
|
||||
getFieldState={getFieldState}
|
||||
setValue={setValue}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LogsTable
|
||||
refetchInterval={refetchInterval}
|
||||
filter={{
|
||||
secretPath: debouncedSecretPath || undefined,
|
||||
secretKey: debouncedSecretKey || undefined,
|
||||
eventMetadata: presets?.eventMetadata,
|
||||
projectId,
|
||||
actorType: presets?.actorType,
|
||||
@@ -89,6 +90,7 @@ export const LogsSection = withPermission(
|
||||
userAgentType,
|
||||
startDate,
|
||||
endDate,
|
||||
environment,
|
||||
actor
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Fragment } from "react";
|
||||
import { faFile, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Spinner,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@@ -52,7 +54,9 @@ export const LogsTable = ({ filter, refetchInterval }: Props) => {
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-24" />
|
||||
<Th className="w-24">
|
||||
<Spinner size="xs" className={twMerge(isPending ? "opacity-100" : "opacity-0")} />
|
||||
</Th>
|
||||
<Th className="w-64">
|
||||
Timestamp
|
||||
<Tooltip
|
||||
@@ -94,7 +98,7 @@ export const LogsTable = ({ filter, refetchInterval }: Props) => {
|
||||
<Button
|
||||
className="mb-20 mt-4 px-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="star"
|
||||
variant="outline_bg"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
export const auditLogFilterFormSchema = z
|
||||
@@ -10,10 +10,12 @@ export const auditLogFilterFormSchema = z
|
||||
.object({ id: z.string(), name: z.string(), type: z.nativeEnum(ProjectType) })
|
||||
.optional()
|
||||
.nullable(),
|
||||
environment: z.object({ name: z.string(), slug: z.string() }).optional().nullable(),
|
||||
eventType: z.nativeEnum(EventType).array(),
|
||||
actor: z.string().optional(),
|
||||
userAgentType: z.nativeEnum(UserAgentType),
|
||||
secretPath: z.string().optional(),
|
||||
secretKey: z.string().optional(),
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
page: z.coerce.number().optional(),
|
||||
@@ -39,3 +41,12 @@ export type SetValueType = (
|
||||
shouldDirty?: boolean;
|
||||
}
|
||||
) => void;
|
||||
|
||||
export type Presets = {
|
||||
actorId?: string;
|
||||
eventType?: EventType[];
|
||||
actorType?: ActorType;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
eventMetadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
-23
@@ -1,8 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton, Tooltip } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { OrgUser } from "@app/hooks/api/types";
|
||||
@@ -14,7 +9,6 @@ type Props = {
|
||||
|
||||
export const UserAuditLogsSection = withPermission(
|
||||
({ orgMembership }: Props) => {
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
@@ -23,25 +17,8 @@ export const UserAuditLogsSection = withPermission(
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<p className="text-lg font-semibold text-gray-200">Audit Logs</p>
|
||||
|
||||
<Tooltip content="Show audit log filters">
|
||||
<IconButton
|
||||
colorSchema="primary"
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>Filter</p>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</div>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<LogsSection
|
||||
showFilters={showFilter}
|
||||
filterClassName="bg-mineshaft-900 static"
|
||||
presets={{
|
||||
actorId: orgMembership.user.id
|
||||
}}
|
||||
|
||||
-1
@@ -35,7 +35,6 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
||||
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
|
||||
eventType: INTEGRATION_EVENTS
|
||||
}}
|
||||
filterClassName="bg-mineshaft-900 static"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+14
-5
@@ -7,7 +7,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
PasswordGenerator
|
||||
} from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import {
|
||||
@@ -226,10 +232,13 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<PasswordGenerator onUsePassword={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+396
-3
@@ -17,11 +17,13 @@ import {
|
||||
faKey,
|
||||
faLock,
|
||||
faMinusSquare,
|
||||
faPaste,
|
||||
faPlus,
|
||||
faRotate,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
import FileSaver from "file-saver";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@@ -56,8 +58,19 @@ import {
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
|
||||
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useCreateSecretBatch,
|
||||
useDeleteSecretBatch,
|
||||
useMoveSecrets,
|
||||
useUpdateSecretBatch
|
||||
} from "@app/hooks/api";
|
||||
import {
|
||||
dashboardKeys,
|
||||
fetchDashboardProjectSecretsByKeys
|
||||
} from "@app/hooks/api/dashboard/queries";
|
||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
|
||||
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
|
||||
|
||||
@@ -68,11 +81,19 @@ import {
|
||||
useSelectedSecrets
|
||||
} from "../../SecretMainPage.store";
|
||||
import { Filter, RowType } from "../../SecretMainPage.types";
|
||||
import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard";
|
||||
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||
import { FolderForm } from "./FolderForm";
|
||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||
|
||||
type TParsedEnv = Record<string, { value: string; comments: string[]; secretPath?: string }>;
|
||||
type TParsedFolderEnv = Record<
|
||||
string,
|
||||
Record<string, { value: string; comments: string[]; secretPath?: string }>
|
||||
>;
|
||||
type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv };
|
||||
|
||||
type Props = {
|
||||
// switch the secrets type as it gets decrypted after api call
|
||||
environment: string;
|
||||
@@ -118,7 +139,9 @@ export const ActionBar = ({
|
||||
"addSecretRotation",
|
||||
"moveSecrets",
|
||||
"misc",
|
||||
"upgradePlan"
|
||||
"upgradePlan",
|
||||
"replicateFolder",
|
||||
"confirmUpload"
|
||||
] as const);
|
||||
const isProtectedBranch = Boolean(protectedBranchPolicyName);
|
||||
const { subscription } = useSubscription();
|
||||
@@ -126,6 +149,13 @@ export const ActionBar = ({
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||
const { mutateAsync: moveSecrets } = useMoveSecrets();
|
||||
const { mutateAsync: updateSecretBatch, isPending: isUpdatingSecrets } = useUpdateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const selectedSecrets = useSelectedSecrets();
|
||||
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
||||
@@ -297,6 +327,285 @@ export const ActionBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Replicate Folder Logic
|
||||
const createSecretCount = Object.keys(
|
||||
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {}
|
||||
).length;
|
||||
|
||||
const updateSecretCount = Object.keys(
|
||||
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.update || {}
|
||||
).length;
|
||||
|
||||
const isNonConflictingUpload = !updateSecretCount;
|
||||
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
|
||||
|
||||
const handleParsedEnvMultiFolder = async (envByPath: TParsedFolderEnv) => {
|
||||
if (Object.keys(envByPath).length === 0) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to find secrets"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allUpdateSecrets: TParsedEnv = {};
|
||||
const allCreateSecrets: TParsedEnv = {};
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(envByPath).map(async ([folderPath, secrets]) => {
|
||||
// Normalize the path
|
||||
let normalizedPath = folderPath;
|
||||
|
||||
// If the path is "/", use the current secretPath
|
||||
if (normalizedPath === "/") {
|
||||
normalizedPath = secretPath;
|
||||
} else {
|
||||
// Otherwise, concatenate with the current secretPath, avoiding double slashes
|
||||
const baseSecretPath = secretPath.endsWith("/") ? secretPath.slice(0, -1) : secretPath;
|
||||
// Remove leading slash from folder path if present to avoid double slashes
|
||||
const cleanFolderPath = folderPath.startsWith("/")
|
||||
? folderPath.substring(1)
|
||||
: folderPath;
|
||||
normalizedPath = `${baseSecretPath}/${cleanFolderPath}`;
|
||||
}
|
||||
|
||||
const secretFolderKeys = Object.keys(secrets);
|
||||
|
||||
if (secretFolderKeys.length === 0) return;
|
||||
|
||||
// Check which secrets already exist in this path
|
||||
const batchSize = 50;
|
||||
const secretBatches = Array.from(
|
||||
{ length: Math.ceil(secretFolderKeys.length / batchSize) },
|
||||
(_, i) => secretFolderKeys.slice(i * batchSize, (i + 1) * batchSize)
|
||||
);
|
||||
|
||||
const existingSecretLookup: Record<string, boolean> = {};
|
||||
|
||||
const processBatches = async () => {
|
||||
await secretBatches.reduce(async (previous, batch) => {
|
||||
await previous;
|
||||
|
||||
const { secrets: batchSecrets } = await fetchDashboardProjectSecretsByKeys({
|
||||
secretPath: normalizedPath,
|
||||
environment,
|
||||
projectId: workspaceId,
|
||||
keys: batch
|
||||
});
|
||||
|
||||
batchSecrets.forEach((secret) => {
|
||||
existingSecretLookup[secret.secretKey] = true;
|
||||
});
|
||||
}, Promise.resolve());
|
||||
};
|
||||
|
||||
await processBatches();
|
||||
|
||||
// Categorize each secret as update or create
|
||||
secretFolderKeys.forEach((secretKey) => {
|
||||
const secretData = secrets[secretKey];
|
||||
|
||||
// Store the path with the secret for later batch processing
|
||||
const secretWithPath = {
|
||||
...secretData,
|
||||
secretPath: normalizedPath
|
||||
};
|
||||
|
||||
if (existingSecretLookup[secretKey]) {
|
||||
allUpdateSecrets[secretKey] = secretWithPath;
|
||||
} else {
|
||||
allCreateSecrets[secretKey] = secretWithPath;
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
handlePopUpOpen("confirmUpload", {
|
||||
update: allUpdateSecrets,
|
||||
create: allCreateSecrets
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
createNotification({
|
||||
text: "Failed to check for secret conflicts",
|
||||
type: "error"
|
||||
});
|
||||
handlePopUpClose("confirmUpload");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFolderImport = async () => {
|
||||
const { update, create } = popUp?.confirmUpload?.data as TSecOverwriteOpt;
|
||||
try {
|
||||
// Group secrets by their path for batch operations
|
||||
const groupedCreateSecrets: Record<
|
||||
string,
|
||||
Array<{
|
||||
type: SecretType;
|
||||
secretComment: string;
|
||||
secretValue: string;
|
||||
secretKey: string;
|
||||
}>
|
||||
> = {};
|
||||
|
||||
const groupedUpdateSecrets: Record<
|
||||
string,
|
||||
Array<{
|
||||
type: SecretType;
|
||||
secretComment: string;
|
||||
secretValue: string;
|
||||
secretKey: string;
|
||||
}>
|
||||
> = {};
|
||||
|
||||
// Collect all unique paths that need folders to be created
|
||||
const allPaths = new Set<string>();
|
||||
|
||||
// Add paths from create secrets
|
||||
Object.values(create || {}).forEach((secData) => {
|
||||
if (secData.secretPath && secData.secretPath !== secretPath) {
|
||||
allPaths.add(secData.secretPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a map of folder paths to their folder name (last segment)
|
||||
const folderPaths = Array.from(allPaths).map((path) => {
|
||||
// Remove trailing slash if it exists
|
||||
const normalizedPath = path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
// Split by '/' to get path segments
|
||||
const segments = normalizedPath.split("/");
|
||||
// Get the last segment as the folder name
|
||||
const folderName = segments[segments.length - 1];
|
||||
// Get the parent path (everything except the last segment)
|
||||
const parentPath = segments.slice(0, -1).join("/");
|
||||
|
||||
return {
|
||||
folderName,
|
||||
fullPath: normalizedPath,
|
||||
parentPath: parentPath || "/"
|
||||
};
|
||||
});
|
||||
|
||||
// Sort paths by depth (shortest first) to ensure parent folders are created before children
|
||||
folderPaths.sort(
|
||||
(a, b) => (a.fullPath.match(/\//g) || []).length - (b.fullPath.match(/\//g) || []).length
|
||||
);
|
||||
|
||||
// Track created folders to avoid duplicates
|
||||
const createdFolders = new Set<string>();
|
||||
|
||||
// Create all necessary folders in order using Promise.all and reduce
|
||||
await folderPaths.reduce(async (previousPromise, { folderName, fullPath, parentPath }) => {
|
||||
// Wait for the previous promise to complete
|
||||
await previousPromise;
|
||||
|
||||
// Skip if we've already created this folder
|
||||
if (createdFolders.has(fullPath)) return Promise.resolve();
|
||||
|
||||
try {
|
||||
await createFolder({
|
||||
name: folderName,
|
||||
path: parentPath,
|
||||
environment,
|
||||
projectId: workspaceId
|
||||
});
|
||||
|
||||
createdFolders.add(fullPath);
|
||||
} catch (err) {
|
||||
console.log(`Folder ${folderName} may already exist:`, err);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}, Promise.resolve());
|
||||
|
||||
if (Object.keys(create || {}).length > 0) {
|
||||
Object.entries(create).forEach(([secretKey, secData]) => {
|
||||
// Use the stored secretPath or fall back to the current secretPath
|
||||
const path = secData.secretPath || secretPath;
|
||||
|
||||
if (!groupedCreateSecrets[path]) {
|
||||
groupedCreateSecrets[path] = [];
|
||||
}
|
||||
|
||||
groupedCreateSecrets[path].push({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(groupedCreateSecrets).map(([path, secrets]) =>
|
||||
createSecretBatch({
|
||||
secretPath: path,
|
||||
workspaceId,
|
||||
environment,
|
||||
secrets
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(update || {}).length > 0) {
|
||||
Object.entries(update).forEach(([secretKey, secData]) => {
|
||||
// Use the stored secretPath or fall back to the current secretPath
|
||||
const path = secData.secretPath || secretPath;
|
||||
|
||||
if (!groupedUpdateSecrets[path]) {
|
||||
groupedUpdateSecrets[path] = [];
|
||||
}
|
||||
|
||||
groupedUpdateSecrets[path].push({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
});
|
||||
});
|
||||
|
||||
// Update secrets for each path in parallel
|
||||
await Promise.all(
|
||||
Object.entries(groupedUpdateSecrets).map(([path, secrets]) =>
|
||||
updateSecretBatch({
|
||||
secretPath: path,
|
||||
workspaceId,
|
||||
environment,
|
||||
secrets
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate appropriate queries to refresh UI
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretApprovalRequestKeys.count({ workspaceId })
|
||||
});
|
||||
|
||||
// Close the modal and show notification
|
||||
handlePopUpClose("confirmUpload");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: isProtectedBranch
|
||||
? "Uploaded changes have been sent for review"
|
||||
: "Successfully uploaded secrets"
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to upload secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
@@ -618,6 +927,29 @@ export const ActionBar = ({
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.SecretFolders, {
|
||||
environment,
|
||||
secretPath
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPaste} className="pr-2" />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("replicateFolder");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10 text-left"
|
||||
isFullWidth
|
||||
>
|
||||
Replicate Folder
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -733,6 +1065,15 @@ export const ActionBar = ({
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
onMoveApproved={handleSecretsMove}
|
||||
/>
|
||||
<ReplicateFolderFromBoard
|
||||
isOpen={popUp.replicateFolder.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("replicateFolder", isOpen)}
|
||||
onParsedEnv={handleParsedEnvMultiFolder}
|
||||
environment={environment}
|
||||
environments={currentWorkspace.environments}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
/>
|
||||
{subscription && (
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
@@ -744,6 +1085,58 @@ export const ActionBar = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={popUp?.confirmUpload?.isOpen}
|
||||
onOpenChange={(open) => handlePopUpToggle("confirmUpload", open)}
|
||||
>
|
||||
<ModalContent
|
||||
title="Confirm Secret Upload"
|
||||
footerContent={[
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema={isNonConflictingUpload ? "primary" : "danger"}
|
||||
key="overwrite-btn"
|
||||
onClick={handleSaveFolderImport}
|
||||
>
|
||||
{isNonConflictingUpload ? "Upload" : "Overwrite"}
|
||||
</Button>,
|
||||
<Button
|
||||
key="keep-old-btn"
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpClose("confirmUpload")}
|
||||
variant="outline_bg"
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
{isNonConflictingUpload ? (
|
||||
<div>
|
||||
Are you sure you want to import {createSecretCount} secret
|
||||
{createSecretCount > 1 ? "s" : ""} to this environment?
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col text-gray-300">
|
||||
<div>Your project already contains the following {updateSecretCount} secrets:</div>
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {})
|
||||
?.map((key) => key)
|
||||
.join(", ")}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
Are you sure you want to overwrite these secrets
|
||||
{createSecretCount > 0
|
||||
? ` and import ${createSecretCount} new
|
||||
one${createSecretCount > 1 ? "s" : ""}`
|
||||
: ""}
|
||||
?
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faClone } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Switch
|
||||
} from "@app/components/v2";
|
||||
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useGetAccessibleSecrets } from "@app/hooks/api/dashboard";
|
||||
import { SecretV3Raw } from "@app/hooks/api/types";
|
||||
|
||||
import { SecretTreeView } from "./SecretTreeView";
|
||||
|
||||
const formSchema = z.object({
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
),
|
||||
secrets: z
|
||||
.object({
|
||||
secretKey: z.string(),
|
||||
secretValue: z.string().optional(),
|
||||
secretPath: z.string()
|
||||
})
|
||||
.array()
|
||||
.min(1, "Select one or more secrets to copy")
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
onToggle: (isOpen: boolean) => void;
|
||||
onParsedEnv: (
|
||||
env: Record<string, Record<string, { value: string; comments: string[]; secretPath?: string }>>
|
||||
) => void;
|
||||
environments?: { name: string; slug: string }[];
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
type SecretFolder = {
|
||||
items: Partial<SecretV3Raw>[];
|
||||
subFolders: Record<string, SecretFolder>;
|
||||
};
|
||||
|
||||
type SecretStructure = {
|
||||
[rootPath: string]: SecretFolder;
|
||||
};
|
||||
|
||||
export const ReplicateFolderFromBoard = ({
|
||||
environments = [],
|
||||
workspaceId,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onParsedEnv
|
||||
}: Props) => {
|
||||
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
|
||||
|
||||
const { handleSubmit, control, watch, reset, setValue } = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { secretPath: "/", environment: environments?.[0], secrets: [] }
|
||||
});
|
||||
|
||||
const envCopySecPath = watch("secretPath");
|
||||
const selectedEnvSlug = watch("environment");
|
||||
const selectedSecrets = watch("secrets");
|
||||
const [debouncedEnvCopySecretPath] = useDebounce(envCopySecPath);
|
||||
|
||||
const { data: accessibleSecrets } = useGetAccessibleSecrets({
|
||||
projectId: workspaceId,
|
||||
secretPath: "/",
|
||||
environment: selectedEnvSlug.slug,
|
||||
recursive: true,
|
||||
filterByAction: shouldIncludeValues
|
||||
? ProjectPermissionSecretActions.ReadValue
|
||||
: ProjectPermissionSecretActions.DescribeSecret,
|
||||
options: { enabled: Boolean(workspaceId) && Boolean(selectedEnvSlug) && isOpen }
|
||||
});
|
||||
|
||||
const restructureSecrets = useMemo(() => {
|
||||
if (!accessibleSecrets) return {};
|
||||
|
||||
const result: SecretStructure = {};
|
||||
result["/"] = {
|
||||
items: [],
|
||||
subFolders: {}
|
||||
};
|
||||
|
||||
accessibleSecrets.forEach((secret) => {
|
||||
const path = secret.secretPath || "/";
|
||||
|
||||
if (path === "/") {
|
||||
result["/"]?.items.push(secret);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith("/") ? path.substring(1) : path;
|
||||
const pathParts = normalizedPath.split("/");
|
||||
|
||||
let currentFolder = result["/"];
|
||||
|
||||
for (let i = 0; i < pathParts.length; i += 1) {
|
||||
const part = pathParts[i];
|
||||
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!part) continue;
|
||||
|
||||
if (i === pathParts.length - 1) {
|
||||
if (!currentFolder.subFolders[part]) {
|
||||
currentFolder.subFolders[part] = {
|
||||
items: [],
|
||||
subFolders: {}
|
||||
};
|
||||
}
|
||||
currentFolder.subFolders[part].items.push(secret);
|
||||
} else {
|
||||
if (!currentFolder.subFolders[part]) {
|
||||
currentFolder.subFolders[part] = {
|
||||
items: [],
|
||||
subFolders: {}
|
||||
};
|
||||
}
|
||||
currentFolder = currentFolder.subFolders[part];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [accessibleSecrets, selectedEnvSlug]);
|
||||
|
||||
const secretsFilteredByPath = useMemo(() => {
|
||||
let normalizedPath = debouncedEnvCopySecretPath;
|
||||
normalizedPath = debouncedEnvCopySecretPath.startsWith("/")
|
||||
? debouncedEnvCopySecretPath
|
||||
: `/${debouncedEnvCopySecretPath}`;
|
||||
if (normalizedPath.length > 1 && normalizedPath.endsWith("/")) {
|
||||
normalizedPath = debouncedEnvCopySecretPath.slice(0, -1);
|
||||
}
|
||||
|
||||
if (normalizedPath === "/") {
|
||||
return restructureSecrets["/"];
|
||||
}
|
||||
|
||||
const segments = normalizedPath.split("/").filter((segment) => segment !== "");
|
||||
|
||||
let currentLevel = restructureSecrets["/"];
|
||||
let result = null;
|
||||
let currentPath = "";
|
||||
|
||||
if (!currentLevel) {
|
||||
setValue("secretPath", "/");
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
const segment = segments[i];
|
||||
currentPath += `/${segment}`;
|
||||
|
||||
if (currentLevel?.subFolders?.[segment]) {
|
||||
currentLevel = currentLevel.subFolders[segment];
|
||||
|
||||
if (currentPath === normalizedPath) {
|
||||
result = currentLevel;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [restructureSecrets, debouncedEnvCopySecretPath]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue("secrets", []);
|
||||
}, [debouncedEnvCopySecretPath, selectedEnvSlug]);
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
const secretsToBePulled: Record<
|
||||
string,
|
||||
Record<string, { value: string; comments: string[]; secretPath: string }>
|
||||
> = {};
|
||||
data.secrets.forEach(({ secretKey, secretValue, secretPath: secretPathToRecreate }) => {
|
||||
const normalizedPath = secretPathToRecreate.startsWith(envCopySecPath)
|
||||
? secretPathToRecreate.slice(envCopySecPath.length)
|
||||
: secretPathToRecreate;
|
||||
|
||||
if (!secretsToBePulled[normalizedPath]) {
|
||||
secretsToBePulled[normalizedPath] = {};
|
||||
}
|
||||
|
||||
secretsToBePulled[normalizedPath][secretKey] = {
|
||||
value: (shouldIncludeValues && secretValue) || "",
|
||||
comments: [""],
|
||||
secretPath: normalizedPath
|
||||
};
|
||||
});
|
||||
onParsedEnv(secretsToBePulled);
|
||||
onToggle(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(state) => {
|
||||
onToggle(state);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
className="max-w-2xl"
|
||||
title="Replicate Folder Content From An Environment"
|
||||
subTitle="Replicate folder content from other environments into this context"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormControl label="Environment" isRequired className="w-1/3">
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={environments}
|
||||
placeholder="Select environment..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Secret Path" className="flex-grow" isRequired>
|
||||
<SecretPathInput
|
||||
{...field}
|
||||
placeholder="Provide a path, default is /"
|
||||
environment={selectedEnvSlug?.slug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-mineshaft-600 pt-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="secrets"
|
||||
render={({ field: { onChange } }) => (
|
||||
<FormControl className="flex-grow" isRequired>
|
||||
<SecretTreeView
|
||||
data={secretsFilteredByPath}
|
||||
basePath={debouncedEnvCopySecretPath}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="my-6 ml-2">
|
||||
<Switch
|
||||
id="populate-include-value"
|
||||
isChecked={shouldIncludeValues}
|
||||
onCheckedChange={(isChecked) => {
|
||||
setValue("secrets", []);
|
||||
setShouldIncludeValues(isChecked as boolean);
|
||||
}}
|
||||
>
|
||||
Include secret values
|
||||
</Switch>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faClone} />}
|
||||
type="submit"
|
||||
isDisabled={!selectedSecrets || selectedSecrets.length === 0}
|
||||
>
|
||||
Replicate Folder
|
||||
</Button>
|
||||
<Button variant="plain" colorSchema="secondary" onClick={() => onToggle(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
faChevronDown,
|
||||
faChevronRight,
|
||||
faFolder,
|
||||
faFolderOpen,
|
||||
faFolderTree,
|
||||
faKey
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
import { Checkbox } from "@app/components/v2";
|
||||
|
||||
interface SecretItem {
|
||||
id?: string;
|
||||
secretKey?: string;
|
||||
secretValue?: string;
|
||||
secretPath?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface FolderStructure {
|
||||
items: SecretItem[];
|
||||
subFolders: {
|
||||
[key: string]: FolderStructure;
|
||||
};
|
||||
}
|
||||
|
||||
interface TreeData {
|
||||
[key: string]: FolderStructure | null;
|
||||
}
|
||||
|
||||
interface FolderProps {
|
||||
name: string;
|
||||
structure: FolderStructure;
|
||||
path: string;
|
||||
selectedItems: SecretItem[];
|
||||
onItemSelect: (item: SecretItem, isChecked: boolean) => void;
|
||||
onFolderSelect: (folderPath: string, isChecked: boolean) => void;
|
||||
isExpanded?: boolean;
|
||||
level: number;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
interface TreeViewProps {
|
||||
data: FolderStructure | null;
|
||||
basePath?: string;
|
||||
className?: string;
|
||||
onChange: (items: SecretItem[]) => void;
|
||||
}
|
||||
|
||||
const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => {
|
||||
let items: SecretItem[] = [];
|
||||
|
||||
items = items.concat(folder.items);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
Object.entries(folder.subFolders).forEach(([_, subFolder]) => {
|
||||
items = items.concat(getAllItemsInFolder(subFolder));
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const getDisplayName = (name: string): string => {
|
||||
const parts = name.split("/");
|
||||
return parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.Trigger;
|
||||
const CollapsibleContent = CollapsiblePrimitive.Content;
|
||||
|
||||
const Folder: React.FC<FolderProps> = ({
|
||||
name,
|
||||
structure,
|
||||
path,
|
||||
selectedItems,
|
||||
onItemSelect,
|
||||
onFolderSelect,
|
||||
isExpanded = false,
|
||||
level,
|
||||
basePath
|
||||
}) => {
|
||||
const [open, setOpen] = useState(isExpanded);
|
||||
const displayName = useMemo(() => getDisplayName(name), [name]);
|
||||
|
||||
const allItems = useMemo(() => getAllItemsInFolder(structure), [structure]);
|
||||
const allItemIds = useMemo(() => allItems.map((item) => item.id), [allItems]);
|
||||
const selectedItemIds = useMemo(() => selectedItems.map((item) => item.id), [selectedItems]);
|
||||
const allSelected = useMemo(
|
||||
() => allItemIds.length > 0 && allItemIds.every((id) => selectedItemIds.includes(id)),
|
||||
[allItemIds, selectedItemIds]
|
||||
);
|
||||
const someSelected = useMemo(
|
||||
() => allItemIds.some((id) => selectedItemIds.includes(id)) && !allSelected,
|
||||
[allItemIds, selectedItemIds, allSelected]
|
||||
);
|
||||
const hasContents = structure.items.length > 0 || Object.keys(structure.subFolders).length > 0;
|
||||
|
||||
const handleFolderSelect = (checked: boolean) => {
|
||||
onFolderSelect(path, checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`folder-container ml-${level > 0 ? "4" : 0}`}>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="group flex items-center rounded px-2 py-1">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-1 flex h-6 w-6 items-center justify-center rounded focus:outline-none"
|
||||
disabled={!hasContents}
|
||||
aria-label={open ? "Collapse folder" : "Expand folder"}
|
||||
>
|
||||
{hasContents && (
|
||||
<FontAwesomeIcon icon={open ? faChevronDown : faChevronRight} className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<div className="mr-2">
|
||||
<FontAwesomeIcon
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
icon={level > 0 ? (open ? faFolderOpen : faFolder) : faFolderTree}
|
||||
className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="folder-root"
|
||||
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
||||
isChecked={allSelected || someSelected}
|
||||
onCheckedChange={handleFolderSelect}
|
||||
isIndeterminate={someSelected && !allSelected}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor={`folder-${path}`}
|
||||
className={`ml-2 flex-1 cursor-pointer truncate ${basePath ? "italic text-mineshaft-300" : ""}`}
|
||||
title={displayName}
|
||||
>
|
||||
{displayName || `${basePath}`}
|
||||
</label>
|
||||
|
||||
{allItemIds.length > 0 && (
|
||||
<span className="ml-2 text-xs text-mineshaft-400">
|
||||
{allItemIds.length} {allItemIds.length === 1 ? "item" : "items"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="overflow-hidden transition-all duration-300 ease-in-out">
|
||||
<div className="relative mt-1">
|
||||
<div className="absolute bottom-0 left-5 top-0 w-px bg-mineshaft-600" />
|
||||
{structure.items.map((item) => (
|
||||
<div key={item.id} className="group ml-6 flex items-center rounded px-2 py-1">
|
||||
<div className="ml-6 mr-2">
|
||||
<FontAwesomeIcon icon={faKey} className="h-3 w-3" />
|
||||
</div>
|
||||
<Checkbox
|
||||
id={`folder-${item.id}`}
|
||||
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
||||
isChecked={selectedItemIds.includes(item.id)}
|
||||
onCheckedChange={(checked) => onItemSelect(item, !!checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="ml-2 flex-1 cursor-pointer truncate"
|
||||
title={item.secretKey}
|
||||
>
|
||||
{item.secretKey}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.entries(structure.subFolders).map(([subName, subStructure]) => (
|
||||
<Folder
|
||||
key={subName}
|
||||
name={subName}
|
||||
structure={subStructure}
|
||||
path={path ? `${path}/${subName}` : subName}
|
||||
selectedItems={selectedItems}
|
||||
onItemSelect={onItemSelect}
|
||||
onFolderSelect={onFolderSelect}
|
||||
level={level + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SecretTreeView: React.FC<TreeViewProps> = ({
|
||||
data,
|
||||
basePath = "/",
|
||||
className = "",
|
||||
onChange
|
||||
}) => {
|
||||
const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]);
|
||||
const rootPath = "/";
|
||||
const treeData: TreeData = data ? { [rootPath]: data as FolderStructure } : { [rootPath]: null };
|
||||
|
||||
const rootFolders = useMemo(() => {
|
||||
return Object.entries(treeData);
|
||||
}, [treeData]);
|
||||
|
||||
const isEmptyData = useMemo(() => {
|
||||
return (
|
||||
!data || (typeof data === "object" && Object.keys(data).length === 0) || !rootFolders.length
|
||||
);
|
||||
}, [data, rootFolders]);
|
||||
|
||||
const handleItemSelect = (item: SecretItem, isChecked: boolean) => {
|
||||
if (isChecked) {
|
||||
setSelectedItems((prev) => [...prev, item]);
|
||||
} else {
|
||||
setSelectedItems((prev) => prev.filter((i) => i.id !== item.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderSelect = (folderPath: string, isChecked: boolean) => {
|
||||
const getFolderFromPath = (tree: TreeData, path: string): FolderStructure | null => {
|
||||
if (rootFolders.length === 1 && rootFolders[0][0] === path) {
|
||||
return rootFolders[0][1];
|
||||
}
|
||||
|
||||
let adjustedPath = path;
|
||||
if (!path.startsWith(rootPath)) {
|
||||
adjustedPath = rootPath === path ? rootPath : `${rootPath}/${path}`;
|
||||
}
|
||||
|
||||
if (adjustedPath === "/") return tree["/"];
|
||||
|
||||
const parts = adjustedPath.split("/").filter((p) => p !== "");
|
||||
|
||||
let current: any;
|
||||
current = tree["/"];
|
||||
|
||||
const targetExists = parts.every((part) => {
|
||||
if (current?.subFolders?.[part]) {
|
||||
current = current.subFolders[part];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!targetExists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
const folder = getFolderFromPath(treeData, folderPath);
|
||||
if (!folder) return;
|
||||
|
||||
const folderItems = getAllItemsInFolder(folder);
|
||||
const folderItemIds = folderItems.map((item) => item.id);
|
||||
|
||||
if (isChecked) {
|
||||
setSelectedItems((prev) => {
|
||||
const prevIds = prev.map((item) => item.id);
|
||||
const newItems = [...prev];
|
||||
folderItems.forEach((item) => {
|
||||
if (!prevIds.includes(item.id)) {
|
||||
newItems.push(item);
|
||||
}
|
||||
});
|
||||
return newItems;
|
||||
});
|
||||
} else {
|
||||
setSelectedItems((prev) => prev.filter((item) => !folderItemIds.includes(item.id)));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItems([]);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(selectedItems);
|
||||
}, [selectedItems]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-start gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900">
|
||||
<div className={`w-full rounded-lg shadow-sm ${className}`}>
|
||||
<div className="h-[25vh] overflow-auto p-3">
|
||||
{isEmptyData ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-center text-mineshaft-300">
|
||||
<p>No secrets or folders available</p>
|
||||
</div>
|
||||
) : (
|
||||
rootFolders.map(([folderName, folderStructure]) => (
|
||||
<Folder
|
||||
basePath={basePath}
|
||||
key={folderName}
|
||||
name={folderName}
|
||||
structure={folderStructure || { items: [], subFolders: {} }}
|
||||
path={folderName}
|
||||
selectedItems={selectedItems}
|
||||
onItemSelect={handleItemSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
isExpanded
|
||||
level={0}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pb-2 pr-2 pt-2">
|
||||
<h3 className="flex items-center text-mineshaft-400">
|
||||
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+10
-7
@@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { Button, FormControl, Input, PasswordGenerator } from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
@@ -162,12 +162,15 @@ export const CreateSecretForm = ({
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<PasswordGenerator onUsePassword={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+14
-11
@@ -64,7 +64,8 @@ export const SecretDropzone = ({
|
||||
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
|
||||
// hide copy secrets from board due to import folders feature
|
||||
const shouldRenderCopySecrets = false;
|
||||
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
|
||||
|
||||
const handleDrag = (e: DragEvent) => {
|
||||
@@ -308,16 +309,18 @@ export const SecretDropzone = ({
|
||||
secretPath={secretPath}
|
||||
isSmaller={isSmaller}
|
||||
/>
|
||||
<CopySecretsFromBoard
|
||||
isOpen={popUp.importSecEnv.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
|
||||
onParsedEnv={handleParsedEnv}
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isSmaller={isSmaller}
|
||||
/>
|
||||
{shouldRenderCopySecrets && (
|
||||
<CopySecretsFromBoard
|
||||
isOpen={popUp.importSecEnv.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
|
||||
onParsedEnv={handleParsedEnv}
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isSmaller={isSmaller}
|
||||
/>
|
||||
)}
|
||||
{!isSmaller && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
|
||||
-1
@@ -41,7 +41,6 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
|
||||
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
|
||||
eventType: INTEGRATION_EVENTS
|
||||
}}
|
||||
filterClassName="bg-mineshaft-900 static"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg bg-mineshaft-800 text-sm text-mineshaft-200">
|
||||
|
||||
Reference in New Issue
Block a user