mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: initial move secret integration
This commit is contained in:
@@ -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."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
useCreateSecretV3,
|
||||
useDeleteSecretBatch,
|
||||
useDeleteSecretV3,
|
||||
useMoveSecrets,
|
||||
useUpdateSecretBatch,
|
||||
useUpdateSecretV3
|
||||
} from "./mutations";
|
||||
|
||||
@@ -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<MutationOptions<{}, {}, TMoveSecretsDTO>, "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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
@@ -455,6 +495,25 @@ export const ActionBar = ({
|
||||
<div className="ml-4 flex-grow px-2 text-sm">
|
||||
{Object.keys(selectedSecrets).length} Selected
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Move"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faAnglesRight} />}
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpOpen("moveSecrets")}
|
||||
isDisabled={!isAllowed}
|
||||
size="xs"
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
@@ -466,7 +525,7 @@ export const ActionBar = ({
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
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}
|
||||
/>
|
||||
<MoveSecretsModal
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
onMoveApproved={handleSecretsMove}
|
||||
/>
|
||||
{subscription && (
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
|
||||
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["moveSecrets"]>;
|
||||
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<typeof formSchema>;
|
||||
|
||||
export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: Props) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormSchema>({ 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 (
|
||||
<Modal
|
||||
isOpen={popUp.moveSecrets.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
reset();
|
||||
handlePopUpToggle("moveSecrets", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title="Move Secrets"
|
||||
subTitle="Move selected secrets from current path to selected destination"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{environments.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||
<SecretPathInput {...field} environment={selectedEnvironment} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-7 flex items-center">
|
||||
<Button
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
key="move-secrets-submit"
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
<Button
|
||||
key="move-secrets-cancel"
|
||||
onClick={() => handlePopUpToggle("moveSecrets", false)}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user