mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 05:27:48 +00:00
Add replicate folder content functionality
This commit is contained in:
@@ -845,6 +845,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
|||||||
projectId: z.string().trim(),
|
projectId: z.string().trim(),
|
||||||
environment: z.string().trim(),
|
environment: z.string().trim(),
|
||||||
secretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
secretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||||
|
recursive: booleanSchema.default(false),
|
||||||
filterByAction: z
|
filterByAction: z
|
||||||
.enum([ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.ReadValue])
|
.enum([ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.ReadValue])
|
||||||
.default(ProjectPermissionSecretActions.ReadValue)
|
.default(ProjectPermissionSecretActions.ReadValue)
|
||||||
@@ -863,7 +864,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
|||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
handler: async (req) => {
|
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({
|
const { secrets } = await server.services.secret.getAccessibleSecrets({
|
||||||
actorId: req.permission.id,
|
actorId: req.permission.id,
|
||||||
@@ -873,7 +874,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
|||||||
environment,
|
environment,
|
||||||
secretPath,
|
secretPath,
|
||||||
projectId,
|
projectId,
|
||||||
filterByAction
|
filterByAction,
|
||||||
|
recursive
|
||||||
});
|
});
|
||||||
|
|
||||||
return { secrets };
|
return { secrets };
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ export const fnSecretBulkDelete = async ({
|
|||||||
interface FolderMap {
|
interface FolderMap {
|
||||||
[parentId: string]: TSecretFolders[];
|
[parentId: string]: TSecretFolders[];
|
||||||
}
|
}
|
||||||
const buildHierarchy = (folders: TSecretFolders[]): FolderMap => {
|
export const buildHierarchy = (folders: TSecretFolders[]): FolderMap => {
|
||||||
const map: FolderMap = {};
|
const map: FolderMap = {};
|
||||||
map.null = []; // Initialize mapping for root directory
|
map.null = []; // Initialize mapping for root directory
|
||||||
|
|
||||||
@@ -371,7 +371,7 @@ const buildHierarchy = (folders: TSecretFolders[]): FolderMap => {
|
|||||||
return map;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
const generatePaths = (
|
export const generatePaths = (
|
||||||
map: FolderMap,
|
map: FolderMap,
|
||||||
parentId: string = "null",
|
parentId: string = "null",
|
||||||
basePath: string = "",
|
basePath: string = "",
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns";
|
|||||||
import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal";
|
import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal";
|
||||||
import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal";
|
import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal";
|
||||||
import {
|
import {
|
||||||
|
buildHierarchy,
|
||||||
expandSecretReferencesFactory,
|
expandSecretReferencesFactory,
|
||||||
fnSecretBulkDelete,
|
fnSecretBulkDelete,
|
||||||
fnSecretBulkInsert,
|
fnSecretBulkInsert,
|
||||||
fnSecretBulkUpdate,
|
fnSecretBulkUpdate,
|
||||||
|
generatePaths,
|
||||||
getAllSecretReferences,
|
getAllSecretReferences,
|
||||||
recursivelyGetSecretPaths,
|
recursivelyGetSecretPaths,
|
||||||
reshapeBridgeSecret
|
reshapeBridgeSecret
|
||||||
@@ -2620,7 +2622,8 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
actorId,
|
actorId,
|
||||||
actor,
|
actor,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
actorOrgId
|
actorOrgId,
|
||||||
|
recursive
|
||||||
}: TGetAccessibleSecretsDTO) => {
|
}: TGetAccessibleSecretsDTO) => {
|
||||||
const { permission } = await permissionService.getProjectPermission({
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
actor,
|
actor,
|
||||||
@@ -2635,10 +2638,38 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
secretPath
|
secretPath
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const folders = [];
|
||||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||||
if (!folder) return { secrets: [] };
|
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({
|
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||||
type: KmsDataKey.SecretManager,
|
type: KmsDataKey.SecretManager,
|
||||||
@@ -2650,7 +2681,7 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
if (
|
if (
|
||||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||||
environment,
|
environment,
|
||||||
secretPath,
|
secretPath: paths[el.folderId],
|
||||||
secretName: el.key,
|
secretName: el.key,
|
||||||
secretTags: el.tags.map((i) => i.slug)
|
secretTags: el.tags.map((i) => i.slug)
|
||||||
})
|
})
|
||||||
@@ -2661,7 +2692,7 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
if (filterByAction === ProjectPermissionSecretActions.ReadValue) {
|
if (filterByAction === ProjectPermissionSecretActions.ReadValue) {
|
||||||
return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||||
environment,
|
environment,
|
||||||
secretPath,
|
secretPath: paths[el.folderId],
|
||||||
secretName: el.key,
|
secretName: el.key,
|
||||||
secretTags: el.tags.map((i) => i.slug)
|
secretTags: el.tags.map((i) => i.slug)
|
||||||
});
|
});
|
||||||
@@ -2674,7 +2705,7 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
filterByAction === ProjectPermissionSecretActions.DescribeSecret &&
|
filterByAction === ProjectPermissionSecretActions.DescribeSecret &&
|
||||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||||
environment,
|
environment,
|
||||||
secretPath,
|
secretPath: paths[secret.folderId],
|
||||||
secretName: secret.key,
|
secretName: secret.key,
|
||||||
secretTags: secret.tags.map((i) => i.slug)
|
secretTags: secret.tags.map((i) => i.slug)
|
||||||
});
|
});
|
||||||
@@ -2682,7 +2713,7 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
return reshapeBridgeSecret(
|
return reshapeBridgeSecret(
|
||||||
projectId,
|
projectId,
|
||||||
environment,
|
environment,
|
||||||
secretPath,
|
paths[secret.folderId],
|
||||||
{
|
{
|
||||||
...secret,
|
...secret,
|
||||||
value: secret.encryptedValue
|
value: secret.encryptedValue
|
||||||
|
|||||||
@@ -356,5 +356,6 @@ export type TGetAccessibleSecretsDTO = {
|
|||||||
environment: string;
|
environment: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
|
recursive?: boolean;
|
||||||
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
||||||
} & TProjectPermission;
|
} & TProjectPermission;
|
||||||
|
|||||||
@@ -1321,7 +1321,8 @@ export const secretServiceFactory = ({
|
|||||||
actorOrgId,
|
actorOrgId,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
environment,
|
environment,
|
||||||
filterByAction
|
filterByAction,
|
||||||
|
recursive
|
||||||
}: TGetAccessibleSecretsDTO) => {
|
}: TGetAccessibleSecretsDTO) => {
|
||||||
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||||
|
|
||||||
@@ -1340,7 +1341,8 @@ export const secretServiceFactory = ({
|
|||||||
actor,
|
actor,
|
||||||
actorId,
|
actorId,
|
||||||
actorOrgId,
|
actorOrgId,
|
||||||
actorAuthMethod
|
actorAuthMethod,
|
||||||
|
recursive
|
||||||
});
|
});
|
||||||
|
|
||||||
return secrets;
|
return secrets;
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export enum SecretsOrderBy {
|
|||||||
export type TGetAccessibleSecretsDTO = {
|
export type TGetAccessibleSecretsDTO = {
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
recursive?: boolean;
|
||||||
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
||||||
} & TProjectPermission;
|
} & TProjectPermission;
|
||||||
|
|
||||||
|
|||||||
@@ -313,12 +313,13 @@ const fetchAccessibleSecrets = async ({
|
|||||||
projectId,
|
projectId,
|
||||||
secretPath,
|
secretPath,
|
||||||
environment,
|
environment,
|
||||||
filterByAction
|
filterByAction,
|
||||||
|
recursive = false
|
||||||
}: TGetAccessibleSecretsDTO) => {
|
}: TGetAccessibleSecretsDTO) => {
|
||||||
const { data } = await apiRequest.get<{ secrets: SecretV3Raw[] }>(
|
const { data } = await apiRequest.get<{ secrets: SecretV3Raw[] }>(
|
||||||
"/api/v1/dashboard/accessible-secrets",
|
"/api/v1/dashboard/accessible-secrets",
|
||||||
{
|
{
|
||||||
params: { projectId, secretPath, environment, filterByAction }
|
params: { projectId, secretPath, environment, filterByAction, recursive }
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -393,7 +394,8 @@ export const useGetAccessibleSecrets = ({
|
|||||||
secretPath,
|
secretPath,
|
||||||
environment,
|
environment,
|
||||||
filterByAction,
|
filterByAction,
|
||||||
options
|
options,
|
||||||
|
recursive = false
|
||||||
}: TGetAccessibleSecretsDTO & {
|
}: TGetAccessibleSecretsDTO & {
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
UseQueryOptions<
|
UseQueryOptions<
|
||||||
@@ -411,8 +413,10 @@ export const useGetAccessibleSecrets = ({
|
|||||||
projectId,
|
projectId,
|
||||||
secretPath,
|
secretPath,
|
||||||
environment,
|
environment,
|
||||||
filterByAction
|
filterByAction,
|
||||||
|
recursive
|
||||||
}),
|
}),
|
||||||
queryFn: () => fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction })
|
queryFn: () =>
|
||||||
|
fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction, recursive })
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export type TGetAccessibleSecretsDTO = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
recursive?: boolean;
|
||||||
filterByAction:
|
filterByAction:
|
||||||
| ProjectPermissionSecretActions.DescribeSecret
|
| ProjectPermissionSecretActions.DescribeSecret
|
||||||
| ProjectPermissionSecretActions.ReadValue;
|
| ProjectPermissionSecretActions.ReadValue;
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export type TGetProjectSecretsKey = {
|
|||||||
includeImports?: boolean;
|
includeImports?: boolean;
|
||||||
viewSecretValue?: boolean;
|
viewSecretValue?: boolean;
|
||||||
expandSecretReferences?: boolean;
|
expandSecretReferences?: boolean;
|
||||||
|
recursive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TGetProjectSecretsDTO = TGetProjectSecretsKey;
|
export type TGetProjectSecretsDTO = TGetProjectSecretsKey;
|
||||||
|
|||||||
+382
-3
@@ -17,10 +17,12 @@ import {
|
|||||||
faKey,
|
faKey,
|
||||||
faLock,
|
faLock,
|
||||||
faMinusSquare,
|
faMinusSquare,
|
||||||
|
faPaste,
|
||||||
faPlus,
|
faPlus,
|
||||||
faTrash
|
faTrash
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import FileSaver from "file-saver";
|
import FileSaver from "file-saver";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
@@ -53,8 +55,19 @@ import {
|
|||||||
useWorkspace
|
useWorkspace
|
||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
|
import {
|
||||||
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
|
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 { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
|
||||||
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
|
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
|
||||||
|
|
||||||
@@ -65,11 +78,19 @@ import {
|
|||||||
useSelectedSecrets
|
useSelectedSecrets
|
||||||
} from "../../SecretMainPage.store";
|
} from "../../SecretMainPage.store";
|
||||||
import { Filter, RowType } from "../../SecretMainPage.types";
|
import { Filter, RowType } from "../../SecretMainPage.types";
|
||||||
|
import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard";
|
||||||
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||||
import { FolderForm } from "./FolderForm";
|
import { FolderForm } from "./FolderForm";
|
||||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
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 = {
|
type Props = {
|
||||||
// switch the secrets type as it gets decrypted after api call
|
// switch the secrets type as it gets decrypted after api call
|
||||||
environment: string;
|
environment: string;
|
||||||
@@ -114,7 +135,9 @@ export const ActionBar = ({
|
|||||||
"bulkDeleteSecrets",
|
"bulkDeleteSecrets",
|
||||||
"moveSecrets",
|
"moveSecrets",
|
||||||
"misc",
|
"misc",
|
||||||
"upgradePlan"
|
"upgradePlan",
|
||||||
|
"replicateFolder",
|
||||||
|
"confirmUpload"
|
||||||
] as const);
|
] as const);
|
||||||
const isProtectedBranch = Boolean(protectedBranchPolicyName);
|
const isProtectedBranch = Boolean(protectedBranchPolicyName);
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
@@ -122,6 +145,13 @@ export const ActionBar = ({
|
|||||||
const { mutateAsync: createFolder } = useCreateFolder();
|
const { mutateAsync: createFolder } = useCreateFolder();
|
||||||
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||||
const { mutateAsync: moveSecrets } = useMoveSecrets();
|
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 selectedSecrets = useSelectedSecrets();
|
||||||
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
||||||
@@ -293,6 +323,271 @@ 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 { secrets: existingSecrets } = await fetchDashboardProjectSecretsByKeys({
|
||||||
|
secretPath: normalizedPath,
|
||||||
|
environment,
|
||||||
|
projectId: workspaceId,
|
||||||
|
keys: secretFolderKeys
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a quick lookup for existing secrets
|
||||||
|
const existingSecretLookup = existingSecrets.reduce<Record<string, boolean>>(
|
||||||
|
(lookup, secret) => ({ ...lookup, [secret.secretKey]: true }),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 flex items-center space-x-2">
|
<div className="mt-4 flex items-center space-x-2">
|
||||||
@@ -570,6 +865,29 @@ export const ActionBar = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</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>
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -679,6 +997,15 @@ export const ActionBar = ({
|
|||||||
handlePopUpToggle={handlePopUpToggle}
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
onMoveApproved={handleSecretsMove}
|
onMoveApproved={handleSecretsMove}
|
||||||
/>
|
/>
|
||||||
|
<ReplicateFolderFromBoard
|
||||||
|
isOpen={popUp.replicateFolder.isOpen}
|
||||||
|
onToggle={(isOpen) => handlePopUpToggle("replicateFolder", isOpen)}
|
||||||
|
onParsedEnv={handleParsedEnvMultiFolder}
|
||||||
|
environment={environment}
|
||||||
|
environments={currentWorkspace.environments}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
secretPath={secretPath}
|
||||||
|
/>
|
||||||
{subscription && (
|
{subscription && (
|
||||||
<UpgradePlanModal
|
<UpgradePlanModal
|
||||||
isOpen={popUp.upgradePlan.isOpen}
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
@@ -690,6 +1017,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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+307
@@ -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 && 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
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-${path}`}
|
||||||
|
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">
|
||||||
|
{allItemIds.length} {allItemIds.length === 1 ? "item" : "items"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent className="overflow-hidden transition-all duration-300 ease-in-out">
|
||||||
|
<div className="mt-1">
|
||||||
|
{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-${path}`}
|
||||||
|
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 && 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="pb-2 pl-2 pt-2">
|
||||||
|
<h3 className="flex items-center">
|
||||||
|
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user