From a0bf03b2ae0e77123a99d3782b8a400ec96a7d10 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Thu, 3 Apr 2025 16:00:41 -0300 Subject: [PATCH] UI improvements on secret deletion warning --- .../src/server/routes/v1/dashboard-router.ts | 7 +- .../secret-import/secret-import-dal.ts | 100 +++++++++++++-- .../secret-import/secret-import-service.ts | 5 +- .../secret-import/secret-import-types.ts | 21 ++++ frontend/src/hooks/api/dashboard/types.ts | 2 +- .../SecretListView/SecretDeletionImpact.tsx | 118 ++++++++++++++++++ .../SecretListView/SecretListView.tsx | 49 +------- 7 files changed, 243 insertions(+), 59 deletions(-) create mode 100644 frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDeletionImpact.tsx diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index f12a5d549..d27e53bb5 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -474,7 +474,12 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { importedBy: z .object({ envName: z.string(), - folderName: z.string() + folders: z + .object({ + folderName: z.string(), + secrets: z.array(z.string()).optional() + }) + .array() }) .array() .optional() diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index 4768c4e38..933a78c21 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -5,6 +5,8 @@ import { TableName, TSecretImports } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; +import { EnvInfo, FolderInfo, FolderResult, SecretResult } from "./secret-import-types"; + export type TSecretImportDALFactory = ReturnType; export const secretImportDALFactory = (db: TDbClient) => { @@ -169,24 +171,104 @@ export const secretImportDALFactory = (db: TDbClient) => { } }; - const getFolderIsImportedBy = async (secretPath: string, environment: string, tx?: Knex) => { + const getFolderIsImportedBy = async ( + secretPath: string, + environmentId: string, + environment: string, + projectId: string, + tx?: Knex + ) => { try { - const docs = await (tx || db.replicaNode())(TableName.SecretImport) - .where({ importPath: secretPath, importEnv: environment }) + const knexInstance = tx || db.replicaNode(); + + const folderImports = await knexInstance(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("name").withSchema(TableName.SecretFolder).as("folderName"), - db.ref("parentId").withSchema(TableName.SecretFolder).as("parentFolderId") + knexInstance.ref("name").withSchema(TableName.Environment).as("envName"), + knexInstance.ref("name").withSchema(TableName.SecretFolder).as("folderName"), + knexInstance.ref("parentId").withSchema(TableName.SecretFolder).as("parentFolderId") ); - return docs.map(({ envName, folderName, parentFolderId }) => ({ + const secretReferences = await knexInstance(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) + .select( + knexInstance.ref("key").withSchema(TableName.SecretV2).as("secretId"), + knexInstance.ref("name").withSchema(TableName.SecretFolder).as("folderName"), + knexInstance.ref("name").withSchema(TableName.Environment).as("envName"), + knexInstance.ref("parentId").withSchema(TableName.SecretFolder).as("parentFolderId") + ); + + const folderResults = folderImports.map(({ envName, folderName, parentFolderId }) => ({ envName, - folderName: parentFolderId ? folderName : "Project Root Folder" + folderName: parentFolderId ? folderName : "/" })); + + const secretResults = secretReferences.map(({ envName, secretId, folderName, parentFolderId }) => ({ + envName, + secretId, + folderName: parentFolderId ? folderName : "/" + })); + + type ResultItem = FolderResult | SecretResult; + const allResults: ResultItem[] = [...folderResults, ...secretResults]; + + type EnvFolderMap = { + [envName: string]: { + [folderName: string]: { + secrets: string[]; + }; + }; + }; + + const groupedByEnv = allResults.reduce((acc, item) => { + const env = item.envName; + const folder = item.folderName; + + const updatedAcc = { ...acc }; + + if (!updatedAcc[env]) { + updatedAcc[env] = {}; + } + + if (!updatedAcc[env][folder]) { + updatedAcc[env][folder] = { secrets: [] }; + } + + if ("secretId" in item && item.secretId) { + updatedAcc[env][folder].secrets = [...updatedAcc[env][folder].secrets, item.secretId]; + } + + return updatedAcc; + }, {}); + + const formattedResult: EnvInfo[] = Object.keys(groupedByEnv).map((envName) => { + const envData = groupedByEnv[envName]; + + const folders: FolderInfo[] = Object.keys(envData).map((folderName) => { + const folderData = envData[folderName]; + const hasSecrets = folderData.secrets.length > 0; + + return { + folderName, + ...(hasSecrets && { secrets: folderData.secrets }) + }; + }); + + return { + envName, + folders + }; + }); + + return formattedResult; } catch (error) { - throw new DatabaseError({ error, name: "get secret imports count" }); + throw new DatabaseError({ error, name: "get secret imports and references" }); } }; diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index fef34e42f..31450513e 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -38,6 +38,7 @@ import { TResyncSecretImportReplicationDTO, TUpdateSecretImportDTO } from "./secret-import-types"; +import { logger } from "@app/lib/logger"; type TSecretImportServiceFactoryDep = { secretImportDAL: TSecretImportDALFactory; @@ -821,8 +822,8 @@ export const secretImportServiceFactory = ({ message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found` }); - const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId); - + const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId, environment, projectId); + logger.info("importedBy", JSON.stringify(importedBy)); return importedBy; }; diff --git a/backend/src/services/secret-import/secret-import-types.ts b/backend/src/services/secret-import/secret-import-types.ts index 638e36cb1..62f8eebd3 100644 --- a/backend/src/services/secret-import/secret-import-types.ts +++ b/backend/src/services/secret-import/secret-import-types.ts @@ -45,3 +45,24 @@ export type TGetSecretsFromImportDTO = { environment: string; path: string; } & TProjectPermission; + +export type FolderResult = { + envName: string; + folderName: string; +}; + +export type SecretResult = { + envName: string; + secretId: string; + folderName: string; +}; + +export type FolderInfo = { + folderName: string; + secrets?: string[]; +}; + +export type EnvInfo = { + envName: string; + folders: FolderInfo[]; +}; diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 62be209dc..4dc2ac8f7 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -31,7 +31,7 @@ export type DashboardProjectSecretsDetailsResponse = { totalDynamicSecretCount?: number; totalSecretCount?: number; totalCount: number; - importedBy?: { envName: string; folderName: string }[]; + importedBy?: { envName: string; folders: { folderName: string; secrets?: string[] }[] }[]; }; export type DashboardProjectSecretsByKeys = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDeletionImpact.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDeletionImpact.tsx new file mode 100644 index 000000000..2b1e0151f --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDeletionImpact.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { faFolder, faKey, faServer, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +interface Folder { + folderName: string; + secrets?: string[]; +} + +interface ImportedByEnvironment { + envName: string; + folders: Folder[]; +} + +interface SecretDeletionImpactProps { + importedBy?: ImportedByEnvironment[]; +} + +export const SecretDeletionImpact: React.FC = ({ importedBy = [] }) => { + const truncateId = (id: string): string => { + if (id.length <= 16) return id; + return `${id.substring(0, 8)}...${id.substring(id.length - 8)}`; + }; + + return ( +
+
+
+ +
+

+ Warning: This secret is currently being imported by another folder, so deletion will + affect both locations. +

+
+ +
+ {importedBy.map((envData, envIndex) => ( +
+
+ + {envData.envName} Environment +
+ + {envData.folders.length > 0 && ( +
+
+ Affected Folders +
+
+ {envData.folders + .filter((folder) => !folder.secrets || folder.secrets.length === 0) + .map((folder, folderIndex) => ( +
+ + {folder.folderName} +
+ ))} +
+
+ )} + + {envData.folders.some((folder) => folder.secrets && folder.secrets.length > 0) && ( +
+
+ Affected Secrets +
+
+ {envData.folders + .filter((folder) => folder.secrets && folder.secrets.length > 0) + .map((folder) => ( +
+
+ + {folder.folderName} +
+ +
+ {(folder.secrets || []).map((secret) => ( +
+ +
+ + {truncateId(secret)} + +
+
+ ))} +
+
+ ))} +
+
+ )} +
+ ))} +
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx index 112e2f437..493663661 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx @@ -1,5 +1,4 @@ import { useCallback } from "react"; -import { faWarning } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQueryClient } from "@tanstack/react-query"; @@ -20,6 +19,7 @@ import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPa import { SecretDetailSidebar } from "./SecretDetailSidebar"; import { SecretItem } from "./SecretItem"; import { FontAwesomeSpriteSymbols } from "./SecretListView.utils"; +import { SecretDeletionImpact } from "./SecretDeletionImpact"; type Props = { secrets?: SecretV3RawSanitized[]; @@ -29,11 +29,9 @@ type Props = { tags?: WsTag[]; isVisible?: boolean; isProtectedBranch?: boolean; - importedBy?: { envName: string; folderName: string }[]; + importedBy?: { envName: string; folders: { folderName: string; secrets?: string[] }[] }[]; }; -type GroupedFolders = Record; - export const SecretListView = ({ secrets = [], environment, @@ -358,48 +356,7 @@ export const SecretListView = ({ onDeleteApproved={handleSecretDelete} buttonText="Delete Secret" > - {importedBy && importedBy.length > 0 && ( -
-
-
- -
-
-

- Warning: This secret is currently being imported by another folder, so deletion - will affect both locations. -

- -
- {Object.entries( - importedBy.reduce((acc, { envName, folderName }) => { - if (!acc[envName]) acc[envName] = []; - acc[envName].push(folderName); - return acc; - }, {}) - ).map(([envName, folders], index, array) => ( -
-
{envName}
-
- {folders.map((folderName, idx) => ( -
- {folderName} -
- ))} -
-
- ))} -
-
-
-
- )} + {importedBy && importedBy.length > 0 && }