From f0210c260716120e6798c7652a6f9624024664b4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 1 Oct 2024 05:17:46 +0400 Subject: [PATCH] feat: fixed UI and added permissions check to backend --- backend/src/server/routes/index.ts | 1 + .../external-migration-service.ts | 18 ++ .../src/hooks/api/migration/mutations.tsx | 42 +-- .../SecretDropzone/GenericDropzone.tsx | 117 ++++++++ .../components/ImportTab/ImportTab.tsx | 258 +++--------------- .../components/EnvKeyPlatformModal.tsx | 155 +++++++++++ .../SelectImportFromPlatformModal.tsx | 101 +++++++ 7 files changed, 460 insertions(+), 232 deletions(-) create mode 100644 frontend/src/views/SecretMainPage/components/SecretDropzone/GenericDropzone.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/EnvKeyPlatformModal.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/SelectImportFromPlatformModal.tsx diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3648f7862..eb2b00120 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1190,6 +1190,7 @@ export const registerRoutes = async ( projectService, orgService, projectEnvService, + permissionService, secretService }); diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 95fd9a496..a65a278f5 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -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 { TProjectServiceFactory } from "../project/project-service"; import { TProjectEnvServiceFactory } from "../project-env/project-env-service"; @@ -10,6 +14,7 @@ type TExternalMigrationServiceFactoryDep = { orgService: TOrgServiceFactory; projectEnvService: TProjectEnvServiceFactory; secretService: TSecretServiceFactory; + permissionService: TPermissionServiceFactory; }; export type TExternalMigrationServiceFactory = ReturnType; @@ -18,6 +23,7 @@ export const externalMigrationServiceFactory = ({ projectService, orgService, projectEnvService, + permissionService, secretService }: TExternalMigrationServiceFactoryDep) => { const importEnvKeyData = async ({ @@ -28,6 +34,18 @@ export const externalMigrationServiceFactory = ({ actorOrgId, actorAuthMethod }: 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 envKeyData = await parseEnvKeyDataFn(json); const response = await importDataIntoInfisicalFn({ diff --git a/frontend/src/hooks/api/migration/mutations.tsx b/frontend/src/hooks/api/migration/mutations.tsx index 096345c3a..fb9bebeff 100644 --- a/frontend/src/hooks/api/migration/mutations.tsx +++ b/frontend/src/hooks/api/migration/mutations.tsx @@ -5,23 +5,27 @@ import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace"; export const useImportEnvKey = () => { - const queryClient = useQueryClient(); + const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ encryptedJson, decryptionKey }: { - encryptedJson: { - nonce: string, - data: string - }, decryptionKey: string - }): Promise<{ success: boolean, message: string }> => { - const { data } = await apiRequest.post("/api/v3/migrate/env-key/", { - encryptedJson, - decryptionKey - }); - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); - } - }); -}; \ No newline at end of file + return useMutation({ + mutationFn: async ({ + encryptedJson, + decryptionKey + }: { + encryptedJson: { + nonce: string; + data: string; + }; + decryptionKey: string; + }) => { + const { data } = await apiRequest.post("/api/v3/migrate/env-key/", { + encryptedJson, + decryptionKey + }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + } + }); +}; diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/GenericDropzone.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/GenericDropzone.tsx new file mode 100644 index 000000000..f4b732b8f --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/GenericDropzone.tsx @@ -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( + ({ onData, isSmaller, text, isDisabled, accept }: Props, ref): JSX.Element => { + const [isDragActive, setDragActive] = useToggle(); + const [selectedFileName, setSelectedFileName] = useState(null); + const inputRef = useRef(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) => { + e.preventDefault(); + + if (!e.target?.files?.[0]) { + return; + } + onData(e.target.files[0]); + updateSelectedFileName(); + }; + + React.useEffect(() => { + updateSelectedFileName(); + }, []); + + return ( +
+
+ {selectedFileName ? ( +

{selectedFileName}

+ ) : ( +
+
+ +
+
+

{text}

+
+ +
+ )} +
+
+ ); + } +); + +GenericDropzone.displayName = "GenericDropzone"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/ImportTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/ImportTab.tsx index 443ab6db8..41020d730 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/ImportTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/ImportTab.tsx @@ -1,228 +1,60 @@ -import { useEffect, useRef } from "react"; -import { Controller, useForm } from "react-hook-form"; 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 { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, IconButton } from "@app/components/v2"; -import { useImportEnvKey } from "@app/hooks/api/migration/mutations"; +import { Button } from "@app/components/v2"; +import { useOrgPermission } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; -const formSchema = z.object({ - 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; +import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal"; export const ImportTab = () => { - const fileUploadRef = useRef(null); + const { membership } = useOrgPermission(); - const { mutateAsync: importEnvKey } = useImportEnvKey(); - - const { - handleSubmit, - control, - watch, - setError, - setValue, - reset, - trigger, - formState: { isSubmitting, isValid } - } = useForm({ - resolver: zodResolver(formSchema), - values: { - encryptionKey: "", - encryptedJson: { - nonce: "", - data: "" - }, - file: undefined - } - }); - - const parseJson = (src: ArrayBuffer) => { - const file = src.toString(); - const formattedData: Record = 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]); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const); return ( -
-

Import from external source

-

- Import data from another secret manager to Infisical. -

-
-
-

Import from EnvKey

- - -
- - Docs - -
-
- -
-
-
- ( - - - - )} - name="encryptionKey" - control={control} - /> -
- ( - - <> - field.onChange(e.target.files)} - ref={fileUploadRef} - /> - { - fileUploadRef?.current?.click(); - }} - > - {fileUploadRef?.current?.value ? ( - - {fileUploadRef?.current?.value.split("\\").pop()} - - ) : ( - <> - Upload export file  - - - )} - - - - )} - /> -
-
- + +
+ + Docs + +
+
+
- +
+ +
+

Import data from another platform to Infisical.

+ + handlePopUpToggle("selectImportPlatform", state)} + />
); }; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/EnvKeyPlatformModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/EnvKeyPlatformModal.tsx new file mode 100644 index 000000000..1bfabf434 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/EnvKeyPlatformModal.tsx @@ -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; + +export const EnvKeyPlatformModal = ({ onClose }: Props) => { + const fileUploadRef = useRef(null); + + const { mutateAsync: importEnvKey } = useImportEnvKey(); + + const { + control, + handleSubmit, + reset, + setValue, + setError, + formState: { isLoading, isDirty, isSubmitting, isValid } + } = useForm({ + 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 = 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 ( +
+ ( + + + + )} + /> + + + +
+ + +
+ + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/SelectImportFromPlatformModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/SelectImportFromPlatformModal.tsx new file mode 100644 index 000000000..3dfbd9b7b --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/SelectImportFromPlatformModal.tsx @@ -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 ( + handleFormReset(state)}> + + + {wizardStep === WizardSteps.SelectPlatform && ( + +
+ Select a platform to import from +
+
+ {PLATFORM_LIST.map((platform, idx) => ( +
{ + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + }} + onKeyDown={(evt) => { + if (evt.key === "Enter") { + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + } + }} + > + +
{platform.title}
+
+ ))} +
+
+ )} + {wizardStep === WizardSteps.PlatformInputs && + selectedPlatform?.platform === "env-key" && ( + + handleFormReset(false)} /> + + )} +
+
+
+ ); +};