diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index e79e009af..56f7f904a 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1847,7 +1847,7 @@ export const secretServiceFactory = ({ if (isEmpty) { throw new BadRequestError({ - message: "No changes were detected between the source and destination." + message: "No changes were made. Secrets already exist in the destination." }); } diff --git a/frontend/src/hooks/api/secrets/index.ts b/frontend/src/hooks/api/secrets/index.ts index b58e8779a..737ebf41a 100644 --- a/frontend/src/hooks/api/secrets/index.ts +++ b/frontend/src/hooks/api/secrets/index.ts @@ -4,6 +4,7 @@ export { useCreateSecretV3, useDeleteSecretBatch, useDeleteSecretV3, + useMoveSecrets, useUpdateSecretBatch, useUpdateSecretV3 } from "./mutations"; diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index e397c7b55..898d99b8d 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -17,6 +17,7 @@ import { TCreateSecretsV3DTO, TDeleteSecretBatchDTO, TDeleteSecretsV3DTO, + TMoveSecretsDTO, TUpdateSecretBatchDTO, TUpdateSecretsV3DTO } from "./types"; @@ -87,11 +88,11 @@ export const useCreateSecretV3 = ({ const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -148,11 +149,11 @@ export const useUpdateSecretV3 = ({ const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -244,11 +245,11 @@ export const useCreateSecretBatch = ({ const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -297,11 +298,11 @@ export const useUpdateSecretBatch = ({ const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -375,6 +376,61 @@ export const useDeleteSecretBatch = ({ }); }; +export const useMoveSecrets = ({ + options +}: { + options?: Omit, "mutationFn">; +} = {}) => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TMoveSecretsDTO>({ + mutationFn: async ({ + sourceEnvironment, + sourceSecretPath, + projectSlug, + destinationEnvironment, + destinationSecretPath, + secretIds + }) => { + const { data } = await apiRequest.post("/api/v3/secrets/move", { + sourceEnvironment, + sourceSecretPath, + projectSlug, + destinationEnvironment, + destinationSecretPath, + secretIds + }); + + return data; + }, + onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => { + queryClient.invalidateQueries( + secretKeys.getProjectSecret({ + workspaceId: projectId, + environment: sourceEnvironment, + secretPath: sourceSecretPath + }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.list({ + environment: sourceEnvironment, + workspaceId: projectId, + directory: sourceSecretPath + }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ + environment: sourceEnvironment, + workspaceId: projectId, + directory: sourceSecretPath + }) + ); + queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId: projectId })); + }, + ...options + }); +}; + export const createSecret = async (dto: CreateSecretDTO) => { const { data } = await apiRequest.post(`/api/v3/secrets/${dto.secretKey}`, dto); return data; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 378405c96..f10907654 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -177,6 +177,16 @@ export type TDeleteSecretBatchDTO = { }>; }; +export type TMoveSecretsDTO = { + projectSlug: string; + projectId: string; + sourceEnvironment: string; + sourceSecretPath: string; + destinationEnvironment: string; + destinationSecretPath: string; + secretIds: string[]; +}; + export type CreateSecretDTO = { workspaceId: string; environment: string; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 627d27539..a04d61b36 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { subject } from "@casl/ability"; import { faAngleDown, + faAnglesRight, faCheckCircle, faChevronRight, faCodeCommit, @@ -46,7 +47,12 @@ import { import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; import { interpolateSecrets } from "@app/helpers/secret"; import { usePopUp } from "@app/hooks"; -import { useCreateFolder, useDeleteSecretBatch, useGetUserWsKey } from "@app/hooks/api"; +import { + useCreateFolder, + useDeleteSecretBatch, + useGetUserWsKey, + useMoveSecrets +} from "@app/hooks/api"; import { DecryptedSecret, SecretType, TImportedSecrets, WsTag } from "@app/hooks/api/types"; import { debounce } from "@app/lib/fn/debounce"; @@ -60,6 +66,7 @@ import { Filter, GroupBy } from "../../SecretMainPage.types"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { FolderForm } from "./FolderForm"; +import { MoveSecretsModal } from "./MoveSecretsModal"; type Props = { secrets?: DecryptedSecret[]; @@ -105,6 +112,7 @@ export const ActionBar = ({ "addDynamicSecret", "addSecretImport", "bulkDeleteSecrets", + "moveSecrets", "misc", "upgradePlan" ] as const); @@ -114,6 +122,7 @@ export const ActionBar = ({ const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { mutateAsync: moveSecrets } = useMoveSecrets(); const { data: decryptFileKey } = useGetUserWsKey(workspaceId); const selectedSecrets = useSelectedSecrets(); @@ -228,6 +237,37 @@ export const ActionBar = ({ } }; + const handleSecretsMove = async ({ + destinationEnvironment, + destinationSecretPath + }: { + destinationEnvironment: string; + destinationSecretPath: string; + }) => { + try { + const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id])); + await moveSecrets({ + projectSlug, + sourceEnvironment: environment, + sourceSecretPath: secretPath, + destinationEnvironment, + destinationSecretPath, + projectId: workspaceId, + secretIds: secretsToMove.map((sec) => sec.id) + }); + + createNotification({ + type: "success", + text: "Successfully moved selected secrets" + }); + } catch (error) { + createNotification({ + type: "error", + text: "Error moving selected secrets" + }); + } + }; + return ( <>
@@ -455,6 +495,25 @@ export const ActionBar = ({
{Object.keys(selectedSecrets).length} Selected
+ + {(isAllowed) => ( + + )} + } - className="ml-4" + className="ml-2" onClick={() => handlePopUpOpen("bulkDeleteSecrets")} isDisabled={!isAllowed} size="xs" @@ -509,6 +568,11 @@ export const ActionBar = ({ onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)} onDeleteApproved={handleSecretBulkDelete} /> + {subscription && ( ; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["moveSecrets"]>, state?: boolean) => void; + onMoveApproved: (moveParams: { + destinationEnvironment: string; + destinationSecretPath: string; + }) => void; +}; + +const formSchema = z.object({ + environment: z.string().trim(), + secretPath: z + .string() + .trim() + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ) +}); + +type TFormSchema = z.infer; + +export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: Props) => { + const { + handleSubmit, + control, + reset, + watch, + formState: { isSubmitting } + } = useForm({ resolver: zodResolver(formSchema) }); + + const { currentWorkspace } = useWorkspace(); + const environments = currentWorkspace?.environments || []; + const selectedEnvironment = watch("environment"); + + const handleFormSubmit = (data: TFormSchema) => { + onMoveApproved({ + destinationEnvironment: data.environment, + destinationSecretPath: data.secretPath + }); + + handlePopUpToggle("moveSecrets", false); + }; + + return ( + { + reset(); + handlePopUpToggle("moveSecrets", isOpen); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +};