feat: fixed UI and added permissions check to backend

This commit is contained in:
Daniel Hougaard
2024-10-01 05:17:46 +04:00
parent 0485b56e8d
commit f0210c2607
7 changed files with 460 additions and 232 deletions

View File

@@ -1190,6 +1190,7 @@ export const registerRoutes = async (
projectService,
orgService,
projectEnvService,
permissionService,
secretService
});

View File

@@ -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<typeof externalMigrationServiceFactory>;
@@ -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({

View File

@@ -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);
}
});
};
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);
}
});
};

View File

@@ -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";

View File

@@ -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<typeof formSchema>;
import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal";
export const ImportTab = () => {
const fileUploadRef = useRef<HTMLInputElement>(null);
const { membership } = useOrgPermission();
const { mutateAsync: importEnvKey } = useImportEnvKey();
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]);
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const);
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<h2 className="mb-4 text-lg font-medium text-white">Import from external source</h2>
<p className="text-sm text-mineshaft-400">
Import data from another secret manager to Infisical.
</p>
<div className="my-6 border-b border-mineshaft-800" />
<div className="justify-left flex">
<h3 className="mb-4 text-lg font-medium text-white">Import from EnvKey</h3>
<Link href="https://infisical.com/docs/documentation/guides/migrating-from-envkey" 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&nbsp;
<FontAwesomeIcon icon={faUpload} size="xs" />
</>
)}
</IconButton>
</>
</FormControl>
)}
/>
</div>
<div className="mt-6">
<Button
colorSchema="primary"
type="submit"
isDisabled={!isValid}
isLoading={isSubmitting}
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<div className="flex items-center gap-2">
<p className="text-xl font-semibold text-mineshaft-100">Import from external source</p>
<div>
<Link
href="https://infisical.com/docs/documentation/guides/migrating-from-envkey"
passHref
>
Import
</Button>
<a target="_blank" rel="noopener noreferrer">
<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>
</form>
</div>
<Button
onClick={() => {
handlePopUpOpen("selectImportPlatform");
}}
isDisabled={membership?.role !== ProjectMembershipRole.Admin}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Import
</Button>
</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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};