Merge pull request #3356 from Infisical/feat/showWarningOnImportedSecretDeletion

Add warning on secret deletions where it's being imported by another folder
This commit is contained in:
carlosmonastyrski
2025-04-14 17:37:16 -03:00
committed by GitHub
13 changed files with 775 additions and 27 deletions

View File

@@ -16,7 +16,9 @@ type Props = {
subTitle?: string;
onDeleteApproved: () => Promise<void>;
buttonText?: string;
formContent?: ReactNode;
children?: ReactNode;
deletionMessage?: ReactNode;
};
export const DeleteActionModal = ({
@@ -28,6 +30,8 @@ export const DeleteActionModal = ({
title,
subTitle = "This action is irreversible.",
buttonText = "Delete",
formContent,
deletionMessage,
children
}: Props): JSX.Element => {
const [inputData, setInputData] = useState("");
@@ -79,6 +83,7 @@ export const DeleteActionModal = ({
}
onClose={onClose}
>
{formContent}
<form
onSubmit={(evt) => {
evt.preventDefault();
@@ -88,7 +93,11 @@ export const DeleteActionModal = ({
<FormControl
label={
<div className="break-words pb-2 text-sm">
Type <span className="font-bold">{deleteKey}</span> to perform this action
{deletionMessage || (
<>
Type <span className="font-bold">{deleteKey}</span> to perform this action
</>
)}
</div>
}
className="mb-0"

View File

@@ -24,6 +24,7 @@ export type DashboardProjectSecretsOverviewResponse = {
totalUniqueDynamicSecretsInPage: number;
totalUniqueFoldersInPage: number;
totalUniqueSecretImportsInPage: number;
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[];
totalUniqueSecretRotationsInPage: number;
};
@@ -41,6 +42,16 @@ export type DashboardProjectSecretsDetailsResponse = {
totalSecretCount?: number;
totalSecretRotationCount?: number;
totalCount: number;
importedBy?: ProjectSecretsImportedBy[];
};
export type ProjectSecretsImportedBy = {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
isImported: boolean;
}[];
};
export type DashboardProjectSecretsByKeys = {

View File

@@ -272,7 +272,8 @@ export const OverviewPage = () => {
totalUniqueSecretsInPage,
totalUniqueSecretImportsInPage,
totalUniqueDynamicSecretsInPage,
totalUniqueSecretRotationsInPage
totalUniqueSecretRotationsInPage,
importedByEnvs
} = overview ?? {};
const secretImportsShaped = secretImports
@@ -1042,6 +1043,7 @@ export const OverviewPage = () => {
secretPath={secretPath}
selectedEntries={selectedEntries}
resetSelectedEntries={resetSelectedEntries}
importedByEnvs={importedByEnvs}
/>
<div className="thin-scrollbar mt-4">
<TableContainer

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { subject } from "@casl/ability";
import { faAnglesRight, faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -14,6 +15,7 @@ import {
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
import { ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types";
import {
SecretType,
SecretV3RawSanitized,
@@ -21,6 +23,7 @@ import {
TSecretFolder
} from "@app/hooks/api/types";
import { MoveSecretsModal } from "@app/pages/secret-manager/OverviewPage/components/SelectionPanel/components";
import { CollapsibleSecretImports } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports";
export enum EntryType {
FOLDER = "folder",
@@ -34,9 +37,15 @@ type Props = {
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
};
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[];
};
export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => {
export const SelectionPanel = ({
secretPath,
resetSelectedEntries,
selectedEntries,
importedByEnvs
}: Props) => {
const { permission } = useProjectPermission();
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
@@ -72,6 +81,81 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
)
);
const secretsToDeleteKeys = useMemo(() => {
return Object.values(selectedEntries.secret).flatMap((entries) =>
Object.values(entries).map((secret) => secret.key)
);
}, [selectedEntries]);
const filterAndMergeEnvironments = (
envNames: string[],
envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]
): ProjectSecretsImportedBy[] => {
const filteredEnvs = envs.filter((env) => envNames.includes(env.environment));
if (filteredEnvs.length === 0) return [];
const allImportedBy = filteredEnvs.flatMap((env) => env.importedBy);
const groupedBySlug: Record<string, ProjectSecretsImportedBy[]> = {};
allImportedBy.forEach((item) => {
const { slug } = item.environment;
if (!groupedBySlug[slug]) groupedBySlug[slug] = [];
groupedBySlug[slug].push(item);
});
const mergedImportedBy = Object.values(groupedBySlug).map((group) => {
const { environment } = group[0];
const allFolders = group.flatMap((item) => item.folders);
const foldersByName: Record<string, (typeof allFolders)[number][]> = {};
allFolders.forEach((folder) => {
if (!foldersByName[folder.name]) foldersByName[folder.name] = [];
foldersByName[folder.name].push(folder);
});
const mergedFolders = Object.entries(foldersByName).map(([name, folders]) => {
const isImported = folders.some((folder) => folder.isImported);
const allSecrets = folders.flatMap((folder) => folder.secrets || []);
const uniqueSecrets: { secretId: string; referencedSecretKey: string }[] = [];
const secretIds = new Set<string>();
allSecrets
.filter((secret) => secretsToDeleteKeys.includes(secret.referencedSecretKey))
.forEach((secret) => {
if (!secretIds.has(secret.secretId)) {
secretIds.add(secret.secretId);
uniqueSecrets.push(secret);
}
});
return {
name,
isImported,
...(uniqueSecrets.length > 0 ? { secrets: uniqueSecrets } : {})
};
});
return {
environment,
folders: mergedFolders.filter(
(folder) => folder.isImported || (folder.secrets && folder.secrets.length > 0)
)
};
});
return mergedImportedBy;
};
const importedBy = useMemo(() => {
if (selectedKeysCount === 0 || !importedByEnvs) return null;
return filterAndMergeEnvironments(
Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)),
importedByEnvs
);
}, [importedByEnvs, selectedEntries, selectedKeysCount]);
const getDeleteModalTitle = () => {
if (selectedFolderCount > 0 && selectedKeysCount > 0) {
return "Do you want to delete the selected secrets and folders across environments?";
@@ -241,6 +325,15 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
title={getDeleteModalTitle()}
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
onDeleteApproved={handleBulkDelete}
formContent={
importedBy &&
importedBy.some((element) => element.folders.length > 0) && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={secretsToDeleteKeys}
/>
)
}
/>
</>
);

View File

@@ -219,6 +219,7 @@ const Page = () => {
totalDynamicSecretCount = 0,
totalSecretCount = 0,
totalCount = 0,
importedBy,
totalSecretRotationCount = 0
} = data ?? {};
@@ -439,6 +440,7 @@ const Page = () => {
onToggleRowType={handleToggleRowType}
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
protectedBranchPolicyName={boardPolicy?.name}
importedBy={importedBy}
/>
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
<div className="flex flex-col" id="dashboard">
@@ -527,6 +529,7 @@ const Page = () => {
workspaceId={workspaceId}
secretPath={secretPath}
isProtectedBranch={isProtectedBranch}
importedBy={importedBy}
/>
)}
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />}

View File

@@ -81,6 +81,7 @@ import {
useSelectedSecrets
} from "../../SecretMainPage.store";
import { Filter, RowType } from "../../SecretMainPage.types";
import { CollapsibleSecretImports } from "../SecretListView/CollapsibleSecretImports";
import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard";
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm";
@@ -112,6 +113,14 @@ type Props = {
onVisibilityToggle: () => void;
onToggleRowType: (rowType: RowType) => void;
onClickRollbackMode: () => void;
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
isImported: boolean;
}[];
}[];
};
export const ActionBar = ({
@@ -129,7 +138,8 @@ export const ActionBar = ({
onVisibilityToggle,
onClickRollbackMode,
onToggleRowType,
protectedBranchPolicyName
protectedBranchPolicyName,
importedBy
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder",
@@ -1059,6 +1069,15 @@ export const ActionBar = ({
title="Do you want to delete these secrets?"
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
onDeleteApproved={handleSecretBulkDelete}
formContent={
importedBy &&
importedBy.length > 0 && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={Object.values(selectedSecrets).map((s) => s.key)}
/>
)
}
/>
<MoveSecretsModal
popUp={popUp}

View File

@@ -41,6 +41,7 @@ interface FolderProps {
isExpanded?: boolean;
level: number;
basePath?: string;
isDisabled?: boolean;
}
interface TreeViewProps {
@@ -48,6 +49,7 @@ interface TreeViewProps {
basePath?: string;
className?: string;
onChange: (items: SecretItem[]) => void;
isDisabled?: boolean;
}
const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => {
@@ -81,7 +83,8 @@ const Folder: React.FC<FolderProps> = ({
onFolderSelect,
isExpanded = false,
level,
basePath
basePath,
isDisabled = false
}) => {
const [open, setOpen] = useState(isExpanded);
const displayName = useMemo(() => getDisplayName(name), [name]);
@@ -127,13 +130,16 @@ const Folder: React.FC<FolderProps> = ({
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}
/>
{!isDisabled && (
<Checkbox
id="folder-root"
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={allSelected || someSelected}
onCheckedChange={handleFolderSelect}
isIndeterminate={someSelected && !allSelected}
isDisabled={isDisabled}
/>
)}
<label
htmlFor={`folder-${path}`}
@@ -158,12 +164,15 @@ const Folder: React.FC<FolderProps> = ({
<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)}
/>
{!isDisabled && (
<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)}
isDisabled={isDisabled}
/>
)}
<label
htmlFor={item.id}
className="ml-2 flex-1 cursor-pointer truncate"
@@ -184,6 +193,7 @@ const Folder: React.FC<FolderProps> = ({
onItemSelect={onItemSelect}
onFolderSelect={onFolderSelect}
level={level + 1}
isDisabled={isDisabled}
/>
))}
</div>
@@ -197,7 +207,8 @@ export const SecretTreeView: React.FC<TreeViewProps> = ({
data,
basePath = "/",
className = "",
onChange
onChange,
isDisabled = false
}) => {
const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]);
const rootPath = "/";
@@ -305,16 +316,19 @@ export const SecretTreeView: React.FC<TreeViewProps> = ({
onFolderSelect={handleFolderSelect}
isExpanded
level={0}
isDisabled={isDisabled}
/>
))
)}
</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>
{!isDisabled && (
<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>
);

View File

@@ -0,0 +1,190 @@
import React, { useMemo } from "react";
import { faFileImport, faKey, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Table, TBody, Td, Th, THead, Tr } from "@app/components/v2";
import { useWorkspace } from "@app/context";
enum ItemType {
Folder = "Folder",
Secret = "Secret"
}
interface FlatItem {
type: ItemType;
path: string;
secretKey?: string;
reference: string;
id: string;
environment: { name: string; slug: string };
}
interface CollapsibleSecretImportsProps {
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
isImported: boolean;
}[];
}[];
secretsToDelete: string[];
}
export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> = ({
importedBy = [],
secretsToDelete
}) => {
const { currentWorkspace } = useWorkspace();
const truncatePath = (path: string, maxLength = 24): string => {
if (path.length <= maxLength) return path;
const endPortion = path.slice(-(maxLength - 3));
const slashIndex = endPortion.indexOf("/");
if (slashIndex !== -1) {
return `...${endPortion.slice(slashIndex)}`;
}
return `...${endPortion}`;
};
const handlePathClick = (item: FlatItem) => {
let pathToNavigate;
if (item.type === ItemType.Folder) {
pathToNavigate = item.path;
} else {
const lastSlashIndex = item.path.lastIndexOf("/");
pathToNavigate = lastSlashIndex > 0 ? item.path.substring(0, lastSlashIndex) : "/";
}
const encodedPath = encodeURIComponent(pathToNavigate);
window.open(
`/secret-manager/${currentWorkspace.id}/secrets/${item.environment.slug}?secretPath=${encodedPath}`,
"_blank"
);
};
const flattenedItems = useMemo(() => {
const items: FlatItem[] = [];
importedBy.forEach((env) => {
env.folders.forEach((folder) => {
if (folder.isImported) {
items.push({
type: ItemType.Folder,
path: folder.name,
id: `folder-${env.environment.name}-${folder.name}`,
reference: "Imported",
environment: env.environment
});
}
if (folder.secrets?.length) {
folder.secrets.forEach(({ secretId: secret, referencedSecretKey }) => {
const secretPath = folder.name === "/" ? `/${secret}` : `${folder.name}/${secret}`;
if (
secretsToDelete.includes(referencedSecretKey) &&
!items.some(
(item) => item.environment.name === env.environment.name && item.path === secretPath
)
) {
items.push({
type: ItemType.Secret,
path: secretPath,
secretKey: referencedSecretKey,
id: `secret-${env.environment.name}-${secretPath}`,
reference: "Referenced",
environment: env.environment
});
}
});
}
});
});
return items.sort((a, b) => {
const envCompare = a.environment.name.localeCompare(b.environment.name);
if (envCompare !== 0) return envCompare;
const aPath = a.path.startsWith("/") ? a.path : `/${a.path}`;
const bPath = b.path.startsWith("/") ? b.path : `/${b.path}`;
const aSegments = aPath.split("/").filter(Boolean).length;
const bSegments = bPath.split("/").filter(Boolean).length;
if (aSegments !== bSegments) {
return aSegments - bSegments;
}
return aPath.localeCompare(bPath);
});
}, [importedBy]);
const hasImportedItems = importedBy.some((element) => {
if (element.folders && element.folders.length > 0) {
return element.folders.some(
(folder) =>
folder.isImported ||
(folder.secrets &&
folder.secrets.length > 0 &&
folder.secrets.some((secret) => secretsToDelete.includes(secret.referencedSecretKey)))
);
}
return false;
});
if (!hasImportedItems) {
return null;
}
return (
<div className="mb-4 w-full">
<div className="mb-4 rounded-md border border-red-700/30 bg-red-900/20">
<div className="flex items-start gap-3 p-4">
<div className="mt-0.5 flex-shrink-0 text-red-500">
<FontAwesomeIcon icon={faWarning} className="h-5 w-5" aria-hidden="true" />
</div>
<div className="w-full">
<p className="text-sm font-semibold text-red-500">
The following resources will be affected by this change
</p>
</div>
</div>
</div>
<div className="max-h-64 overflow-y-auto rounded-md border border-mineshaft-700">
<Table>
<THead className="sticky -top-1 bg-bunker-800">
<Th className="px-4">Type</Th>
<Th className="px-4">Environment</Th>
<Th className="truncate px-4">Path</Th>
<Th className="px-4">Usage</Th>
</THead>
<TBody>
{flattenedItems.map((item) => (
<Tr
key={item.id}
onClick={() => handlePathClick(item)}
className="cursor-pointer hover:bg-mineshaft-700"
title={`Navigate to ${item.path}`}
>
<Td>
<FontAwesomeIcon
icon={item.type === ItemType.Secret ? faKey : faFileImport}
className={`h-4 w-4 ${item.type === ItemType.Secret ? "text-gray-400" : "text-green-700"}`}
aria-hidden="true"
/>
</Td>
<Td className="px-4">{item.environment.name}</Td>
<Td className="truncate px-4">{truncatePath(item.path)}</Td>
<Td className="px-4">{item.reference}</Td>
</Tr>
))}
</TBody>
</Table>
</div>
</div>
);
};

View File

@@ -16,6 +16,7 @@ import { WsTag } from "@app/hooks/api/types";
import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal";
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
import { SecretDetailSidebar } from "./SecretDetailSidebar";
import { SecretItem } from "./SecretItem";
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
@@ -28,6 +29,14 @@ type Props = {
tags?: WsTag[];
isVisible?: boolean;
isProtectedBranch?: boolean;
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
isImported: boolean;
}[];
}[];
};
export const SecretListView = ({
@@ -37,7 +46,8 @@ export const SecretListView = ({
secretPath = "/",
tags: wsTags = [],
isVisible,
isProtectedBranch = false
isProtectedBranch = false,
importedBy
}: Props) => {
const queryClient = useQueryClient();
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
@@ -357,6 +367,24 @@ export const SecretListView = ({
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
onDeleteApproved={handleSecretDelete}
buttonText="Delete Secret"
formContent={
importedBy &&
importedBy.length > 0 && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={[(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key || ""]}
/>
)
}
deletionMessage={
<>
Type the secret key{" "}
<span className="font-bold">
&quot;{(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key}&quot;
</span>{" "}
to perform this action
</>
}
/>
<SecretDetailSidebar
environment={environment}