mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-10 05:28:29 +00:00
feat: fixed UI and added permissions check to backend
This commit is contained in:
@@ -1190,6 +1190,7 @@ export const registerRoutes = async (
|
|||||||
projectService,
|
projectService,
|
||||||
orgService,
|
orgService,
|
||||||
projectEnvService,
|
projectEnvService,
|
||||||
|
permissionService,
|
||||||
secretService
|
secretService
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { OrgMembershipRole } from "@app/db/schemas";
|
||||||
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
|
import { ForbiddenRequestError } from "@app/lib/errors";
|
||||||
|
|
||||||
import { TOrgServiceFactory } from "../org/org-service";
|
import { TOrgServiceFactory } from "../org/org-service";
|
||||||
import { TProjectServiceFactory } from "../project/project-service";
|
import { TProjectServiceFactory } from "../project/project-service";
|
||||||
import { TProjectEnvServiceFactory } from "../project-env/project-env-service";
|
import { TProjectEnvServiceFactory } from "../project-env/project-env-service";
|
||||||
@@ -10,6 +14,7 @@ type TExternalMigrationServiceFactoryDep = {
|
|||||||
orgService: TOrgServiceFactory;
|
orgService: TOrgServiceFactory;
|
||||||
projectEnvService: TProjectEnvServiceFactory;
|
projectEnvService: TProjectEnvServiceFactory;
|
||||||
secretService: TSecretServiceFactory;
|
secretService: TSecretServiceFactory;
|
||||||
|
permissionService: TPermissionServiceFactory;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
|
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
|
||||||
@@ -18,6 +23,7 @@ export const externalMigrationServiceFactory = ({
|
|||||||
projectService,
|
projectService,
|
||||||
orgService,
|
orgService,
|
||||||
projectEnvService,
|
projectEnvService,
|
||||||
|
permissionService,
|
||||||
secretService
|
secretService
|
||||||
}: TExternalMigrationServiceFactoryDep) => {
|
}: TExternalMigrationServiceFactoryDep) => {
|
||||||
const importEnvKeyData = async ({
|
const importEnvKeyData = async ({
|
||||||
@@ -28,6 +34,18 @@ export const externalMigrationServiceFactory = ({
|
|||||||
actorOrgId,
|
actorOrgId,
|
||||||
actorAuthMethod
|
actorAuthMethod
|
||||||
}: TImportEnvKeyDataCreate) => {
|
}: TImportEnvKeyDataCreate) => {
|
||||||
|
const { membership } = await permissionService.getOrgPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (membership.role !== OrgMembershipRole.Admin) {
|
||||||
|
throw new ForbiddenRequestError({ message: "Only admins can import data" });
|
||||||
|
}
|
||||||
|
|
||||||
const json = await decryptEnvKeyDataFn(decryptionKey, encryptedJson);
|
const json = await decryptEnvKeyDataFn(decryptionKey, encryptedJson);
|
||||||
const envKeyData = await parseEnvKeyDataFn(json);
|
const envKeyData = await parseEnvKeyDataFn(json);
|
||||||
const response = await importDataIntoInfisicalFn({
|
const response = await importDataIntoInfisicalFn({
|
||||||
|
|||||||
@@ -5,23 +5,27 @@ import { apiRequest } from "@app/config/request";
|
|||||||
import { workspaceKeys } from "../workspace";
|
import { workspaceKeys } from "../workspace";
|
||||||
|
|
||||||
export const useImportEnvKey = () => {
|
export const useImportEnvKey = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ encryptedJson, decryptionKey }: {
|
mutationFn: async ({
|
||||||
encryptedJson: {
|
encryptedJson,
|
||||||
nonce: string,
|
decryptionKey
|
||||||
data: string
|
}: {
|
||||||
}, decryptionKey: string
|
encryptedJson: {
|
||||||
}): Promise<{ success: boolean, message: string }> => {
|
nonce: string;
|
||||||
const { data } = await apiRequest.post("/api/v3/migrate/env-key/", {
|
data: string;
|
||||||
encryptedJson,
|
};
|
||||||
decryptionKey
|
decryptionKey: string;
|
||||||
});
|
}) => {
|
||||||
return data;
|
const { data } = await apiRequest.post("/api/v3/migrate/env-key/", {
|
||||||
},
|
encryptedJson,
|
||||||
onSuccess: () => {
|
decryptionKey
|
||||||
queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
|
});
|
||||||
}
|
return data;
|
||||||
});
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import React, {
|
||||||
|
ChangeEvent,
|
||||||
|
DragEvent,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
|
import { faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { useToggle } from "@app/hooks";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
accept?: string;
|
||||||
|
onData: (file: File) => void;
|
||||||
|
isSmaller: boolean;
|
||||||
|
text?: string;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GenericDropzone = forwardRef<HTMLInputElement, Props>(
|
||||||
|
({ onData, isSmaller, text, isDisabled, accept }: Props, ref): JSX.Element => {
|
||||||
|
const [isDragActive, setDragActive] = useToggle();
|
||||||
|
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
|
||||||
|
|
||||||
|
const updateSelectedFileName = () => {
|
||||||
|
if (inputRef.current?.files?.[0]) {
|
||||||
|
setSelectedFileName(inputRef.current.files[0].name);
|
||||||
|
} else {
|
||||||
|
setSelectedFileName(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrag = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
setDragActive.on();
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
setDragActive.off();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!e.dataTransfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
setDragActive.off();
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
onData(file);
|
||||||
|
setSelectedFileName(file.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!e.target?.files?.[0]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onData(e.target.files[0]);
|
||||||
|
updateSelectedFileName();
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
updateSelectedFileName();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={twMerge(
|
||||||
|
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 py-4 px-2 text-sm text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100",
|
||||||
|
isDragActive && "opacity-100",
|
||||||
|
!isSmaller && "mx-auto w-full max-w-3xl flex-col space-y-4 py-20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedFileName ? (
|
||||||
|
<p>{selectedFileName}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-2">
|
||||||
|
<div>
|
||||||
|
<FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="">{text}</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
disabled={isDisabled}
|
||||||
|
id="fileSelect"
|
||||||
|
type="file"
|
||||||
|
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
GenericDropzone.displayName = "GenericDropzone";
|
||||||
@@ -1,228 +1,60 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { faArrowUpRightFromSquare, faBookOpen, faUpload } from "@fortawesome/free-solid-svg-icons";
|
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { Button } from "@app/components/v2";
|
||||||
import { Button, FormControl, IconButton } from "@app/components/v2";
|
import { useOrgPermission } from "@app/context";
|
||||||
import { useImportEnvKey } from "@app/hooks/api/migration/mutations";
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||||
|
|
||||||
const formSchema = z.object({
|
import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal";
|
||||||
encryptionKey: z.string().min(1),
|
|
||||||
file: z.unknown(),
|
|
||||||
encryptedJson: z.object({
|
|
||||||
nonce: z.string().min(1),
|
|
||||||
data: z.string().min(1)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
type TForm = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
export const ImportTab = () => {
|
export const ImportTab = () => {
|
||||||
const fileUploadRef = useRef<HTMLInputElement>(null);
|
const { membership } = useOrgPermission();
|
||||||
|
|
||||||
const { mutateAsync: importEnvKey } = useImportEnvKey();
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const);
|
||||||
|
|
||||||
const {
|
|
||||||
handleSubmit,
|
|
||||||
control,
|
|
||||||
watch,
|
|
||||||
setError,
|
|
||||||
setValue,
|
|
||||||
reset,
|
|
||||||
trigger,
|
|
||||||
formState: { isSubmitting, isValid }
|
|
||||||
} = useForm<TForm>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
values: {
|
|
||||||
encryptionKey: "",
|
|
||||||
encryptedJson: {
|
|
||||||
nonce: "",
|
|
||||||
data: ""
|
|
||||||
},
|
|
||||||
file: undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const parseJson = (src: ArrayBuffer) => {
|
|
||||||
const file = src.toString();
|
|
||||||
const formattedData: Record<string, string> = JSON.parse(file);
|
|
||||||
if (
|
|
||||||
Object.keys(formattedData).includes("nonce") &&
|
|
||||||
Object.keys(formattedData).includes("data")
|
|
||||||
) {
|
|
||||||
const data = {
|
|
||||||
nonce: formattedData.nonce,
|
|
||||||
data: formattedData.data
|
|
||||||
};
|
|
||||||
setValue("encryptedJson", data);
|
|
||||||
trigger("encryptedJson");
|
|
||||||
} else {
|
|
||||||
setValue("encryptedJson", {
|
|
||||||
nonce: "",
|
|
||||||
data: ""
|
|
||||||
});
|
|
||||||
if (fileUploadRef.current) {
|
|
||||||
fileUploadRef.current.value = "";
|
|
||||||
}
|
|
||||||
createNotification({
|
|
||||||
text: "Improper file format, please upload the EnvKey export.",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseFile = (file?: File) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
if (!file) {
|
|
||||||
createNotification({
|
|
||||||
text: "No file selected.",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reader.onload = (event) => {
|
|
||||||
if (!event?.target?.result) return;
|
|
||||||
// parse function's argument looks like to be ArrayBuffer
|
|
||||||
parseJson(event.target.result as ArrayBuffer);
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitExport = async (data: TForm) => {
|
|
||||||
if (!data.encryptedJson) {
|
|
||||||
setError("encryptedJson", {
|
|
||||||
type: "required",
|
|
||||||
message: "File is required"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await importEnvKey({ encryptedJson: data.encryptedJson, decryptionKey: data.encryptionKey });
|
|
||||||
createNotification({
|
|
||||||
text: "Data imported successfully.",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
reset();
|
|
||||||
if (fileUploadRef.current) {
|
|
||||||
fileUploadRef.current.value = "";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const watchEncryptedJsonFile: any = watch("file");
|
|
||||||
useEffect(() => {
|
|
||||||
if (watchEncryptedJsonFile) {
|
|
||||||
parseFile(watchEncryptedJsonFile?.[0]);
|
|
||||||
}
|
|
||||||
}, [watchEncryptedJsonFile]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<h2 className="mb-4 text-lg font-medium text-white">Import from external source</h2>
|
<div className="flex justify-between">
|
||||||
<p className="text-sm text-mineshaft-400">
|
<div className="flex items-center gap-2">
|
||||||
Import data from another secret manager to Infisical.
|
<p className="text-xl font-semibold text-mineshaft-100">Import from external source</p>
|
||||||
</p>
|
|
||||||
<div className="my-6 border-b border-mineshaft-800" />
|
<div>
|
||||||
<div className="justify-left flex">
|
<Link
|
||||||
<h3 className="mb-4 text-lg font-medium text-white">Import from EnvKey</h3>
|
href="https://infisical.com/docs/documentation/guides/migrating-from-envkey"
|
||||||
<Link href="https://infisical.com/docs/documentation/guides/migrating-from-envkey" passHref>
|
passHref
|
||||||
<a target="_blank" rel="noopener noreferrer">
|
|
||||||
<div className="ml-2 mb-1 inline-block rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
|
|
||||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
|
||||||
Docs
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faArrowUpRightFromSquare}
|
|
||||||
className="ml-1.5 mb-[0.07rem] text-xxs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<form onSubmit={handleSubmit(submitExport)}>
|
|
||||||
<Controller
|
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
|
||||||
errorText={error?.message}
|
|
||||||
isError={Boolean(error)}
|
|
||||||
label="Encryption Key"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
{...field}
|
|
||||||
onChange={onChange}
|
|
||||||
type="password"
|
|
||||||
className="w-full rounded-lg bg-mineshaft-800 py-2 px-4 text-white"
|
|
||||||
placeholder="Enter encryption key"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
name="encryptionKey"
|
|
||||||
control={control}
|
|
||||||
/>
|
|
||||||
<div className="justify-left flex">
|
|
||||||
<Controller
|
|
||||||
name="file"
|
|
||||||
control={control}
|
|
||||||
defaultValue={null}
|
|
||||||
rules={{ required: "File is required" }}
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
|
||||||
errorText={error?.message}
|
|
||||||
isError={Boolean(error)}
|
|
||||||
label="Export file from EnvKey"
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
id="fileSelect"
|
|
||||||
className="hidden"
|
|
||||||
type="file"
|
|
||||||
accept=".envkey-archive"
|
|
||||||
onChange={(e) => field.onChange(e.target.files)}
|
|
||||||
ref={fileUploadRef}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
className="p-10"
|
|
||||||
ariaLabel="upload-export"
|
|
||||||
colorSchema="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
fileUploadRef?.current?.click();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{fileUploadRef?.current?.value ? (
|
|
||||||
<span className="text-sm text-green">
|
|
||||||
{fileUploadRef?.current?.value.split("\\").pop()}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Upload export file
|
|
||||||
<FontAwesomeIcon icon={faUpload} size="xs" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6">
|
|
||||||
<Button
|
|
||||||
colorSchema="primary"
|
|
||||||
type="submit"
|
|
||||||
isDisabled={!isValid}
|
|
||||||
isLoading={isSubmitting}
|
|
||||||
>
|
>
|
||||||
Import
|
<a target="_blank" rel="noopener noreferrer">
|
||||||
</Button>
|
<div className="ml-2 inline-block rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
|
||||||
|
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||||
|
Docs
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faArrowUpRightFromSquare}
|
||||||
|
className="ml-1.5 mb-[0.07rem] text-xxs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
handlePopUpOpen("selectImportPlatform");
|
||||||
|
}}
|
||||||
|
isDisabled={membership?.role !== ProjectMembershipRole.Admin}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mb-4 text-gray-400">Import data from another platform to Infisical.</p>
|
||||||
|
|
||||||
|
<SelectImportFromPlatformModal
|
||||||
|
isOpen={popUp.selectImportPlatform.isOpen}
|
||||||
|
onToggle={(state) => handlePopUpToggle("selectImportPlatform", state)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, FormControl, Input } from "@app/components/v2";
|
||||||
|
import { useImportEnvKey } from "@app/hooks/api/migration/mutations";
|
||||||
|
import { GenericDropzone } from "@app/views/SecretMainPage/components/SecretDropzone/GenericDropzone";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
encryptionKey: z.string().min(1),
|
||||||
|
encryptedJson: z.object({
|
||||||
|
nonce: z.string().min(1),
|
||||||
|
data: z.string().min(1)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export const EnvKeyPlatformModal = ({ onClose }: Props) => {
|
||||||
|
const fileUploadRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { mutateAsync: importEnvKey } = useImportEnvKey();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
setError,
|
||||||
|
formState: { isLoading, isDirty, isSubmitting, isValid }
|
||||||
|
} = useForm<TFormData>({
|
||||||
|
resolver: zodResolver(formSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: TFormData) => {
|
||||||
|
if (!data.encryptedJson) {
|
||||||
|
setError("encryptedJson", {
|
||||||
|
type: "required",
|
||||||
|
message: "File is required"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await importEnvKey({
|
||||||
|
encryptedJson: data.encryptedJson,
|
||||||
|
decryptionKey: data.encryptionKey
|
||||||
|
});
|
||||||
|
createNotification({
|
||||||
|
text: "Data imported successfully.",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
reset();
|
||||||
|
|
||||||
|
if (fileUploadRef.current) {
|
||||||
|
fileUploadRef.current.value = "";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImportFileDrop = (file?: File) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
if (!file) {
|
||||||
|
createNotification({
|
||||||
|
text: "No file selected.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reader.onload = (event) => {
|
||||||
|
if (!event?.target?.result) return;
|
||||||
|
|
||||||
|
const droppedFile = event.target.result.toString();
|
||||||
|
const formattedData: Record<string, string> = JSON.parse(droppedFile);
|
||||||
|
if (
|
||||||
|
Object.keys(formattedData).includes("nonce") &&
|
||||||
|
Object.keys(formattedData).includes("data")
|
||||||
|
) {
|
||||||
|
const data = {
|
||||||
|
nonce: formattedData.nonce,
|
||||||
|
data: formattedData.data
|
||||||
|
};
|
||||||
|
setValue("encryptedJson", data, { shouldDirty: true, shouldValidate: true });
|
||||||
|
} else {
|
||||||
|
setValue(
|
||||||
|
"encryptedJson",
|
||||||
|
{
|
||||||
|
nonce: "",
|
||||||
|
data: ""
|
||||||
|
},
|
||||||
|
{ shouldDirty: true, shouldValidate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fileUploadRef.current) {
|
||||||
|
fileUploadRef.current.value = "";
|
||||||
|
}
|
||||||
|
createNotification({
|
||||||
|
text: "Improper file format, please upload the EnvKey export.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="encryptionKey"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Encryption key"
|
||||||
|
isRequired
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error)}
|
||||||
|
>
|
||||||
|
<Input type="password" placeholder="" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GenericDropzone
|
||||||
|
ref={fileUploadRef}
|
||||||
|
text="Select Env Key export file"
|
||||||
|
onData={onImportFileDrop}
|
||||||
|
isSmaller
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center space-x-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={!isDirty || isSubmitting || isLoading || !isValid}
|
||||||
|
>
|
||||||
|
Import data
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline_bg" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { Modal, ModalContent } from "@app/components/v2";
|
||||||
|
|
||||||
|
import { EnvKeyPlatformModal } from "./EnvKeyPlatformModal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onToggle: (isOpen: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum WizardSteps {
|
||||||
|
SelectPlatform = "select-platform",
|
||||||
|
PlatformInputs = "platform-inputs"
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_LIST = [
|
||||||
|
{
|
||||||
|
icon: faKey,
|
||||||
|
platform: "env-key",
|
||||||
|
title: "Env Key"
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SelectImportFromPlatformModal = ({ isOpen, onToggle }: Props) => {
|
||||||
|
const [wizardStep, setWizardStep] = useState(WizardSteps.SelectPlatform);
|
||||||
|
const [selectedPlatform, setSelectedPlatform] = useState<(typeof PLATFORM_LIST)[number] | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFormReset = (state: boolean = false) => {
|
||||||
|
onToggle(state);
|
||||||
|
setWizardStep(WizardSteps.SelectPlatform);
|
||||||
|
setSelectedPlatform(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={(state) => handleFormReset(state)}>
|
||||||
|
<ModalContent
|
||||||
|
title={
|
||||||
|
selectedPlatform ? `Import from ${selectedPlatform.title}` : "Import from external source"
|
||||||
|
}
|
||||||
|
className="my-4"
|
||||||
|
>
|
||||||
|
<AnimatePresence exitBeforeEnter>
|
||||||
|
{wizardStep === WizardSteps.SelectPlatform && (
|
||||||
|
<motion.div
|
||||||
|
key="select-type-step"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
|
>
|
||||||
|
<div className="mb-4 text-sm text-mineshaft-300">
|
||||||
|
Select a platform to import from
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{PLATFORM_LIST.map((platform, idx) => (
|
||||||
|
<div
|
||||||
|
key={`platform-${idx + 1}`}
|
||||||
|
className="flex h-28 w-32 cursor-pointer flex-col items-center space-y-4 rounded border border-mineshaft-500 bg-bunker-600 p-6 transition-all hover:border-primary/70 hover:bg-primary/10 hover:text-white"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPlatform(platform);
|
||||||
|
setWizardStep(WizardSteps.PlatformInputs);
|
||||||
|
}}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
if (evt.key === "Enter") {
|
||||||
|
setSelectedPlatform(platform);
|
||||||
|
setWizardStep(WizardSteps.PlatformInputs);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={platform.icon} size="lg" />
|
||||||
|
<div className="whitespace-pre-wrap text-center text-sm">{platform.title}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{wizardStep === WizardSteps.PlatformInputs &&
|
||||||
|
selectedPlatform?.platform === "env-key" && (
|
||||||
|
<motion.div
|
||||||
|
key="env-key-step"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
|
>
|
||||||
|
<EnvKeyPlatformModal onClose={() => handleFormReset(false)} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user