mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
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:
@@ -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) +
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
"{(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key}"
|
||||
</span>{" "}
|
||||
to perform this action
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SecretDetailSidebar
|
||||
environment={environment}
|
||||
|
||||
Reference in New Issue
Block a user