diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index bcb0a949a..5d75a6d2c 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -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) + diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index da25f4d30..1a171aa2e 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 { EnvironmentInfo, FolderInfo, FolderResult, SecretResult } from "./secret-import-types"; + export type TSecretImportDALFactory = ReturnType; 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((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 }; }; diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 6141a37b0..c40d6b22b 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -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 }; }; diff --git a/backend/src/services/secret-import/secret-import-types.ts b/backend/src/services/secret-import/secret-import-types.ts index 638e36cb1..e4490e715 100644 --- a/backend/src/services/secret-import/secret-import-types.ts +++ b/backend/src/services/secret-import/secret-import-types.ts @@ -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[]; +}; diff --git a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx index 299b23bb5..a2b69eaba 100644 --- a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx +++ b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx @@ -16,7 +16,9 @@ type Props = { subTitle?: string; onDeleteApproved: () => Promise; 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}
{ evt.preventDefault(); @@ -88,7 +93,11 @@ export const DeleteActionModal = ({ - Type {deleteKey} to perform this action + {deletionMessage || ( + <> + Type {deleteKey} to perform this action + + )} } className="mb-0" diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 6a3e9ba7c..e80874f8e 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -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 = { diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 4ee50ed4f..5fe31a7b3 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -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} />
>; [EntryType.SECRET]: Record>; }; + 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 = {}; + + 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 = {}; + 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(); + + 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) && ( + + ) + } /> ); diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index cd2a1aee1..e6a5461e4 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -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} />
@@ -527,6 +529,7 @@ const Page = () => { workspaceId={workspaceId} secretPath={secretPath} isProtectedBranch={isProtectedBranch} + importedBy={importedBy} /> )} {canReadSecret && } diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index 7b1965878..c62f15d95 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -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 && ( + s.key)} + /> + ) + } /> void; + isDisabled?: boolean; } const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => { @@ -81,7 +83,8 @@ const Folder: React.FC = ({ 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 = ({ className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`} />
- + {!isDisabled && ( + + )}
@@ -197,7 +207,8 @@ export const SecretTreeView: React.FC = ({ data, basePath = "/", className = "", - onChange + onChange, + isDisabled = false }) => { const [selectedItems, setSelectedItems] = useState([]); const rootPath = "/"; @@ -305,16 +316,19 @@ export const SecretTreeView: React.FC = ({ onFolderSelect={handleFolderSelect} isExpanded level={0} + isDisabled={isDisabled} /> )) )}
-
-

- {selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected -

-
+ {!isDisabled && ( +
+

+ {selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected +

+
+ )} ); diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports.tsx new file mode 100644 index 000000000..9a66b23f5 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports.tsx @@ -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 = ({ + 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 ( +
+
+
+
+
+
+

+ The following resources will be affected by this change +

+
+
+
+ +
+ + + + + + + + + {flattenedItems.map((item) => ( + handlePathClick(item)} + className="cursor-pointer hover:bg-mineshaft-700" + title={`Navigate to ${item.path}`} + > + + + + + + ))} + +
TypeEnvironmentPathUsage
+ {item.environment.name}{truncatePath(item.path)}{item.reference}
+
+
+ ); +}; 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 b6d98a08b..bb6420606 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx @@ -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 && ( + + ) + } + deletionMessage={ + <> + Type the secret key{" "} + + "{(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key}" + {" "} + to perform this action + + } />