diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 901723647..fc33bcafe 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -897,6 +897,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/").transform(removeTrailingSlash), + recursive: booleanSchema.default(false), filterByAction: z .enum([ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.ReadValue]) .default(ProjectPermissionSecretActions.ReadValue) @@ -915,7 +916,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { projectId, environment, secretPath, filterByAction } = req.query; + const { projectId, environment, secretPath, filterByAction, recursive } = req.query; const { secrets } = await server.services.secret.getAccessibleSecrets({ actorId: req.permission.id, @@ -925,7 +926,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { environment, secretPath, projectId, - filterByAction + filterByAction, + recursive }); return { secrets }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index b2fe923aa..1544e963c 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -356,7 +356,7 @@ export const fnSecretBulkDelete = async ({ interface FolderMap { [parentId: string]: TSecretFolders[]; } -const buildHierarchy = (folders: TSecretFolders[]): FolderMap => { +export const buildHierarchy = (folders: TSecretFolders[]): FolderMap => { const map: FolderMap = {}; map.null = []; // Initialize mapping for root directory @@ -371,7 +371,7 @@ const buildHierarchy = (folders: TSecretFolders[]): FolderMap => { return map; }; -const generatePaths = ( +export const generatePaths = ( map: FolderMap, parentId: string = "null", basePath: string = "", diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index dc975854f..6fdfb10f1 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -44,10 +44,12 @@ import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; import { + buildHierarchy, expandSecretReferencesFactory, fnSecretBulkDelete, fnSecretBulkInsert, fnSecretBulkUpdate, + generatePaths, getAllSecretReferences, recursivelyGetSecretPaths, reshapeBridgeSecret @@ -2620,7 +2622,8 @@ export const secretV2BridgeServiceFactory = ({ actorId, actor, actorAuthMethod, - actorOrgId + actorOrgId, + recursive }: TGetAccessibleSecretsDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -2635,10 +2638,38 @@ export const secretV2BridgeServiceFactory = ({ secretPath }); + const folders = []; const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) return { secrets: [] }; + folders.push({ ...folder, parentId: null }); - const secrets = await secretDAL.findByFolderIds([folder.id]); + const env = await projectEnvDAL.findOne({ + projectId, + slug: environment + }); + + if (!env) { + throw new NotFoundError({ + message: `Environment with slug '${environment}' in project with ID ${projectId} not found` + }); + } + + if (recursive) { + const subFolders = await folderDAL.find({ + envId: env.id, + isReserved: false + }); + folders.push(...subFolders); + } + + if (folders.length === 0) return { secrets: [] }; + + const folderMap = buildHierarchy(folders); + const paths = Object.fromEntries( + generatePaths(folderMap).map(({ folderId, path }) => [folderId, path === "/" ? path : path.substring(1)]) + ); + + const secrets = await secretDAL.findByFolderIds(folders.map((f) => f.id)); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -2650,7 +2681,7 @@ export const secretV2BridgeServiceFactory = ({ if ( !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { environment, - secretPath, + secretPath: paths[el.folderId], secretName: el.key, secretTags: el.tags.map((i) => i.slug) }) @@ -2661,7 +2692,7 @@ export const secretV2BridgeServiceFactory = ({ if (filterByAction === ProjectPermissionSecretActions.ReadValue) { return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { environment, - secretPath, + secretPath: paths[el.folderId], secretName: el.key, secretTags: el.tags.map((i) => i.slug) }); @@ -2674,7 +2705,7 @@ export const secretV2BridgeServiceFactory = ({ filterByAction === ProjectPermissionSecretActions.DescribeSecret && !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { environment, - secretPath, + secretPath: paths[secret.folderId], secretName: secret.key, secretTags: secret.tags.map((i) => i.slug) }); @@ -2682,7 +2713,7 @@ export const secretV2BridgeServiceFactory = ({ return reshapeBridgeSecret( projectId, environment, - secretPath, + paths[secret.folderId], { ...secret, value: secret.encryptedValue diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 7f415bff8..5c5b26ae4 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -356,5 +356,6 @@ export type TGetAccessibleSecretsDTO = { environment: string; projectId: string; secretPath: string; + recursive?: boolean; filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue; } & TProjectPermission; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 13a5c9736..7aeb59f01 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1321,7 +1321,8 @@ export const secretServiceFactory = ({ actorOrgId, actorAuthMethod, environment, - filterByAction + filterByAction, + recursive }: TGetAccessibleSecretsDTO) => { const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); @@ -1340,7 +1341,8 @@ export const secretServiceFactory = ({ actor, actorId, actorOrgId, - actorAuthMethod + actorAuthMethod, + recursive }); return secrets; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 6a5c097ed..bea59a439 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -184,6 +184,7 @@ export enum SecretsOrderBy { export type TGetAccessibleSecretsDTO = { secretPath: string; environment: string; + recursive?: boolean; filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue; } & TProjectPermission; diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index ba417430a..061baae0c 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -319,12 +319,13 @@ const fetchAccessibleSecrets = async ({ projectId, secretPath, environment, - filterByAction + filterByAction, + recursive = false }: TGetAccessibleSecretsDTO) => { const { data } = await apiRequest.get<{ secrets: SecretV3Raw[] }>( "/api/v1/dashboard/accessible-secrets", { - params: { projectId, secretPath, environment, filterByAction } + params: { projectId, secretPath, environment, filterByAction, recursive } } ); @@ -399,7 +400,8 @@ export const useGetAccessibleSecrets = ({ secretPath, environment, filterByAction, - options + options, + recursive = false }: TGetAccessibleSecretsDTO & { options?: Omit< UseQueryOptions< @@ -417,8 +419,10 @@ export const useGetAccessibleSecrets = ({ projectId, secretPath, environment, - filterByAction + filterByAction, + recursive }), - queryFn: () => fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction }) + queryFn: () => + fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction, recursive }) }); }; diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index bdff878cd..556f76eaf 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -111,6 +111,7 @@ export type TGetAccessibleSecretsDTO = { projectId: string; secretPath: string; environment: string; + recursive?: boolean; filterByAction: | ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 5c4d4ffc6..58383e1ff 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -121,6 +121,7 @@ export type TGetProjectSecretsKey = { includeImports?: boolean; viewSecretValue?: boolean; expandSecretReferences?: boolean; + recursive?: boolean; }; export type TGetProjectSecretsDTO = TGetProjectSecretsKey; 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 681ad99e3..9dd5589a5 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -17,10 +17,12 @@ import { faKey, faLock, faMinusSquare, + faPaste, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; import FileSaver from "file-saver"; import { twMerge } from "tailwind-merge"; @@ -53,8 +55,19 @@ import { useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api"; -import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries"; +import { + useCreateFolder, + useCreateSecretBatch, + useDeleteSecretBatch, + useMoveSecrets, + useUpdateSecretBatch +} from "@app/hooks/api"; +import { + dashboardKeys, + fetchDashboardProjectSecretsByKeys +} from "@app/hooks/api/dashboard/queries"; +import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; +import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries"; import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types"; import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput"; @@ -65,11 +78,19 @@ import { useSelectedSecrets } from "../../SecretMainPage.store"; import { Filter, RowType } from "../../SecretMainPage.types"; +import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { FolderForm } from "./FolderForm"; import { MoveSecretsModal } from "./MoveSecretsModal"; +type TParsedEnv = Record; +type TParsedFolderEnv = Record< + string, + Record +>; +type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv }; + type Props = { // switch the secrets type as it gets decrypted after api call environment: string; @@ -114,7 +135,9 @@ export const ActionBar = ({ "bulkDeleteSecrets", "moveSecrets", "misc", - "upgradePlan" + "upgradePlan", + "replicateFolder", + "confirmUpload" ] as const); const isProtectedBranch = Boolean(protectedBranchPolicyName); const { subscription } = useSubscription(); @@ -122,6 +145,13 @@ export const ActionBar = ({ const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); const { mutateAsync: moveSecrets } = useMoveSecrets(); + const { mutateAsync: updateSecretBatch, isPending: isUpdatingSecrets } = useUpdateSecretBatch({ + options: { onSuccess: undefined } + }); + const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({ + options: { onSuccess: undefined } + }); + const queryClient = useQueryClient(); const selectedSecrets = useSelectedSecrets(); const { reset: resetSelectedSecret } = useSelectedSecretActions(); @@ -293,6 +323,285 @@ export const ActionBar = ({ } }; + // Replicate Folder Logic + const createSecretCount = Object.keys( + (popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {} + ).length; + + const updateSecretCount = Object.keys( + (popUp.confirmUpload?.data as TSecOverwriteOpt)?.update || {} + ).length; + + const isNonConflictingUpload = !updateSecretCount; + const isSubmitting = isCreatingSecrets || isUpdatingSecrets; + + const handleParsedEnvMultiFolder = async (envByPath: TParsedFolderEnv) => { + if (Object.keys(envByPath).length === 0) { + createNotification({ + type: "error", + text: "Failed to find secrets" + }); + return; + } + + try { + const allUpdateSecrets: TParsedEnv = {}; + const allCreateSecrets: TParsedEnv = {}; + + await Promise.all( + Object.entries(envByPath).map(async ([folderPath, secrets]) => { + // Normalize the path + let normalizedPath = folderPath; + + // If the path is "/", use the current secretPath + if (normalizedPath === "/") { + normalizedPath = secretPath; + } else { + // Otherwise, concatenate with the current secretPath, avoiding double slashes + const baseSecretPath = secretPath.endsWith("/") ? secretPath.slice(0, -1) : secretPath; + // Remove leading slash from folder path if present to avoid double slashes + const cleanFolderPath = folderPath.startsWith("/") + ? folderPath.substring(1) + : folderPath; + normalizedPath = `${baseSecretPath}/${cleanFolderPath}`; + } + + const secretFolderKeys = Object.keys(secrets); + + if (secretFolderKeys.length === 0) return; + + // Check which secrets already exist in this path + const batchSize = 50; + const secretBatches = Array.from( + { length: Math.ceil(secretFolderKeys.length / batchSize) }, + (_, i) => secretFolderKeys.slice(i * batchSize, (i + 1) * batchSize) + ); + + const existingSecretLookup: Record = {}; + + const processBatches = async () => { + await secretBatches.reduce(async (previous, batch) => { + await previous; + + const { secrets: batchSecrets } = await fetchDashboardProjectSecretsByKeys({ + secretPath: normalizedPath, + environment, + projectId: workspaceId, + keys: batch + }); + + batchSecrets.forEach((secret) => { + existingSecretLookup[secret.secretKey] = true; + }); + }, Promise.resolve()); + }; + + await processBatches(); + + // Categorize each secret as update or create + secretFolderKeys.forEach((secretKey) => { + const secretData = secrets[secretKey]; + + // Store the path with the secret for later batch processing + const secretWithPath = { + ...secretData, + secretPath: normalizedPath + }; + + if (existingSecretLookup[secretKey]) { + allUpdateSecrets[secretKey] = secretWithPath; + } else { + allCreateSecrets[secretKey] = secretWithPath; + } + }); + }) + ); + + handlePopUpOpen("confirmUpload", { + update: allUpdateSecrets, + create: allCreateSecrets + }); + } catch (e) { + console.error(e); + createNotification({ + text: "Failed to check for secret conflicts", + type: "error" + }); + handlePopUpClose("confirmUpload"); + } + }; + + const handleSaveFolderImport = async () => { + const { update, create } = popUp?.confirmUpload?.data as TSecOverwriteOpt; + try { + // Group secrets by their path for batch operations + const groupedCreateSecrets: Record< + string, + Array<{ + type: SecretType; + secretComment: string; + secretValue: string; + secretKey: string; + }> + > = {}; + + const groupedUpdateSecrets: Record< + string, + Array<{ + type: SecretType; + secretComment: string; + secretValue: string; + secretKey: string; + }> + > = {}; + + // Collect all unique paths that need folders to be created + const allPaths = new Set(); + + // Add paths from create secrets + Object.values(create || {}).forEach((secData) => { + if (secData.secretPath && secData.secretPath !== secretPath) { + allPaths.add(secData.secretPath); + } + }); + + // Create a map of folder paths to their folder name (last segment) + const folderPaths = Array.from(allPaths).map((path) => { + // Remove trailing slash if it exists + const normalizedPath = path.endsWith("/") ? path.slice(0, -1) : path; + // Split by '/' to get path segments + const segments = normalizedPath.split("/"); + // Get the last segment as the folder name + const folderName = segments[segments.length - 1]; + // Get the parent path (everything except the last segment) + const parentPath = segments.slice(0, -1).join("/"); + + return { + folderName, + fullPath: normalizedPath, + parentPath: parentPath || "/" + }; + }); + + // Sort paths by depth (shortest first) to ensure parent folders are created before children + folderPaths.sort( + (a, b) => (a.fullPath.match(/\//g) || []).length - (b.fullPath.match(/\//g) || []).length + ); + + // Track created folders to avoid duplicates + const createdFolders = new Set(); + + // Create all necessary folders in order using Promise.all and reduce + await folderPaths.reduce(async (previousPromise, { folderName, fullPath, parentPath }) => { + // Wait for the previous promise to complete + await previousPromise; + + // Skip if we've already created this folder + if (createdFolders.has(fullPath)) return Promise.resolve(); + + try { + await createFolder({ + name: folderName, + path: parentPath, + environment, + projectId: workspaceId + }); + + createdFolders.add(fullPath); + } catch (err) { + console.log(`Folder ${folderName} may already exist:`, err); + } + + return Promise.resolve(); + }, Promise.resolve()); + + if (Object.keys(create || {}).length > 0) { + Object.entries(create).forEach(([secretKey, secData]) => { + // Use the stored secretPath or fall back to the current secretPath + const path = secData.secretPath || secretPath; + + if (!groupedCreateSecrets[path]) { + groupedCreateSecrets[path] = []; + } + + groupedCreateSecrets[path].push({ + type: SecretType.Shared, + secretComment: secData.comments.join("\n"), + secretValue: secData.value, + secretKey + }); + }); + + await Promise.all( + Object.entries(groupedCreateSecrets).map(([path, secrets]) => + createSecretBatch({ + secretPath: path, + workspaceId, + environment, + secrets + }) + ) + ); + } + + if (Object.keys(update || {}).length > 0) { + Object.entries(update).forEach(([secretKey, secData]) => { + // Use the stored secretPath or fall back to the current secretPath + const path = secData.secretPath || secretPath; + + if (!groupedUpdateSecrets[path]) { + groupedUpdateSecrets[path] = []; + } + + groupedUpdateSecrets[path].push({ + type: SecretType.Shared, + secretComment: secData.comments.join("\n"), + secretValue: secData.value, + secretKey + }); + }); + + // Update secrets for each path in parallel + await Promise.all( + Object.entries(groupedUpdateSecrets).map(([path, secrets]) => + updateSecretBatch({ + secretPath: path, + workspaceId, + environment, + secrets + }) + ) + ); + } + + // Invalidate appropriate queries to refresh UI + queryClient.invalidateQueries({ + queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: secretApprovalRequestKeys.count({ workspaceId }) + }); + + // Close the modal and show notification + handlePopUpClose("confirmUpload"); + createNotification({ + type: "success", + text: isProtectedBranch + ? "Uploaded changes have been sent for review" + : "Successfully uploaded secrets" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to upload secrets" + }); + } + }; + return ( <>
@@ -570,6 +879,29 @@ export const ActionBar = ({ )} + + {(isAllowed) => ( + + )} +
@@ -679,6 +1011,15 @@ export const ActionBar = ({ handlePopUpToggle={handlePopUpToggle} onMoveApproved={handleSecretsMove} /> + handlePopUpToggle("replicateFolder", isOpen)} + onParsedEnv={handleParsedEnvMultiFolder} + environment={environment} + environments={currentWorkspace.environments} + workspaceId={workspaceId} + secretPath={secretPath} + /> {subscription && ( )} + handlePopUpToggle("confirmUpload", open)} + > + + {isNonConflictingUpload ? "Upload" : "Overwrite"} + , + + ]} + > + {isNonConflictingUpload ? ( +
+ Are you sure you want to import {createSecretCount} secret + {createSecretCount > 1 ? "s" : ""} to this environment? +
+ ) : ( +
+
Your project already contains the following {updateSecretCount} secrets:
+
+ {Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {}) + ?.map((key) => key) + .join(", ")} +
+
+ Are you sure you want to overwrite these secrets + {createSecretCount > 0 + ? ` and import ${createSecretCount} new + one${createSecretCount > 1 ? "s" : ""}` + : ""} + ? +
+
+ )} +
+
); }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/ReplicateFolderFromBoard.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/ReplicateFolderFromBoard.tsx new file mode 100644 index 000000000..339612a89 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/ReplicateFolderFromBoard.tsx @@ -0,0 +1,307 @@ +import { useEffect, useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faClone } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FilterableSelect, + FormControl, + Modal, + ModalContent, + Switch +} from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; +import { useDebounce } from "@app/hooks"; +import { useGetAccessibleSecrets } from "@app/hooks/api/dashboard"; +import { SecretV3Raw } from "@app/hooks/api/types"; + +import { SecretTreeView } from "./SecretTreeView"; + +const formSchema = z.object({ + environment: z.object({ name: z.string(), slug: z.string() }), + secretPath: z + .string() + .trim() + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ), + secrets: z + .object({ + secretKey: z.string(), + secretValue: z.string().optional(), + secretPath: z.string() + }) + .array() + .min(1, "Select one or more secrets to copy") +}); + +type TFormSchema = z.infer; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + onParsedEnv: ( + env: Record> + ) => void; + environments?: { name: string; slug: string }[]; + workspaceId: string; + environment: string; + secretPath: string; +}; + +type SecretFolder = { + items: Partial[]; + subFolders: Record; +}; + +type SecretStructure = { + [rootPath: string]: SecretFolder; +}; + +export const ReplicateFolderFromBoard = ({ + environments = [], + workspaceId, + isOpen, + onToggle, + onParsedEnv +}: Props) => { + const [shouldIncludeValues, setShouldIncludeValues] = useState(true); + + const { handleSubmit, control, watch, reset, setValue } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { secretPath: "/", environment: environments?.[0], secrets: [] } + }); + + const envCopySecPath = watch("secretPath"); + const selectedEnvSlug = watch("environment"); + const selectedSecrets = watch("secrets"); + const [debouncedEnvCopySecretPath] = useDebounce(envCopySecPath); + + const { data: accessibleSecrets } = useGetAccessibleSecrets({ + projectId: workspaceId, + secretPath: "/", + environment: selectedEnvSlug.slug, + recursive: true, + filterByAction: shouldIncludeValues + ? ProjectPermissionSecretActions.ReadValue + : ProjectPermissionSecretActions.DescribeSecret, + options: { enabled: Boolean(workspaceId) && Boolean(selectedEnvSlug) && isOpen } + }); + + const restructureSecrets = useMemo(() => { + if (!accessibleSecrets) return {}; + + const result: SecretStructure = {}; + result["/"] = { + items: [], + subFolders: {} + }; + + accessibleSecrets.forEach((secret) => { + const path = secret.secretPath || "/"; + + if (path === "/") { + result["/"]?.items.push(secret); + return; + } + + const normalizedPath = path.startsWith("/") ? path.substring(1) : path; + const pathParts = normalizedPath.split("/"); + + let currentFolder = result["/"]; + + for (let i = 0; i < pathParts.length; i += 1) { + const part = pathParts[i]; + + // eslint-disable-next-line no-continue + if (!part) continue; + + if (i === pathParts.length - 1) { + if (!currentFolder.subFolders[part]) { + currentFolder.subFolders[part] = { + items: [], + subFolders: {} + }; + } + currentFolder.subFolders[part].items.push(secret); + } else { + if (!currentFolder.subFolders[part]) { + currentFolder.subFolders[part] = { + items: [], + subFolders: {} + }; + } + currentFolder = currentFolder.subFolders[part]; + } + } + }); + + return result; + }, [accessibleSecrets, selectedEnvSlug]); + + const secretsFilteredByPath = useMemo(() => { + let normalizedPath = debouncedEnvCopySecretPath; + normalizedPath = debouncedEnvCopySecretPath.startsWith("/") + ? debouncedEnvCopySecretPath + : `/${debouncedEnvCopySecretPath}`; + if (normalizedPath.length > 1 && normalizedPath.endsWith("/")) { + normalizedPath = debouncedEnvCopySecretPath.slice(0, -1); + } + + if (normalizedPath === "/") { + return restructureSecrets["/"]; + } + + const segments = normalizedPath.split("/").filter((segment) => segment !== ""); + + let currentLevel = restructureSecrets["/"]; + let result = null; + let currentPath = ""; + + if (!currentLevel) { + setValue("secretPath", "/"); + return null; + } + + for (let i = 0; i < segments.length; i += 1) { + const segment = segments[i]; + currentPath += `/${segment}`; + + if (currentLevel?.subFolders?.[segment]) { + currentLevel = currentLevel.subFolders[segment]; + + if (currentPath === normalizedPath) { + result = currentLevel; + break; + } + } else { + return null; + } + } + + return result; + }, [restructureSecrets, debouncedEnvCopySecretPath]); + + useEffect(() => { + setValue("secrets", []); + }, [debouncedEnvCopySecretPath, selectedEnvSlug]); + + const handleFormSubmit = async (data: TFormSchema) => { + const secretsToBePulled: Record< + string, + Record + > = {}; + data.secrets.forEach(({ secretKey, secretValue, secretPath: secretPathToRecreate }) => { + const normalizedPath = secretPathToRecreate.startsWith(envCopySecPath) + ? secretPathToRecreate.slice(envCopySecPath.length) + : secretPathToRecreate; + + if (!secretsToBePulled[normalizedPath]) { + secretsToBePulled[normalizedPath] = {}; + } + + secretsToBePulled[normalizedPath][secretKey] = { + value: (shouldIncludeValues && secretValue) || "", + comments: [""], + secretPath: normalizedPath + }; + }); + onParsedEnv(secretsToBePulled); + onToggle(false); + reset(); + }; + + return ( + { + onToggle(state); + reset(); + }} + > + +
+
+ ( + + option.name} + getOptionValue={(option) => option.slug} + /> + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ { + setValue("secrets", []); + setShouldIncludeValues(isChecked as boolean); + }} + > + Include secret values + +
+
+ + +
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/SecretTreeView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/SecretTreeView.tsx new file mode 100644 index 000000000..4119f54e3 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/SecretTreeView.tsx @@ -0,0 +1,321 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + faChevronDown, + faChevronRight, + faFolder, + faFolderOpen, + faFolderTree, + faKey +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +import { Checkbox } from "@app/components/v2"; + +interface SecretItem { + id?: string; + secretKey?: string; + secretValue?: string; + secretPath?: string; + [key: string]: any; +} + +interface FolderStructure { + items: SecretItem[]; + subFolders: { + [key: string]: FolderStructure; + }; +} + +interface TreeData { + [key: string]: FolderStructure | null; +} + +interface FolderProps { + name: string; + structure: FolderStructure; + path: string; + selectedItems: SecretItem[]; + onItemSelect: (item: SecretItem, isChecked: boolean) => void; + onFolderSelect: (folderPath: string, isChecked: boolean) => void; + isExpanded?: boolean; + level: number; + basePath?: string; +} + +interface TreeViewProps { + data: FolderStructure | null; + basePath?: string; + className?: string; + onChange: (items: SecretItem[]) => void; +} + +const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => { + let items: SecretItem[] = []; + + items = items.concat(folder.items); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Object.entries(folder.subFolders).forEach(([_, subFolder]) => { + items = items.concat(getAllItemsInFolder(subFolder)); + }); + + return items; +}; + +const getDisplayName = (name: string): string => { + const parts = name.split("/"); + return parts[parts.length - 1]; +}; + +const Collapsible = CollapsiblePrimitive.Root; +const CollapsibleTrigger = CollapsiblePrimitive.Trigger; +const CollapsibleContent = CollapsiblePrimitive.Content; + +const Folder: React.FC = ({ + name, + structure, + path, + selectedItems, + onItemSelect, + onFolderSelect, + isExpanded = false, + level, + basePath +}) => { + const [open, setOpen] = useState(isExpanded); + const displayName = useMemo(() => getDisplayName(name), [name]); + + const allItems = useMemo(() => getAllItemsInFolder(structure), [structure]); + const allItemIds = useMemo(() => allItems.map((item) => item.id), [allItems]); + const selectedItemIds = useMemo(() => selectedItems.map((item) => item.id), [selectedItems]); + const allSelected = useMemo( + () => allItemIds.length > 0 && allItemIds.every((id) => selectedItemIds.includes(id)), + [allItemIds, selectedItemIds] + ); + const someSelected = useMemo( + () => allItemIds.some((id) => selectedItemIds.includes(id)) && !allSelected, + [allItemIds, selectedItemIds, allSelected] + ); + const hasContents = structure.items.length > 0 || Object.keys(structure.subFolders).length > 0; + + const handleFolderSelect = (checked: boolean) => { + onFolderSelect(path, checked); + }; + + return ( +
0 ? "4" : 0}`}> + +
+ + + + +
+ 0 ? (open ? faFolderOpen : faFolder) : faFolderTree} + className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`} + /> +
+ + + + + {allItemIds.length > 0 && ( + + {allItemIds.length} {allItemIds.length === 1 ? "item" : "items"} + + )} +
+ + +
+
+ {structure.items.map((item) => ( +
+
+ +
+ onItemSelect(item, !!checked)} + /> + +
+ ))} + + {Object.entries(structure.subFolders).map(([subName, subStructure]) => ( + + ))} +
+ + +
+ ); +}; + +export const SecretTreeView: React.FC = ({ + data, + basePath = "/", + className = "", + onChange +}) => { + const [selectedItems, setSelectedItems] = useState([]); + const rootPath = "/"; + const treeData: TreeData = data ? { [rootPath]: data as FolderStructure } : { [rootPath]: null }; + + const rootFolders = useMemo(() => { + return Object.entries(treeData); + }, [treeData]); + + const isEmptyData = useMemo(() => { + return ( + !data || (typeof data === "object" && Object.keys(data).length === 0) || !rootFolders.length + ); + }, [data, rootFolders]); + + const handleItemSelect = (item: SecretItem, isChecked: boolean) => { + if (isChecked) { + setSelectedItems((prev) => [...prev, item]); + } else { + setSelectedItems((prev) => prev.filter((i) => i.id !== item.id)); + } + }; + + const handleFolderSelect = (folderPath: string, isChecked: boolean) => { + const getFolderFromPath = (tree: TreeData, path: string): FolderStructure | null => { + if (rootFolders.length === 1 && rootFolders[0][0] === path) { + return rootFolders[0][1]; + } + + let adjustedPath = path; + if (!path.startsWith(rootPath)) { + adjustedPath = rootPath === path ? rootPath : `${rootPath}/${path}`; + } + + if (adjustedPath === "/") return tree["/"]; + + const parts = adjustedPath.split("/").filter((p) => p !== ""); + + let current: any; + current = tree["/"]; + + const targetExists = parts.every((part) => { + if (current?.subFolders?.[part]) { + current = current.subFolders[part]; + return true; + } + return false; + }); + + if (!targetExists) { + return null; + } + + return current; + }; + + const folder = getFolderFromPath(treeData, folderPath); + if (!folder) return; + + const folderItems = getAllItemsInFolder(folder); + const folderItemIds = folderItems.map((item) => item.id); + + if (isChecked) { + setSelectedItems((prev) => { + const prevIds = prev.map((item) => item.id); + const newItems = [...prev]; + folderItems.forEach((item) => { + if (!prevIds.includes(item.id)) { + newItems.push(item); + } + }); + return newItems; + }); + } else { + setSelectedItems((prev) => prev.filter((item) => !folderItemIds.includes(item.id))); + } + }; + + useEffect(() => { + setSelectedItems([]); + }, [data]); + + useEffect(() => { + onChange(selectedItems); + }, [selectedItems]); + + return ( +
+
+
+ {isEmptyData ? ( +
+

No secrets or folders available

+
+ ) : ( + rootFolders.map(([folderName, folderStructure]) => ( + + )) + )} +
+ +
+

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

+
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx index e289c1894..de4043975 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -64,7 +64,8 @@ export const SecretDropzone = ({ const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({ options: { onSuccess: undefined } }); - + // hide copy secrets from board due to import folders feature + const shouldRenderCopySecrets = false; const isSubmitting = isCreatingSecrets || isUpdatingSecrets; const handleDrag = (e: DragEvent) => { @@ -308,16 +309,18 @@ export const SecretDropzone = ({ secretPath={secretPath} isSmaller={isSmaller} /> - handlePopUpToggle("importSecEnv", isOpen)} - onParsedEnv={handleParsedEnv} - environment={environment} - environments={environments} - workspaceId={workspaceId} - secretPath={secretPath} - isSmaller={isSmaller} - /> + {shouldRenderCopySecrets && ( + handlePopUpToggle("importSecEnv", isOpen)} + onParsedEnv={handleParsedEnv} + environment={environment} + environments={environments} + workspaceId={workspaceId} + secretPath={secretPath} + isSmaller={isSmaller} + /> + )} {!isSmaller && (