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

@@ -142,6 +142,34 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
})
.array()
.optional(),
importedByEnvs: z
.object({
environment: z.string(),
importedBy: z
.object({
environment: z.object({
name: z.string(),
slug: z.string()
}),
folders: z
.object({
name: z.string(),
isImported: z.boolean(),
secrets: z
.object({
secretId: z.string(),
referencedSecretKey: z.string()
})
.array()
.optional()
})
.array()
})
.array()
.optional()
})
.array()
.optional(),
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
@@ -471,6 +499,28 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
const importedByEnvs = [];
for await (const environment of environments) {
const importedBy = await server.services.secretImport.getFolderIsImportedBy({
path: secretPath,
environment,
projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
secrets: secrets?.filter((s) => s.environment === environment)
});
if (importedBy) {
importedByEnvs.push({
environment,
importedBy
});
}
}
return {
folders,
dynamicSecrets,
@@ -482,6 +532,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalImportCount,
totalSecretCount,
totalSecretRotationCount,
importedByEnvs,
totalCount:
(totalFolderCount ?? 0) +
(totalDynamicSecretCount ?? 0) +
@@ -575,6 +626,28 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
importedBy: z
.object({
environment: z.object({
name: z.string(),
slug: z.string()
}),
folders: z
.object({
name: z.string(),
isImported: z.boolean(),
secrets: z
.object({
secretId: z.string(),
referencedSecretKey: z.string()
})
.array()
.optional()
})
.array()
})
.array()
.optional(),
totalSecretRotationCount: z.number().optional(),
totalCount: z.number()
})
@@ -835,6 +908,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
const importedBy = await server.services.secretImport.getFolderIsImportedBy({
path: secretPath,
environment,
projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
secrets
});
if (secrets?.length || secretRotations?.length) {
const secretCount =
(secrets?.length ?? 0) +
@@ -880,6 +964,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalDynamicSecretCount,
totalSecretCount,
totalSecretRotationCount,
importedBy,
totalCount:
(totalImportCount ?? 0) +
(totalFolderCount ?? 0) +

View File

@@ -5,6 +5,8 @@ import { TableName, TSecretImports } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { EnvironmentInfo, FolderInfo, FolderResult, SecretResult } from "./secret-import-types";
export type TSecretImportDALFactory = ReturnType<typeof secretImportDALFactory>;
export const secretImportDALFactory = (db: TDbClient) => {
@@ -169,6 +171,136 @@ export const secretImportDALFactory = (db: TDbClient) => {
}
};
const getFolderIsImportedBy = async (
secretPath: string,
environmentId: string,
environment: string,
projectId: string,
tx?: Knex
) => {
try {
const folderImports = await (tx || db.replicaNode())(TableName.SecretImport)
.where({ importPath: secretPath, importEnv: environmentId })
.join(TableName.SecretFolder, `${TableName.SecretImport}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.select(
db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
db.ref("name").withSchema(TableName.SecretFolder).as("folderName"),
db.ref("id").withSchema(TableName.SecretFolder).as("folderId")
);
const secretReferences = await (tx || db.replicaNode())(TableName.SecretReferenceV2)
.where({ secretPath, environment })
.join(TableName.SecretV2, `${TableName.SecretReferenceV2}.secretId`, `${TableName.SecretV2}.id`)
.join(TableName.SecretFolder, `${TableName.SecretV2}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.where(`${TableName.Environment}.projectId`, projectId)
.where(`${TableName.SecretFolder}.isReserved`, false)
.select(
db.ref("key").withSchema(TableName.SecretV2).as("secretId"),
db.ref("name").withSchema(TableName.SecretFolder).as("folderName"),
db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
db.ref("id").withSchema(TableName.SecretFolder).as("folderId"),
db.ref("secretKey").withSchema(TableName.SecretReferenceV2).as("referencedSecretKey")
);
const folderResults = folderImports.map(({ envName, envSlug, folderName, folderId }) => ({
envName,
envSlug,
folderName,
folderId
}));
const secretResults = secretReferences.map(
({ envName, envSlug, secretId, folderName, folderId, referencedSecretKey }) => ({
envName,
envSlug,
secretId,
folderName,
folderId,
referencedSecretKey
})
);
type ResultItem = FolderResult | SecretResult;
const allResults: ResultItem[] = [...folderResults, ...secretResults];
type EnvFolderMap = {
[envName: string]: {
envSlug: string;
folders: {
[folderName: string]: {
secrets: {
secretId: string;
referencedSecretKey: string;
}[];
folderId: string;
folderImported: boolean;
};
};
};
};
const groupedByEnv = allResults.reduce<EnvFolderMap>((acc, item) => {
const env = item.envName;
const folder = item.folderName;
const { envSlug } = item;
const updatedAcc = { ...acc };
if (!updatedAcc[env]) {
updatedAcc[env] = {
envSlug,
folders: {}
};
}
if (!updatedAcc[env].folders[folder]) {
updatedAcc[env].folders[folder] = { secrets: [], folderId: item.folderId, folderImported: false };
}
if ("secretId" in item && item.secretId) {
updatedAcc[env].folders[folder].secrets = [
...updatedAcc[env].folders[folder].secrets,
{ secretId: item.secretId, referencedSecretKey: item.referencedSecretKey }
];
} else {
updatedAcc[env].folders[folder].folderImported = true;
}
return updatedAcc;
}, {});
const formattedResult: EnvironmentInfo[] = Object.keys(groupedByEnv).map((envName) => {
const envData = groupedByEnv[envName];
const folders: FolderInfo[] = Object.keys(envData.folders).map((folderName) => {
const folderData = envData.folders[folderName];
const hasSecrets = folderData.secrets.length > 0;
return {
folderName,
folderId: folderData.folderId,
folderImported: folderData.folderImported,
...(hasSecrets && { secrets: folderData.secrets })
};
});
return {
envName,
envSlug: envData.envSlug,
folders
};
});
return formattedResult;
} catch (error) {
throw new DatabaseError({ error, name: "GetSecretImportsAndReferences" });
}
};
return {
...secretImportOrm,
find,
@@ -176,6 +308,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
findByFolderIds,
findLastImportPosition,
updateAllPosition,
getProjectImportCount
getProjectImportCount,
getFolderIsImportedBy
};
};

View File

@@ -27,6 +27,7 @@ import { decryptSecretRaw } from "../secret/secret-fns";
import { TSecretQueueFactory } from "../secret/secret-queue";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { recursivelyGetSecretPaths } from "../secret-v2-bridge/secret-v2-bridge-fns";
import { TSecretImportDALFactory } from "./secret-import-dal";
import { fnSecretsFromImports, fnSecretsV2FromImports } from "./secret-import-fns";
import {
@@ -798,6 +799,139 @@ export const secretImportServiceFactory = ({
return secImportsArrays.flat();
};
const getFolderIsImportedBy = async ({
path: secretPath,
environment,
projectId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
secrets
}: TGetSecretImportsDTO & {
secrets: { secretKey: string; secretValue: string }[] | undefined;
}) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath })
);
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder)
throw new NotFoundError({
message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`
});
const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId, environment, projectId);
const deepPaths: { path: string; folderId: string }[] = [];
await Promise.all(
importedBy.map(async (el) => {
const envDeepPaths = await recursivelyGetSecretPaths({
folderDAL,
projectEnvDAL,
projectId,
environment: el.envSlug,
currentPath: "/"
});
deepPaths.push(...envDeepPaths);
})
);
const result = importedBy.map((el) => ({
environment: {
name: el.envName,
slug: el.envSlug
},
folders: el.folders.map((folderItem) => ({
folderId: folderItem.folderId,
isImported: folderItem.folderImported,
secrets: folderItem.secrets,
name: deepPaths.find((p) => p.folderId === folderItem.folderId)?.path || `...${folderItem.folderName}`
}))
}));
// Special case for same folder references as these do not have an entry on the references table
const locallyReferenced =
secrets
?.filter((secret) => {
return secrets.some(
(otherSecret) =>
otherSecret.secretKey !== secret.secretKey && secret.secretValue.includes(`\${${otherSecret.secretKey}}`)
);
})
.flatMap((secret) => {
return secrets
.filter(
(otherSecret) =>
otherSecret.secretKey !== secret.secretKey &&
secret.secretValue.includes(`\${${otherSecret.secretKey}}`)
)
.map((otherSecret) => ({
secretId: secret.secretKey,
referencedSecretKey: otherSecret.secretKey
}));
}) || [];
if (locallyReferenced.length > 0) {
const existingEnvIndex = result.findIndex((item) => item.environment.slug === environment);
if (existingEnvIndex >= 0) {
const existingFolderIndex = result[existingEnvIndex].folders.findIndex(
(folderItem) => folderItem.name === secretPath
);
if (existingFolderIndex >= 0) {
if (!result[existingEnvIndex].folders[existingFolderIndex].secrets) {
result[existingEnvIndex].folders[existingFolderIndex].secrets = [];
}
const existingSecrets = result[existingEnvIndex].folders[existingFolderIndex].secrets || [];
locallyReferenced.forEach((ref) => {
if (
!existingSecrets.some(
(s) => s.secretId === ref.secretId && s.referencedSecretKey === ref.referencedSecretKey
)
) {
existingSecrets.push(ref);
}
});
} else {
result[existingEnvIndex].folders.push({
folderId: folder.id,
isImported: false,
secrets: locallyReferenced,
name: secretPath
});
}
} else {
result.push({
environment: {
slug: environment,
name: environment
},
folders: [
{
folderId: folder.id,
isImported: false,
secrets: locallyReferenced,
name: secretPath
}
]
});
}
}
return result;
};
return {
createImport,
updateImport,
@@ -810,6 +944,7 @@ export const secretImportServiceFactory = ({
getProjectImportCount,
fnSecretsFromImports,
getProjectImportMultiEnvCount,
getImportsMultiEnv
getImportsMultiEnv,
getFolderIsImportedBy
};
};

View File

@@ -45,3 +45,29 @@ export type TGetSecretsFromImportDTO = {
environment: string;
path: string;
} & TProjectPermission;
export type FolderResult = {
envName: string;
folderName: string;
folderId: string;
envSlug: string;
};
export type SecretResult = {
secretId: string;
referencedSecretKey: string;
} & FolderResult;
export type FolderInfo = {
folderName: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
folderId: string;
folderImported: boolean;
envSlug?: string;
};
export type EnvironmentInfo = {
envName: string;
envSlug: string;
folders: FolderInfo[];
};

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}