Merge branch 'main' into secret-rotations-v2-feature

This commit is contained in:
Scott Wilson
2025-04-03 21:21:06 -07:00
96 changed files with 4509 additions and 926 deletions
@@ -1,7 +1,9 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { MongoAbility, MongoQuery } from "@casl/ability";
import {
faAnglesUp,
faArrowUpRightFromSquare,
faDownLeftAndUpRightToCenter,
faUpRightAndDownLeftFromCenter,
faWindowRestore
} from "@fortawesome/free-solid-svg-icons";
@@ -10,6 +12,7 @@ import {
Background,
BackgroundVariant,
ConnectionLineType,
ControlButton,
Controls,
Node,
NodeMouseHandler,
@@ -23,7 +26,9 @@ import { twMerge } from "tailwind-merge";
import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components";
import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput";
import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode";
import { AccessTreeErrorBoundary, AccessTreeProvider } from "./components";
import { BasePermissionEdge } from "./edges";
import { useAccessTree } from "./hooks";
import { FolderNode, RoleNode } from "./nodes";
@@ -35,13 +40,30 @@ export type AccessTreeProps = {
const EdgeTypes = { base: BasePermissionEdge };
const NodeTypes = { role: RoleNode, folder: FolderNode };
const NodeTypes = { role: RoleNode, folder: FolderNode, showMoreButton: ShowMoreButtonNode };
const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
const accessTreeData = useAccessTree(permissions);
const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData;
const [selectedPath, setSelectedPath] = useState<string>("/");
const accessTreeData = useAccessTree(permissions, selectedPath);
const { edges, nodes, isLoading, viewMode, setViewMode, environment } = accessTreeData;
const [initialRender, setInitialRender] = useState(true);
const { fitView, getViewport, setCenter } = useReactFlow();
useEffect(() => {
setSelectedPath("/");
}, [environment]);
const { getViewport, setCenter, fitView } = useReactFlow();
const goToRootNode = useCallback(() => {
const roleNode = nodes.find((node) => node.type === "role");
if (roleNode) {
setCenter(
roleNode.position.x + (roleNode.width ? roleNode.width / 2 : 0),
roleNode.position.y + (roleNode.height ? roleNode.height / 2 : 0),
{ duration: 800, zoom: 1 }
);
}
}, [nodes, setCenter]);
const onNodeClick: NodeMouseHandler<Node> = useCallback(
(_, node) => {
@@ -55,14 +77,19 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
);
useEffect(() => {
setTimeout(() => {
fitView({
padding: 0.2,
duration: 1000,
maxZoom: 1
});
}, 1);
}, [fitView, nodes, edges, getViewport()]);
setInitialRender(true);
}, [selectedPath, environment]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (initialRender) {
timer = setTimeout(() => {
goToRootNode();
setInitialRender(false);
}, 500);
}
return () => clearTimeout(timer);
}, [nodes, edges, getViewport(), initialRender, goToRootNode]);
const handleToggleModalView = () =>
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
@@ -133,13 +160,13 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
edges={edges}
edgeTypes={EdgeTypes}
nodeTypes={NodeTypes}
fitView
onNodeClick={onNodeClick}
colorMode="dark"
nodesDraggable={false}
edgesReconnectable={false}
nodesConnectable={false}
connectionLineType={ConnectionLineType.SmoothStep}
minZoom={0.001}
proOptions={{
hideAttribution: false // we need pro license if we want to hide
}}
@@ -151,9 +178,17 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
)}
{viewMode !== ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
{viewMode !== ViewMode.Undocked && (
<AccessTreeSecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
)}
<Tooltip position="bottom" align="center" content={undockButtonLabel}>
<IconButton
className="mr-1 rounded"
className="ml-1 w-10 rounded"
colorSchema="secondary"
variant="plain"
onClick={handleToggleUndockedView}
@@ -170,7 +205,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
</Tooltip>
<Tooltip align="end" position="bottom" content={windowButtonLabel}>
<IconButton
className="rounded"
className="w-10 rounded"
colorSchema="secondary"
variant="plain"
onClick={handleToggleModalView}
@@ -179,7 +214,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
<FontAwesomeIcon
icon={
viewMode === ViewMode.Modal
? faArrowUpRightFromSquare
? faDownLeftAndUpRightToCenter
: faUpRightAndDownLeftFromCenter
}
/>
@@ -187,9 +222,28 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
</Tooltip>
</Panel>
)}
<PermissionSimulation {...accessTreeData} />
{viewMode === ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
<AccessTreeSecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
</Panel>
)}
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
<Controls position="bottom-left" />
<Controls
position="bottom-left"
showInteractive={false}
onFitView={() => fitView({ duration: 800 })}
>
<ControlButton onClick={goToRootNode}>
<Tooltip position="right" content="Go to root folder">
<FontAwesomeIcon icon={faAnglesUp} />
</Tooltip>
</ControlButton>
</Controls>
</ReactFlow>
</div>
</div>
@@ -46,6 +46,12 @@ export const PermissionSimulation = ({
className="mr-1 rounded"
colorSchema="secondary"
onClick={handlePermissionSimulation}
rightIcon={
<FontAwesomeIcon
className="pl-1 text-sm text-bunker-300 hover:text-primary hover:opacity-80"
icon={faChevronDown}
/>
}
>
Permission Simulation
</Button>
@@ -5,6 +5,7 @@ import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries";
import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types";
import { useAccessTreeContext } from "../components";
import { PermissionAccess } from "../types";
@@ -15,8 +16,24 @@ import {
getSubjectActionRuleMap,
positionElements
} from "../utils";
import { createShowMoreNode } from "../utils/createShowMoreNode";
export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, MongoQuery>) => {
const INITIAL_FOLDERS_PER_LEVEL = 10;
const FOLDERS_INCREMENT = 10;
type LevelFolderMap = Record<
string,
{
folders: TSecretFolderWithPath[];
visibleCount: number;
hasMore: boolean;
}
>;
export const useAccessTree = (
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>,
searchPath: string
) => {
const { currentWorkspace } = useWorkspace();
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
const [nodes, setNodes] = useNodesState<Node>([]);
@@ -27,19 +44,124 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
currentWorkspace.id
);
const [levelFolderMap, setLevelFolderMap] = useState<LevelFolderMap>({});
const [totalFolderCount, setTotalFolderCount] = useState(0);
const showMoreFolders = (parentId: string) => {
setLevelFolderMap((prevMap) => {
const level = prevMap[parentId];
if (!level) return prevMap;
const newVisibleCount = Math.min(
level.visibleCount + FOLDERS_INCREMENT,
level.folders.length
);
return {
...prevMap,
[parentId]: {
...level,
visibleCount: newVisibleCount,
hasMore: newVisibleCount < level.folders.length
}
};
});
};
const levelsWithMoreFolders = Object.entries(levelFolderMap)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, level]) => level.hasMore)
.map(([parentId]) => parentId);
const getLevelCounts = (parentId: string) => {
const level = levelFolderMap[parentId];
if (!level) return { visibleCount: 0, totalCount: 0, hasMore: false };
return {
visibleCount: level.visibleCount,
totalCount: level.folders.length,
hasMore: level.hasMore
};
};
useEffect(() => {
if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return;
const { folders, name } = environmentsFolders[environment];
const { folders } = environmentsFolders[environment];
setTotalFolderCount(folders.length);
const groupedFolders: Record<string, TSecretFolderWithPath[]> = {};
const filteredFolders = folders.filter((folder) => {
if (folder.path.startsWith(searchPath)) {
return true;
}
if (
searchPath.startsWith(folder.path) &&
(folder.path === "/" ||
searchPath === folder.path ||
searchPath.indexOf("/", folder.path.length) === folder.path.length)
) {
return true;
}
return false;
});
filteredFolders.forEach((folder) => {
const parentId = folder.parentId || "";
if (!groupedFolders[parentId]) {
groupedFolders[parentId] = [];
}
groupedFolders[parentId].push(folder);
});
const newLevelFolderMap: LevelFolderMap = {};
Object.entries(groupedFolders).forEach(([parentId, folderList]) => {
const key = parentId;
newLevelFolderMap[key] = {
folders: folderList,
visibleCount: Math.min(INITIAL_FOLDERS_PER_LEVEL, folderList.length),
hasMore: folderList.length > INITIAL_FOLDERS_PER_LEVEL
};
});
setLevelFolderMap(newLevelFolderMap);
}, [permissions, environmentsFolders, environment, subject, secretName, searchPath]);
useEffect(() => {
if (
!environmentsFolders ||
!permissions ||
!environmentsFolders[environment] ||
Object.keys(levelFolderMap).length === 0
)
return;
const { slug } = environmentsFolders[environment];
const roleNode = createRoleNode({
subject,
environment: name
environment: slug,
environments: environmentsFolders,
onSubjectChange: setSubject,
onEnvironmentChange: setEnvironment
});
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
const folderNodes = folders.map((folder) =>
const visibleFolders: TSecretFolderWithPath[] = [];
Object.entries(levelFolderMap).forEach(([key, levelData]) => {
if (key !== "__rootFolderId") {
visibleFolders.push(...levelData.folders.slice(0, levelData.visibleCount));
}
});
// eslint-disable-next-line no-underscore-dangle
const rootFolder = levelFolderMap.__rootFolderId?.folders[0];
const folderNodes = visibleFolders.map((folder) =>
createFolderNode({
folder,
permissions,
@@ -50,10 +172,45 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
})
);
const folderEdges = folderNodes.map(({ data: folder }) => {
const actions = Object.values(folder.actions);
const folderEdges: Edge[] = [];
if (rootFolder) {
const rootFolderNode = folderNodes.find(
(node) => node.data.id === rootFolder.id || node.data.path === rootFolder.path
);
if (rootFolderNode) {
const rootActions = Object.values(rootFolderNode.data.actions);
let rootAccess: PermissionAccess;
if (Object.values(rootActions).some((action) => action === PermissionAccess.Full)) {
rootAccess = PermissionAccess.Full;
} else if (
Object.values(rootActions).some((action) => action === PermissionAccess.Partial)
) {
rootAccess = PermissionAccess.Partial;
} else {
rootAccess = PermissionAccess.None;
}
folderEdges.push(
createBaseEdge({
source: roleNode.id,
target: rootFolderNode.id,
access: rootAccess
})
);
}
}
folderNodes.forEach(({ data: folder }) => {
if (rootFolder && (folder.id === rootFolder.id || folder.path === rootFolder.path)) {
return;
}
const actions = Object.values(folder.actions);
let access: PermissionAccess;
if (Object.values(actions).some((action) => action === PermissionAccess.Full)) {
access = PermissionAccess.Full;
} else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) {
@@ -62,17 +219,55 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
access = PermissionAccess.None;
}
return createBaseEdge({
source: folder.parentId ?? roleNode.id,
target: folder.id,
access
});
folderEdges.push(
createBaseEdge({
source: folder.parentId ?? roleNode.id,
target: folder.id,
access
})
);
});
const init = positionElements([roleNode, ...folderNodes], [...folderEdges]);
const addMoreButtons: Node[] = [];
Object.entries(levelFolderMap).forEach(([parentId, levelData]) => {
if (parentId === "__rootFolderId") return;
const key = parentId === "null" ? null : parentId;
if (key && levelData.hasMore) {
const showMoreButtonNode = createShowMoreNode({
parentId: key,
onClick: () => showMoreFolders(key),
remaining: levelData.folders.length - levelData.visibleCount,
subject
});
addMoreButtons.push(showMoreButtonNode);
folderEdges.push(
createBaseEdge({
source: key,
target: showMoreButtonNode.id,
access: PermissionAccess.Partial
})
);
}
});
const init = positionElements([roleNode, ...folderNodes, ...addMoreButtons], [...folderEdges]);
setNodes(init.nodes);
setEdges(init.edges);
}, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]);
}, [
levelFolderMap,
permissions,
environmentsFolders,
environment,
subject,
secretName,
setNodes,
setEdges
]);
return {
nodes,
@@ -86,6 +281,11 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
secretName,
setSecretName,
viewMode,
setViewMode
setViewMode,
levelFolderMap,
showMoreFolders,
levelsWithMoreFolders,
getLevelCounts,
totalFolderCount
};
};
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from "react";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
type AccessTreeSecretPathInputProps = {
placeholder: string;
environment: string;
value: string;
onChange: (path: string) => void;
};
export const AccessTreeSecretPathInput = ({
placeholder,
environment,
value,
onChange
}: AccessTreeSecretPathInputProps) => {
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
const timeout: NodeJS.Timeout = setTimeout(() => {
setIsFocused(false);
}, 200);
return () => clearTimeout(timeout);
};
useEffect(() => {
if (!isFocused) {
setIsExpanded(false);
}
}, [isFocused]);
const focusInput = () => {
const inputElement = inputRef.current?.querySelector("input");
if (inputElement) {
inputElement.focus();
}
};
const toggleSearch = () => {
setIsExpanded(!isExpanded);
if (!isExpanded) {
const timeout: NodeJS.Timeout = setTimeout(focusInput, 300);
return () => clearTimeout(timeout);
}
return () => {};
};
return (
<div ref={wrapperRef} className="relative">
<div
className={twMerge(
"flex items-center overflow-hidden rounded transition-all duration-300 ease-in-out",
isFocused ? "bg-mineshaft-800 shadow-md" : "bg-mineshaft-700",
isExpanded ? "w-64" : "h-10 w-10"
)}
>
{isExpanded ? (
<div
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
onClick={toggleSearch}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleSearch();
}
}}
>
<FontAwesomeIcon icon={faSearch} />
</div>
) : (
<Tooltip position="bottom" content="Search paths">
<div
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
onClick={toggleSearch}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleSearch();
}
}}
>
<FontAwesomeIcon icon={faSearch} />
</div>
</Tooltip>
)}
<div
ref={inputRef}
className={twMerge(
"flex-1 transition-opacity duration-300",
isExpanded ? "opacity-100" : "hidden"
)}
onFocus={handleFocus}
onBlur={handleBlur}
role="search"
>
<div className="custom-input-wrapper">
<SecretPathInput
placeholder={placeholder}
environment={environment}
value={value}
onChange={onChange}
/>
</div>
</div>
</div>
</div>
);
};
@@ -1,10 +1,42 @@
import { Dispatch, SetStateAction } from "react";
import { faFileImport, faFolder, faKey, faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types";
import { createRoleNode } from "../utils";
const getSubjectIcon = (subject: ProjectPermissionSub) => {
switch (subject) {
case ProjectPermissionSub.Secrets:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.SecretFolders:
return <FontAwesomeIcon icon={faFolder} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.DynamicSecrets:
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.SecretImports:
return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-yellow-700" />;
default:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
}
};
const formatLabel = (text: string) => {
return text.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
};
export const RoleNode = ({
data: { subject, environment }
}: NodeProps & { data: ReturnType<typeof createRoleNode>["data"] }) => {
data: { subject, environment, onSubjectChange, onEnvironmentChange, environments }
}: NodeProps & {
data: ReturnType<typeof createRoleNode>["data"] & {
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
onEnvironmentChange: (value: string) => void;
environments: TProjectEnvironmentsFolders;
};
}) => {
return (
<>
<Handle
@@ -12,11 +44,60 @@ export const RoleNode = ({
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-3 py-2 font-inter shadow-lg">
<div className="flex max-w-[14rem] flex-col items-center text-xs text-mineshaft-200">
<span className="capitalize">{subject.replace("-", " ")} Access</span>
<div className="max-w-[14rem] whitespace-nowrap text-xs text-mineshaft-300">
<p className="truncate capitalize">{environment}</p>
<div className="flex w-full flex-col items-center justify-center rounded-md border-2 border-mineshaft-500 bg-gradient-to-b from-mineshaft-700 to-mineshaft-800 px-5 py-4 font-inter shadow-2xl">
<div className="flex w-full min-w-[240px] flex-col gap-4">
<div className="flex w-full flex-col gap-1.5">
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Subject</div>
<Select
value={subject}
onValueChange={(value) => onSubjectChange(value as ProjectPermissionSub)}
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
position="popper"
dropdownContainerClassName="max-w-none"
aria-label="Subject"
>
{[
ProjectPermissionSub.Secrets,
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.DynamicSecrets,
ProjectPermissionSub.SecretImports
].map((sub) => {
return (
<SelectItem
className="relative flex items-center gap-2 py-2 pl-8 pr-8 text-sm capitalize hover:bg-mineshaft-700"
value={sub}
key={sub}
>
<div className="flex items-center gap-3">
{getSubjectIcon(sub)}
<span className="font-medium">{formatLabel(sub)}</span>
</div>
</SelectItem>
);
})}
</Select>
</div>
<div className="flex w-full flex-col gap-1.5">
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Environment</div>
<Select
value={environment}
onValueChange={onEnvironmentChange}
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
position="popper"
dropdownContainerClassName="max-w-none"
aria-label="Environment"
>
{Object.values(environments).map((env) => (
<SelectItem
key={env.slug}
value={env.slug}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{env.name}</div>
</SelectItem>
))}
</Select>
</div>
</div>
</div>
@@ -0,0 +1,37 @@
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { Button, Tooltip } from "@app/components/v2";
import { createShowMoreNode } from "../utils/createShowMoreNode";
export const ShowMoreButtonNode = ({
data: { onClick, remaining }
}: NodeProps & { data: ReturnType<typeof createShowMoreNode>["data"] }) => {
const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`;
return (
<div className="flex h-full w-full items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-2">
<Handle
type="target"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<div className="flex items-center justify-center">
<Tooltip position="right" content={tooltipText}>
<Button
colorSchema="secondary"
variant="plain"
size="xs"
onClick={onClick}
rightIcon={<FontAwesomeIcon icon={faChevronRight} className="ml-1" />}
>
Show More
</Button>
</Tooltip>
</div>
</div>
);
};
@@ -7,7 +7,8 @@ export enum PermissionAccess {
export enum PermissionNode {
Role = "role",
Folder = "folder",
Environment = "environment"
Environment = "environment",
ShowMoreButton = "showMoreButton"
}
export enum PermissionEdge {
@@ -5,11 +5,13 @@ import { PermissionAccess, PermissionEdge } from "../types";
export const createBaseEdge = ({
source,
target,
access
access,
hideEdge = false
}: {
source: string;
target: string;
access: PermissionAccess;
hideEdge?: boolean;
}) => {
const color = access === PermissionAccess.None ? "#707174" : "#ccccce";
return {
@@ -17,10 +19,12 @@ export const createBaseEdge = ({
source,
target,
type: PermissionEdge.Base,
markerEnd: {
type: MarkerType.ArrowClosed,
color
},
style: { stroke: color }
markerEnd: hideEdge
? undefined
: {
type: MarkerType.ArrowClosed,
color
},
style: { stroke: hideEdge ? "transparent" : color }
};
};
@@ -1,17 +1,31 @@
import { Dispatch, SetStateAction } from "react";
import { ProjectPermissionSub } from "@app/context";
import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types";
import { PermissionNode } from "../types";
export const createRoleNode = ({
subject,
environment
environment,
environments,
onSubjectChange,
onEnvironmentChange
}: {
subject: string;
environment: string;
environments: TProjectEnvironmentsFolders;
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
onEnvironmentChange: (value: string) => void;
}) => ({
id: `role-${subject}-${environment}`,
position: { x: 0, y: 0 },
data: {
subject,
environment
environment,
environments,
onSubjectChange,
onEnvironmentChange
},
type: PermissionNode.Role,
height: 48,
@@ -0,0 +1,45 @@
import { ProjectPermissionSub } from "@app/context";
import { PermissionNode } from "../types";
export const createShowMoreNode = ({
parentId,
onClick,
remaining,
subject
}: {
parentId: string | null;
onClick: () => void;
remaining: number;
subject: ProjectPermissionSub;
}) => {
let height: number;
switch (subject) {
case ProjectPermissionSub.DynamicSecrets:
height = 130;
break;
case ProjectPermissionSub.Secrets:
height = 85;
break;
default:
height = 64;
}
const id = `show-more-${parentId || "root"}`;
return {
id,
type: PermissionNode.ShowMoreButton,
position: { x: 0, y: 0 },
data: {
parentId,
onClick,
remaining
},
width: 150,
height,
style: {
background: "transparent",
border: "none"
}
};
};
@@ -2,27 +2,96 @@ import Dagre from "@dagrejs/dagre";
import { Edge, Node } from "@xyflow/react";
export const positionElements = (nodes: Node[], edges: Edge[]) => {
const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton");
const showMoreParentIds = new Set(
showMoreNodes.map((node) => node.data.parentId).filter(Boolean)
);
const nodeMap: Record<string, Node> = {};
const childrenMap: Record<string, string[]> = {};
edges.forEach((edge) => {
if (!childrenMap[edge.source]) {
childrenMap[edge.source] = [];
}
childrenMap[edge.source].push(edge.target);
});
const dagre = new Dagre.graphlib.Graph({ directed: true })
.setDefaultEdgeLabel(() => ({}))
.setGraph({ rankdir: "TB" });
.setGraph({
rankdir: "TB",
nodesep: 50,
ranksep: 70
});
nodes.forEach((node) => {
dagre.setNode(node.id, {
width: node.width || 150,
height: node.height || 40
});
});
edges.forEach((edge) => dagre.setEdge(edge.source, edge.target));
nodes.forEach((node) => dagre.setNode(node.id, node));
Dagre.layout(dagre, {});
return {
nodes: nodes.map((node) => {
const { x, y } = dagre.node(node.id);
const positionedNodes = nodes.map((node) => {
const { x, y } = dagre.node(node.id);
if (node.type === "role") {
return {
...node,
position: {
x: x - (node.width ? node.width / 2 : 0),
y: y - (node.height ? node.height / 2 : 0)
y: y - 150
}
};
}),
}
return {
...node,
position: {
x: x - (node.width ? node.width / 2 : 0),
y: y - (node.height ? node.height / 2 : 0)
},
style: node.type === "showMoreButton" ? { ...node.style, zIndex: 10 } : node.style
};
});
positionedNodes.forEach((node) => {
nodeMap[node.id] = node;
});
Array.from(showMoreParentIds).forEach((parentId) => {
const showMoreNodeIndex = positionedNodes.findIndex(
(node) => node.type === "showMoreButton" && node.data.parentId === parentId
);
if (showMoreNodeIndex !== -1) {
const siblings = positionedNodes.filter(
(node) => node.data?.parentId === parentId && node.type !== "showMoreButton"
);
if (siblings.length > 0) {
const rightmostSibling = siblings.reduce(
(rightmost, current) => (current.position.x > rightmost.position.x ? current : rightmost),
siblings[0]
);
positionedNodes[showMoreNodeIndex] = {
...positionedNodes[showMoreNodeIndex],
position: {
x: rightmostSibling.position.x + (rightmostSibling.width || 150) + 30,
y: rightmostSibling.position.y
}
};
}
}
});
return {
nodes: positionedNodes,
edges
};
};
@@ -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";
+2
View File
@@ -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",
+2 -2
View File
@@ -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;
+9 -5
View File
@@ -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;
+1
View File
@@ -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>;
};
@@ -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
}}
@@ -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>
) : (
@@ -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>
)}
/>
@@ -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>
</>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
)}
/>
@@ -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}
@@ -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">