Merge pull request #3289 from Infisical/feat/addReplicateFolderContent

Add replicate folder content functionality
This commit is contained in:
carlosmonastyrski
2025-04-01 14:59:22 -03:00
committed by GitHub
13 changed files with 1098 additions and 31 deletions

View File

@@ -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 };

View File

@@ -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 = "",

View File

@@ -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

View File

@@ -356,5 +356,6 @@ export type TGetAccessibleSecretsDTO = {
environment: string;
projectId: string;
secretPath: string;
recursive?: boolean;
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
} & TProjectPermission;

View File

@@ -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;

View File

@@ -184,6 +184,7 @@ export enum SecretsOrderBy {
export type TGetAccessibleSecretsDTO = {
secretPath: string;
environment: string;
recursive?: boolean;
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
} & TProjectPermission;

View File

@@ -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 })
});
};

View File

@@ -111,6 +111,7 @@ export type TGetAccessibleSecretsDTO = {
projectId: string;
secretPath: string;
environment: string;
recursive?: boolean;
filterByAction:
| ProjectPermissionSecretActions.DescribeSecret
| ProjectPermissionSecretActions.ReadValue;

View File

@@ -121,6 +121,7 @@ export type TGetProjectSecretsKey = {
includeImports?: boolean;
viewSecretValue?: boolean;
expandSecretReferences?: boolean;
recursive?: boolean;
};
export type TGetProjectSecretsDTO = TGetProjectSecretsKey;

View File

@@ -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<string, { value: string; comments: string[]; secretPath?: string }>;
type TParsedFolderEnv = Record<
string,
Record<string, { value: string; comments: string[]; secretPath?: string }>
>;
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<string, boolean> = {};
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<string>();
// 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<string>();
// 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 (
<>
<div className="mt-4 flex items-center space-x-2">
@@ -570,6 +879,29 @@ export const ActionBar = ({
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.SecretFolders, {
environment,
secretPath
})}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faPaste} className="pr-2" />}
onClick={() => {
handlePopUpOpen("replicateFolder");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Replicate Folder
</Button>
)}
</ProjectPermissionCan>
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -679,6 +1011,15 @@ export const ActionBar = ({
handlePopUpToggle={handlePopUpToggle}
onMoveApproved={handleSecretsMove}
/>
<ReplicateFolderFromBoard
isOpen={popUp.replicateFolder.isOpen}
onToggle={(isOpen) => handlePopUpToggle("replicateFolder", isOpen)}
onParsedEnv={handleParsedEnvMultiFolder}
environment={environment}
environments={currentWorkspace.environments}
workspaceId={workspaceId}
secretPath={secretPath}
/>
{subscription && (
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
@@ -690,6 +1031,58 @@ export const ActionBar = ({
}
/>
)}
<Modal
isOpen={popUp?.confirmUpload?.isOpen}
onOpenChange={(open) => handlePopUpToggle("confirmUpload", open)}
>
<ModalContent
title="Confirm Secret Upload"
footerContent={[
<Button
isLoading={isSubmitting}
isDisabled={isSubmitting}
colorSchema={isNonConflictingUpload ? "primary" : "danger"}
key="overwrite-btn"
onClick={handleSaveFolderImport}
>
{isNonConflictingUpload ? "Upload" : "Overwrite"}
</Button>,
<Button
key="keep-old-btn"
className="ml-4"
onClick={() => handlePopUpClose("confirmUpload")}
variant="outline_bg"
isDisabled={isSubmitting}
>
Cancel
</Button>
]}
>
{isNonConflictingUpload ? (
<div>
Are you sure you want to import {createSecretCount} secret
{createSecretCount > 1 ? "s" : ""} to this environment?
</div>
) : (
<div className="flex flex-col text-gray-300">
<div>Your project already contains the following {updateSecretCount} secrets:</div>
<div className="mt-2 text-sm text-gray-400">
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {})
?.map((key) => key)
.join(", ")}
</div>
<div className="mt-6">
Are you sure you want to overwrite these secrets
{createSecretCount > 0
? ` and import ${createSecretCount} new
one${createSecretCount > 1 ? "s" : ""}`
: ""}
?
</div>
</div>
)}
</ModalContent>
</Modal>
</>
);
};

View File

