mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-03 15:26:06 +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()
|
.array()
|
||||||
.optional(),
|
.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(),
|
totalFolderCount: z.number().optional(),
|
||||||
totalDynamicSecretCount: z.number().optional(),
|
totalDynamicSecretCount: z.number().optional(),
|
||||||
totalSecretCount: 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 {
|
return {
|
||||||
folders,
|
folders,
|
||||||
dynamicSecrets,
|
dynamicSecrets,
|
||||||
@@ -482,6 +532,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
|||||||
totalImportCount,
|
totalImportCount,
|
||||||
totalSecretCount,
|
totalSecretCount,
|
||||||
totalSecretRotationCount,
|
totalSecretRotationCount,
|
||||||
|
importedByEnvs,
|
||||||
totalCount:
|
totalCount:
|
||||||
(totalFolderCount ?? 0) +
|
(totalFolderCount ?? 0) +
|
||||||
(totalDynamicSecretCount ?? 0) +
|
(totalDynamicSecretCount ?? 0) +
|
||||||
@@ -575,6 +626,28 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
|||||||
totalFolderCount: z.number().optional(),
|
totalFolderCount: z.number().optional(),
|
||||||
totalDynamicSecretCount: z.number().optional(),
|
totalDynamicSecretCount: z.number().optional(),
|
||||||
totalSecretCount: 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(),
|
totalSecretRotationCount: z.number().optional(),
|
||||||
totalCount: z.number()
|
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) {
|
if (secrets?.length || secretRotations?.length) {
|
||||||
const secretCount =
|
const secretCount =
|
||||||
(secrets?.length ?? 0) +
|
(secrets?.length ?? 0) +
|
||||||
@@ -880,6 +964,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
|||||||
totalDynamicSecretCount,
|
totalDynamicSecretCount,
|
||||||
totalSecretCount,
|
totalSecretCount,
|
||||||
totalSecretRotationCount,
|
totalSecretRotationCount,
|
||||||
|
importedBy,
|
||||||
totalCount:
|
totalCount:
|
||||||
(totalImportCount ?? 0) +
|
(totalImportCount ?? 0) +
|
||||||
(totalFolderCount ?? 0) +
|
(totalFolderCount ?? 0) +
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { TableName, TSecretImports } from "@app/db/schemas";
|
|||||||
import { DatabaseError } from "@app/lib/errors";
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
import { ormify } from "@app/lib/knex";
|
import { ormify } from "@app/lib/knex";
|
||||||
|
|
||||||
|
import { EnvironmentInfo, FolderInfo, FolderResult, SecretResult } from "./secret-import-types";
|
||||||
|
|
||||||
export type TSecretImportDALFactory = ReturnType<typeof secretImportDALFactory>;
|
export type TSecretImportDALFactory = ReturnType<typeof secretImportDALFactory>;
|
||||||
|
|
||||||
export const secretImportDALFactory = (db: TDbClient) => {
|
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 {
|
return {
|
||||||
...secretImportOrm,
|
...secretImportOrm,
|
||||||
find,
|
find,
|
||||||
@@ -176,6 +308,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
|
|||||||
findByFolderIds,
|
findByFolderIds,
|
||||||
findLastImportPosition,
|
findLastImportPosition,
|
||||||
updateAllPosition,
|
updateAllPosition,
|
||||||
getProjectImportCount
|
getProjectImportCount,
|
||||||
|
getFolderIsImportedBy
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { decryptSecretRaw } from "../secret/secret-fns";
|
|||||||
import { TSecretQueueFactory } from "../secret/secret-queue";
|
import { TSecretQueueFactory } from "../secret/secret-queue";
|
||||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-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 { TSecretImportDALFactory } from "./secret-import-dal";
|
||||||
import { fnSecretsFromImports, fnSecretsV2FromImports } from "./secret-import-fns";
|
import { fnSecretsFromImports, fnSecretsV2FromImports } from "./secret-import-fns";
|
||||||
import {
|
import {
|
||||||
@@ -798,6 +799,139 @@ export const secretImportServiceFactory = ({
|
|||||||
return secImportsArrays.flat();
|
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 {
|
return {
|
||||||
createImport,
|
createImport,
|
||||||
updateImport,
|
updateImport,
|
||||||
@@ -810,6 +944,7 @@ export const secretImportServiceFactory = ({
|
|||||||
getProjectImportCount,
|
getProjectImportCount,
|
||||||
fnSecretsFromImports,
|
fnSecretsFromImports,
|
||||||
getProjectImportMultiEnvCount,
|
getProjectImportMultiEnvCount,
|
||||||
getImportsMultiEnv
|
getImportsMultiEnv,
|
||||||
|
getFolderIsImportedBy
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,3 +45,29 @@ export type TGetSecretsFromImportDTO = {
|
|||||||
environment: string;
|
environment: string;
|
||||||
path: string;
|
path: string;
|
||||||
} & TProjectPermission;
|
} & 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;
|
subTitle?: string;
|
||||||
onDeleteApproved: () => Promise<void>;
|
onDeleteApproved: () => Promise<void>;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
|
formContent?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
deletionMessage?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteActionModal = ({
|
export const DeleteActionModal = ({
|
||||||
@@ -28,6 +30,8 @@ export const DeleteActionModal = ({
|
|||||||
title,
|
title,
|
||||||
subTitle = "This action is irreversible.",
|
subTitle = "This action is irreversible.",
|
||||||
buttonText = "Delete",
|
buttonText = "Delete",
|
||||||
|
formContent,
|
||||||
|
deletionMessage,
|
||||||
children
|
children
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const [inputData, setInputData] = useState("");
|
const [inputData, setInputData] = useState("");
|
||||||
@@ -79,6 +83,7 @@ export const DeleteActionModal = ({
|
|||||||
}
|
}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
|
{formContent}
|
||||||
<form
|
<form
|
||||||
onSubmit={(evt) => {
|
onSubmit={(evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@@ -88,7 +93,11 @@ export const DeleteActionModal = ({
|
|||||||
<FormControl
|
<FormControl
|
||||||
label={
|
label={
|
||||||
<div className="break-words pb-2 text-sm">
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
className="mb-0"
|
className="mb-0"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type DashboardProjectSecretsOverviewResponse = {
|
|||||||
totalUniqueDynamicSecretsInPage: number;
|
totalUniqueDynamicSecretsInPage: number;
|
||||||
totalUniqueFoldersInPage: number;
|
totalUniqueFoldersInPage: number;
|
||||||
totalUniqueSecretImportsInPage: number;
|
totalUniqueSecretImportsInPage: number;
|
||||||
|
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[];
|
||||||
totalUniqueSecretRotationsInPage: number;
|
totalUniqueSecretRotationsInPage: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +42,16 @@ export type DashboardProjectSecretsDetailsResponse = {
|
|||||||
totalSecretCount?: number;
|
totalSecretCount?: number;
|
||||||
totalSecretRotationCount?: number;
|
totalSecretRotationCount?: number;
|
||||||
totalCount: 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 = {
|
export type DashboardProjectSecretsByKeys = {
|
||||||
|
|||||||
@@ -272,7 +272,8 @@ export const OverviewPage = () => {
|
|||||||
totalUniqueSecretsInPage,
|
totalUniqueSecretsInPage,
|
||||||
totalUniqueSecretImportsInPage,
|
totalUniqueSecretImportsInPage,
|
||||||
totalUniqueDynamicSecretsInPage,
|
totalUniqueDynamicSecretsInPage,
|
||||||
totalUniqueSecretRotationsInPage
|
totalUniqueSecretRotationsInPage,
|
||||||
|
importedByEnvs
|
||||||
} = overview ?? {};
|
} = overview ?? {};
|
||||||
|
|
||||||
const secretImportsShaped = secretImports
|
const secretImportsShaped = secretImports
|
||||||
@@ -1042,6 +1043,7 @@ export const OverviewPage = () => {
|
|||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
selectedEntries={selectedEntries}
|
selectedEntries={selectedEntries}
|
||||||
resetSelectedEntries={resetSelectedEntries}
|
resetSelectedEntries={resetSelectedEntries}
|
||||||
|
importedByEnvs={importedByEnvs}
|
||||||
/>
|
/>
|
||||||
<div className="thin-scrollbar mt-4">
|
<div className="thin-scrollbar mt-4">
|
||||||
<TableContainer
|
<TableContainer
|
||||||
|
|||||||
+94
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { subject } from "@casl/ability";
|
import { subject } from "@casl/ability";
|
||||||
import { faAnglesRight, faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faAnglesRight, faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
|
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
|
||||||
|
import { ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types";
|
||||||
import {
|
import {
|
||||||
SecretType,
|
SecretType,
|
||||||
SecretV3RawSanitized,
|
SecretV3RawSanitized,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
TSecretFolder
|
TSecretFolder
|
||||||
} from "@app/hooks/api/types";
|
} from "@app/hooks/api/types";
|
||||||
import { MoveSecretsModal } from "@app/pages/secret-manager/OverviewPage/components/SelectionPanel/components";
|
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 {
|
export enum EntryType {
|
||||||
FOLDER = "folder",
|
FOLDER = "folder",
|
||||||
@@ -34,9 +37,15 @@ type Props = {
|
|||||||
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
|
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
|
||||||
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
|
[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 { permission } = useProjectPermission();
|
||||||
|
|
||||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
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 = () => {
|
const getDeleteModalTitle = () => {
|
||||||
if (selectedFolderCount > 0 && selectedKeysCount > 0) {
|
if (selectedFolderCount > 0 && selectedKeysCount > 0) {
|
||||||
return "Do you want to delete the selected secrets and folders across environments?";
|
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()}
|
title={getDeleteModalTitle()}
|
||||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
||||||
onDeleteApproved={handleBulkDelete}
|
onDeleteApproved={handleBulkDelete}
|
||||||
|
formContent={
|
||||||
|
importedBy &&
|
||||||
|
importedBy.some((element) => element.folders.length > 0) && (
|
||||||
|
<CollapsibleSecretImports
|
||||||
|
importedBy={importedBy}
|
||||||
|
secretsToDelete={secretsToDeleteKeys}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ const Page = () => {
|
|||||||
totalDynamicSecretCount = 0,
|
totalDynamicSecretCount = 0,
|
||||||
totalSecretCount = 0,
|
totalSecretCount = 0,
|
||||||
totalCount = 0,
|
totalCount = 0,
|
||||||
|
importedBy,
|
||||||
totalSecretRotationCount = 0
|
totalSecretRotationCount = 0
|
||||||
} = data ?? {};
|
} = data ?? {};
|
||||||
|
|
||||||
@@ -439,6 +440,7 @@ const Page = () => {
|
|||||||
onToggleRowType={handleToggleRowType}
|
onToggleRowType={handleToggleRowType}
|
||||||
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
|
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
|
||||||
protectedBranchPolicyName={boardPolicy?.name}
|
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="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">
|
<div className="flex flex-col" id="dashboard">
|
||||||
@@ -527,6 +529,7 @@ const Page = () => {
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
isProtectedBranch={isProtectedBranch}
|
isProtectedBranch={isProtectedBranch}
|
||||||
|
importedBy={importedBy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />}
|
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />}
|
||||||
|
|||||||
+20
-1
@@ -81,6 +81,7 @@ import {
|
|||||||
useSelectedSecrets
|
useSelectedSecrets
|
||||||
} from "../../SecretMainPage.store";
|
} from "../../SecretMainPage.store";
|
||||||
import { Filter, RowType } from "../../SecretMainPage.types";
|
import { Filter, RowType } from "../../SecretMainPage.types";
|
||||||
|
import { CollapsibleSecretImports } from "../SecretListView/CollapsibleSecretImports";
|
||||||
import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard";
|
import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard";
|
||||||
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||||
@@ -112,6 +113,14 @@ type Props = {
|
|||||||
onVisibilityToggle: () => void;
|
onVisibilityToggle: () => void;
|
||||||
onToggleRowType: (rowType: RowType) => void;
|
onToggleRowType: (rowType: RowType) => void;
|
||||||
onClickRollbackMode: () => void;
|
onClickRollbackMode: () => void;
|
||||||
|
importedBy?: {
|
||||||
|
environment: { name: string; slug: string };
|
||||||
|
folders: {
|
||||||
|
name: string;
|
||||||
|
secrets?: { secretId: string; referencedSecretKey: string }[];
|
||||||
|
isImported: boolean;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ActionBar = ({
|
export const ActionBar = ({
|
||||||
@@ -129,7 +138,8 @@ export const ActionBar = ({
|
|||||||
onVisibilityToggle,
|
onVisibilityToggle,
|
||||||
onClickRollbackMode,
|
onClickRollbackMode,
|
||||||
onToggleRowType,
|
onToggleRowType,
|
||||||
protectedBranchPolicyName
|
protectedBranchPolicyName,
|
||||||
|
importedBy
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||||
"addFolder",
|
"addFolder",
|
||||||
@@ -1059,6 +1069,15 @@ export const ActionBar = ({
|
|||||||
title="Do you want to delete these secrets?"
|
title="Do you want to delete these secrets?"
|
||||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
|
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
|
||||||
onDeleteApproved={handleSecretBulkDelete}
|
onDeleteApproved={handleSecretBulkDelete}
|
||||||
|
formContent={
|
||||||
|
importedBy &&
|
||||||
|
importedBy.length > 0 && (
|
||||||
|
<CollapsibleSecretImports
|
||||||
|
importedBy={importedBy}
|
||||||
|
secretsToDelete={Object.values(selectedSecrets).map((s) => s.key)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<MoveSecretsModal
|
<MoveSecretsModal
|
||||||
popUp={popUp}
|
popUp={popUp}
|
||||||
|
|||||||
+34
-20
@@ -41,6 +41,7 @@ interface FolderProps {
|
|||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
level: number;
|
level: number;
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TreeViewProps {
|
interface TreeViewProps {
|
||||||
@@ -48,6 +49,7 @@ interface TreeViewProps {
|
|||||||
basePath?: string;
|
basePath?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onChange: (items: SecretItem[]) => void;
|
onChange: (items: SecretItem[]) => void;
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => {
|
const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => {
|
||||||
@@ -81,7 +83,8 @@ const Folder: React.FC<FolderProps> = ({
|
|||||||
onFolderSelect,
|
onFolderSelect,
|
||||||
isExpanded = false,
|
isExpanded = false,
|
||||||
level,
|
level,
|
||||||
basePath
|
basePath,
|
||||||
|
isDisabled = false
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = useState(isExpanded);
|
const [open, setOpen] = useState(isExpanded);
|
||||||
const displayName = useMemo(() => getDisplayName(name), [name]);
|
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"}`}
|
className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
{!isDisabled && (
|
||||||
id="folder-root"
|
<Checkbox
|
||||||
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
id="folder-root"
|
||||||
isChecked={allSelected || someSelected}
|
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
||||||
onCheckedChange={handleFolderSelect}
|
isChecked={allSelected || someSelected}
|
||||||
isIndeterminate={someSelected && !allSelected}
|
onCheckedChange={handleFolderSelect}
|
||||||
/>
|
isIndeterminate={someSelected && !allSelected}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<label
|
<label
|
||||||
htmlFor={`folder-${path}`}
|
htmlFor={`folder-${path}`}
|
||||||
@@ -158,12 +164,15 @@ const Folder: React.FC<FolderProps> = ({
|
|||||||
<div className="ml-6 mr-2">
|
<div className="ml-6 mr-2">
|
||||||
<FontAwesomeIcon icon={faKey} className="h-3 w-3" />
|
<FontAwesomeIcon icon={faKey} className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
{!isDisabled && (
|
||||||
id={`folder-${item.id}`}
|
<Checkbox
|
||||||
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
id={`folder-${item.id}`}
|
||||||
isChecked={selectedItemIds.includes(item.id)}
|
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
||||||
onCheckedChange={(checked) => onItemSelect(item, !!checked)}
|
isChecked={selectedItemIds.includes(item.id)}
|
||||||
/>
|
onCheckedChange={(checked) => onItemSelect(item, !!checked)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<label
|
<label
|
||||||
htmlFor={item.id}
|
htmlFor={item.id}
|
||||||
className="ml-2 flex-1 cursor-pointer truncate"
|
className="ml-2 flex-1 cursor-pointer truncate"
|
||||||
@@ -184,6 +193,7 @@ const Folder: React.FC<FolderProps> = ({
|
|||||||
onItemSelect={onItemSelect}
|
onItemSelect={onItemSelect}
|
||||||
onFolderSelect={onFolderSelect}
|
onFolderSelect={onFolderSelect}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
|
isDisabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -197,7 +207,8 @@ export const SecretTreeView: React.FC<TreeViewProps> = ({
|
|||||||
data,
|
data,
|
||||||
basePath = "/",
|
basePath = "/",
|
||||||
className = "",
|
className = "",
|
||||||
onChange
|
onChange,
|
||||||
|
isDisabled = false
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]);
|
const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]);
|
||||||
const rootPath = "/";
|
const rootPath = "/";
|
||||||
@@ -305,16 +316,19 @@ export const SecretTreeView: React.FC<TreeViewProps> = ({
|
|||||||
onFolderSelect={handleFolderSelect}
|
onFolderSelect={handleFolderSelect}
|
||||||
isExpanded
|
isExpanded
|
||||||
level={0}
|
level={0}
|
||||||
|
isDisabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pb-2 pr-2 pt-2">
|
{!isDisabled && (
|
||||||
<h3 className="flex items-center text-mineshaft-400">
|
<div className="flex justify-end pb-2 pr-2 pt-2">
|
||||||
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
|
<h3 className="flex items-center text-mineshaft-400">
|
||||||
</h3>
|
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
|
||||||
</div>
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+190
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+29
-1
@@ -16,6 +16,7 @@ import { WsTag } from "@app/hooks/api/types";
|
|||||||
import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal";
|
import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal";
|
||||||
|
|
||||||
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
|
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
|
||||||
|
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
|
||||||
import { SecretDetailSidebar } from "./SecretDetailSidebar";
|
import { SecretDetailSidebar } from "./SecretDetailSidebar";
|
||||||
import { SecretItem } from "./SecretItem";
|
import { SecretItem } from "./SecretItem";
|
||||||
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
|
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
|
||||||
@@ -28,6 +29,14 @@ type Props = {
|
|||||||
tags?: WsTag[];
|
tags?: WsTag[];
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
isProtectedBranch?: boolean;
|
isProtectedBranch?: boolean;
|
||||||
|
importedBy?: {
|
||||||
|
environment: { name: string; slug: string };
|
||||||
|
folders: {
|
||||||
|
name: string;
|
||||||
|
secrets?: { secretId: string; referencedSecretKey: string }[];
|
||||||
|
isImported: boolean;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SecretListView = ({
|
export const SecretListView = ({
|
||||||
@@ -37,7 +46,8 @@ export const SecretListView = ({
|
|||||||
secretPath = "/",
|
secretPath = "/",
|
||||||
tags: wsTags = [],
|
tags: wsTags = [],
|
||||||
isVisible,
|
isVisible,
|
||||||
isProtectedBranch = false
|
isProtectedBranch = false,
|
||||||
|
importedBy
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
@@ -357,6 +367,24 @@ export const SecretListView = ({
|
|||||||
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
|
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
|
||||||
onDeleteApproved={handleSecretDelete}
|
onDeleteApproved={handleSecretDelete}
|
||||||
buttonText="Delete Secret"
|
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
|
<SecretDetailSidebar
|
||||||
environment={environment}
|
environment={environment}
|
||||||
|
|||||||
Reference in New Issue
Block a user