mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-09 07:28:09 -05:00
Merge pull request #3289 from Infisical/feat/addReplicateFolderContent
Add replicate folder content functionality
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -356,5 +356,6 @@ export type TGetAccessibleSecretsDTO = {
|
||||
environment: string;
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
recursive?: boolean;
|
||||
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
||||
} & TProjectPermission;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -184,6 +184,7 @@ export enum SecretsOrderBy {
|
||||
export type TGetAccessibleSecretsDTO = {
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
recursive?: boolean;
|
||||
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
||||
} & TProjectPermission;
|
||||
|
||||
|
||||
@@ -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 })
|
||||
});
|
||||
};
|
||||
|
||||
@@ -111,6 +111,7 @@ export type TGetAccessibleSecretsDTO = {
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
recursive?: boolean;
|
||||
filterByAction:
|
||||
| ProjectPermissionSecretActions.DescribeSecret
|
||||
| ProjectPermissionSecretActions.ReadValue;
|
||||
|
||||
@@ -121,6 +121,7 @@ export type TGetProjectSecretsKey = {
|
||||
includeImports?: boolean;
|
||||
viewSecretValue?: boolean;
|
||||
expandSecretReferences?: boolean;
|
||||
recursive?: boolean;
|
||||
};
|
||||
|
||||
export type TGetProjectSecretsDTO = TGetProjectSecretsKey;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user