@@ -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<typeof formSchema>;
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
onParsedEnv: (
env: Record<string, Record<string, { value: string; comments: string[]; secretPath?: string }>>
) => void;
environments?: { name: string; slug: string }[];
workspaceId: string;
environment: string;
secretPath: string;
};
type SecretFolder = {
items: Partial<SecretV3Raw>[];
subFolders: Record<string, SecretFolder>;
};
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<TFormSchema>({
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<string, { value: string; comments: string[]; secretPath: string }>
> = {};
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 (
<Modal
isOpen={isOpen}
onOpenChange={(state) => {
onToggle(state);
reset();
}}
>
<ModalContent
bodyClassName="overflow-visible"
className="max-w-2xl"
title="Replicate Folder Content From An Environment"
subTitle="Replicate folder content from other environments into this context"
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="environment"
render={({ field: { value, onChange } }) => (
<FormControl label="Environment" isRequired className="w-1/3">
<FilterableSelect
value={value}
onChange={onChange}
options={environments}
placeholder="Select environment..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
render={({ field }) => (
<FormControl label="Secret Path" className="flex-grow" isRequired>
<SecretPathInput
{...field}
placeholder="Provide a path, default is /"
environment={selectedEnvSlug?.slug}
/>
</FormControl>
)}
/>
</div>
<div className="border-t border-mineshaft-600 pt-4">
<Controller
control={control}
name="secrets"
render={({ field: { onChange } }) => (
<FormControl className="flex-grow" isRequired>
<SecretTreeView
data={secretsFilteredByPath}
basePath={debouncedEnvCopySecretPath}
onChange={onChange}
/>
</FormControl>
)}
/>
<div className="my-6 ml-2">
<Switch
id="populate-include-value"
isChecked={shouldIncludeValues}
onCheckedChange={(isChecked) => {
setValue("secrets", []);
setShouldIncludeValues(isChecked as boolean);
}}
>
Include secret values
</Switch>
</div>
<div className="flex items-center space-x-4">
<Button
leftIcon={<FontAwesomeIcon icon={faClone} />}
type="submit"
isDisabled={!selectedSecrets || selectedSecrets.length === 0}
>
Replicate Folder
</Button>
<Button variant="plain" colorSchema="secondary" onClick={() => onToggle(false)}>
Cancel
</Button>
</div>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -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<FolderProps> = ({
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 (
<div className={`folder-container ml-${level > 0 ? "4" : 0}`}>
<Collapsible open={open} onOpenChange={setOpen}>
<div className="group flex items-center rounded px-2 py-1">
<CollapsibleTrigger asChild>
<button
type="button"
className="mr-1 flex h-6 w-6 items-center justify-center rounded focus:outline-none"
disabled={!hasContents}
aria-label={open ? "Collapse folder" : "Expand folder"}
>
{hasContents && (
<FontAwesomeIcon icon={open ? faChevronDown : faChevronRight} className="h-3 w-3" />
)}
</button>
</CollapsibleTrigger>
<div className="mr-2">
<FontAwesomeIcon
// eslint-disable-next-line no-nested-ternary
icon={level > 0 ? (open ? faFolderOpen : faFolder) : faFolderTree}
className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`}
/>
</div>
<Checkbox
id="folder-root"
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={allSelected || someSelected}
onCheckedChange={handleFolderSelect}
isIndeterminate={someSelected && !allSelected}
/>
<label
htmlFor={`folder-${path}`}
className={`ml-2 flex-1 cursor-pointer truncate ${basePath ? "italic text-mineshaft-300" : ""}`}
title={displayName}
>
{displayName || `${basePath}`}
</label>
{allItemIds.length > 0 && (
<span className="ml-2 text-xs text-mineshaft-400">
{allItemIds.length} {allItemIds.length === 1 ? "item" : "items"}
</span>
)}
</div>
<CollapsibleContent className="overflow-hidden transition-all duration-300 ease-in-out">
<div className="relative mt-1">
<div className="absolute bottom-0 left-5 top-0 w-px bg-mineshaft-600" />
{structure.items.map((item) => (
<div key={item.id} className="group ml-6 flex items-center rounded px-2 py-1">
<div className="ml-6 mr-2">
<FontAwesomeIcon icon={faKey} className="h-3 w-3" />
</div>
<Checkbox
id={`folder-${item.id}`}
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={selectedItemIds.includes(item.id)}
onCheckedChange={(checked) => onItemSelect(item, !!checked)}
/>
<label
htmlFor={item.id}
className="ml-2 flex-1 cursor-pointer truncate"
title={item.secretKey}
>
{item.secretKey}
</label>
</div>
))}
{Object.entries(structure.subFolders).map(([subName, subStructure]) => (
<Folder
key={subName}
name={subName}
structure={subStructure}
path={path ? `${path}/${subName}` : subName}
selectedItems={selectedItems}
onItemSelect={onItemSelect}
onFolderSelect={onFolderSelect}
level={level + 1}
/>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
export const SecretTreeView: React.FC<TreeViewProps> = ({
data,
basePath = "/",
className = "",
onChange
}) => {
const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]);
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 (
<div className="flex w-full items-start gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900">
<div className={`w-full rounded-lg shadow-sm ${className}`}>
<div className="h-[25vh] overflow-auto p-3">
{isEmptyData ? (
<div className="flex h-full w-full items-center justify-center text-center text-mineshaft-300">
<p>No secrets or folders available</p>
</div>
) : (
rootFolders.map(([folderName, folderStructure]) => (
<Folder
basePath={basePath}
key={folderName}
name={folderName}
structure={folderStructure || { items: [], subFolders: {} }}
path={folderName}
selectedItems={selectedItems}
onItemSelect={handleItemSelect}
onFolderSelect={handleFolderSelect}
isExpanded
level={0}
/>
))
)}
</div>
<div className="flex justify-end pb-2 pr-2 pt-2">
<h3 className="flex items-center text-mineshaft-400">
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
</h3>
</div>
</div>
</div>
);
};

View File

@@ -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}
/>
<CopySecretsFromBoard
isOpen={popUp.importSecEnv.isOpen}
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
onParsedEnv={handleParsedEnv}
environment={environment}
environments={environments}
workspaceId={workspaceId}
secretPath={secretPath}
isSmaller={isSmaller}
/>
{shouldRenderCopySecrets && (
<CopySecretsFromBoard
isOpen={popUp.importSecEnv.isOpen}
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
onParsedEnv={handleParsedEnv}
environment={environment}
environments={environments}
workspaceId={workspaceId}
secretPath={secretPath}
isSmaller={isSmaller}
/>
)}
{!isSmaller && (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}