From 466dadc611242b9113ba9d324e3112b656bd6ff7 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 2 Aug 2023 16:07:56 +0530 Subject: [PATCH 1/4] feat: added pull secret feature in dashboard with env json parsing and multiline parsing --- frontend/src/hooks/api/secrets/queries.tsx | 7 +- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useDebounce.tsx | 26 ++ .../src/views/DashboardPage/DashboardPage.tsx | 11 +- .../DashboardPage/DashboardPage.utils.ts | 11 +- .../SecretDropzone/SecretDropzone.tsx | 276 +++++++++++++++--- 6 files changed, 287 insertions(+), 45 deletions(-) create mode 100644 frontend/src/hooks/useDebounce.tsx diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 34ecd7ec4..6f65a8edd 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -54,13 +54,14 @@ export const useGetProjectSecrets = ({ env, decryptFileKey, isPaused, - folderId + folderId, + secretPath }: GetProjectSecretsDTO) => useQuery({ // wait for all values to be available enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused, - queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId), - queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId), + queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId || secretPath), + queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath), select: useCallback( (data: EncryptedSecret[]) => { const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index a3ded034c..98fbe2413 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,3 +1,4 @@ +export { useDebounce } from "./useDebounce"; export { useLeaveConfirm } from "./useLeaveConfirm"; export { usePersistentState } from "./usePersistentState"; export { usePopUp } from "./usePopUp"; diff --git a/frontend/src/hooks/useDebounce.tsx b/frontend/src/hooks/useDebounce.tsx new file mode 100644 index 000000000..318763210 --- /dev/null +++ b/frontend/src/hooks/useDebounce.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +// Ref: https://usehooks.com/useDebounce/ +export const useDebounce = (value: T, delay = 500): T => { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect( + () => { + // Update debounced value after delay + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // Cancel the timeout if value changes (also on delay change or unmount) + // This is how we prevent debounced value from updating if value is changed ... + // .. within the delay period. Timeout gets cleared and restarted. + return () => { + clearTimeout(handler); + }; + }, + [value, delay] // Only re-call effect if value or delay changes + ); + + return debouncedValue; +}; diff --git a/frontend/src/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx index 6d794cbec..e809a08a0 100644 --- a/frontend/src/views/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage/DashboardPage.tsx @@ -740,7 +740,7 @@ export const DashboardPage = () => { return (
-
+ {/* breadcrumb row */}
{
{!isEmptyPage && ( @@ -935,7 +935,7 @@ export const DashboardPage = () => { collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis]} > - + @@ -1016,9 +1016,12 @@ export const DashboardPage = () => { {/* secrets table and drawers, modals */} diff --git a/frontend/src/views/DashboardPage/DashboardPage.utils.ts b/frontend/src/views/DashboardPage/DashboardPage.utils.ts index a46e97a84..375a05489 100644 --- a/frontend/src/views/DashboardPage/DashboardPage.utils.ts +++ b/frontend/src/views/DashboardPage/DashboardPage.utils.ts @@ -75,6 +75,13 @@ export type FormData = yup.InferType; export type TSecretDetailsOpen = { index: number; id: string }; export type TSecOverwriteOpt = { secrets: Record }; +// to convert multi line into single line ones by quoting them and changing to string \n +const formatMultiValueEnv = (val?: string) => { + if (!val) return ""; + if (!val.match("\n")) return val; + return `"${val.replace(/\n/g, "\\n")}"`; +}; + export const downloadSecret = ( secrets: FormData["secrets"] = [], importedSecrets: { key: string; value?: string; comment?: string }[] = [], @@ -86,9 +93,11 @@ export const downloadSecret = ( }); const finalSecret = [...importedSecrets]; secrets.forEach(({ key, value, valueOverride, overrideAction, comment }) => { + const finalVal = + overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value; const newValue = { key, - value: overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value, + value: formatMultiValueEnv(finalVal), comment }; // can also be zero thus failing diff --git a/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx index 7b8671583..cac722368 100644 --- a/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -1,26 +1,109 @@ -import { ChangeEvent, DragEvent } from "react"; +import { ChangeEvent, DragEvent, useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { faUpload } from "@fortawesome/free-solid-svg-icons"; +import { faClone, faSearch, faUpload } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; import { twMerge } from "tailwind-merge"; +import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionalityj +// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality import { parseDotEnv } from "@app/components/utilities/parseDotEnv"; -import { Button } from "@app/components/v2"; -import { useToggle } from "@app/hooks/useToggle"; +import { + Button, + Checkbox, + FormControl, + Input, + Modal, + ModalContent, + ModalTrigger, + Select, + SelectItem, + Skeleton +} from "@app/components/v2"; +import { useDebounce, usePopUp, useToggle } from "@app/hooks"; +import { useGetProjectSecrets } from "@app/hooks/api"; +import { UserWsKeyPair } from "@app/hooks/api/types"; + +const formSchema = yup.object({ + environment: yup.string().required().label("Environment").trim(), + secretPath: yup + .string() + .required() + .label("Secret Path") + .trim() + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ), + secrets: yup.lazy((val) => { + const valSchema: Record = {}; + Object.keys(val).forEach((key) => { + valSchema[key] = yup.string().trim(); + }); + return yup.object(valSchema); + }) +}); + +type TFormSchema = yup.InferType; + +const parseJson = (src: ArrayBuffer) => { + const file = src.toString(); + const formatedData: Record = JSON.parse(file); + const env: Record = {}; + Object.keys(formatedData).forEach((key) => { + if (typeof formatedData[key] === "string") { + env[key] = { value: formatedData[key], comments: [] }; + } + }); + return env; +}; type Props = { isSmaller: boolean; onParsedEnv: (env: Record) => void; onAddNewSecret?: () => void; + environments?: { name: string; slug: string }[]; + workspaceId: string; + decryptFileKey: UserWsKeyPair; }; -export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props): JSX.Element => { +export const SecretDropzone = ({ + isSmaller, + onParsedEnv, + onAddNewSecret, + environments = [], + workspaceId, + decryptFileKey +}: Props): JSX.Element => { const { t } = useTranslation(); const [isDragActive, setDragActive] = useToggle(); const [isLoading, setIsLoading] = useToggle(); const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["importSecEnv"] as const); + const [searchFilter, setSearchFilter] = useState(""); + + const { handleSubmit, control, watch, register, reset, setValue } = useForm({ + resolver: yupResolver(formSchema), + defaultValues: { secretPath: "/", environment: environments?.[0]?.slug } + }); + + const secretPath = watch("secretPath"); + const selectedEnvSlug = watch("environment"); + const debouncedSecretPath = useDebounce(secretPath); + + const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ + workspaceId, + env: selectedEnvSlug, + secretPath: debouncedSecretPath, + isPaused: !(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedSecretPath)), + decryptFileKey + }); + + useEffect(() => { + setValue("secrets", {}); + setSearchFilter(""); + }, [debouncedSecretPath]); const handleDrag = (e: DragEvent) => { e.preventDefault(); @@ -32,7 +115,7 @@ export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props } }; - const parseFile = (file?: File) => { + const parseFile = (file?: File, isJson?: boolean) => { const reader = new FileReader(); if (!file) { createNotification({ @@ -47,7 +130,9 @@ export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props reader.onload = (event) => { if (!event?.target?.result) return; // parse function's argument looks like to be ArrayBuffer - const env = parseDotEnv(event.target.result as ArrayBuffer); + const env = isJson + ? parseJson(event.target.result as ArrayBuffer) + : parseDotEnv(event.target.result as ArrayBuffer); setIsLoading.off(); onParsedEnv(env); }; @@ -74,7 +159,19 @@ export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props const handleFileUpload = (e: ChangeEvent) => { e.preventDefault(); - parseFile(e.target?.files?.[0]); + parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json"); + }; + + const handleFormSubmit = (data: TFormSchema) => { + const secretsToBePulled: Record = {}; + Object.keys(data.secrets || {}).forEach((key) => { + if (data.secrets[key]) { + secretsToBePulled[key] = { value: data.secrets[key] || "", comments: [""] }; + } + }); + onParsedEnv(secretsToBePulled); + handlePopUpClose("importSecEnv"); + reset(); }; return ( @@ -84,9 +181,9 @@ export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props onDragOver={handleDrag} onDrop={handleDrop} className={twMerge( - "relative mx-0.5 mb-4 mt-4 flex w-full max-w-[calc(100vw-292px)] cursor-pointer items-center justify-center space-x-2 rounded-md bg-mineshaft-900 py-8 px-2 text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100", + "relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 py-4 text-sm px-2 text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100", isDragActive && "opacity-100", - !isSmaller && "max-w-3xl flex-col space-y-4 py-20", + !isSmaller && "w-full max-w-3xl flex-col space-y-4 py-20", isLoading && "bg-bunker-800" )} > @@ -95,35 +192,140 @@ export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props loading animation ) : ( - <> -
- -
-
-

{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}

-
- - {!isSmaller && ( - <> -
-
-

OR

-
-
-
+ +
+
+ +
+
+

{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}

+
+ +
+
+

OR

+
+
+
+ { + handlePopUpToggle("importSecEnv", isOpen); + reset(); + setSearchFilter(""); + }} + > + + + + + +
+ ( + + + + )} + /> + + + +
+
+
+
Secrets
+
+ } + onChange={(evt) => setSearchFilter(evt.target.value)} + /> +
+
+
+ {isSecretsLoading && + Array.apply(0, Array(4)).map((_x, i) => ( + + ))} + {secrets?.secrets + ?.filter(({ key }) => + key.toLowerCase().includes(searchFilter.toLowerCase()) + ) + ?.map(({ _id, key, value: secVal }) => ( + ( + onChange(isChecked ? secVal : "")} + > + {key} + + )} + /> + ))} +
+
+ + +
+
+ +
+
+ {!isSmaller && ( -
- - )}{" "} - + )} +
+
+ )}
); From 817a783ec2ca13a0358178c83c67d7ca6b44112d Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Thu, 3 Aug 2023 13:07:51 +0530 Subject: [PATCH 2/4] feat: updated text and added select all in copy secrets for dashboard --- frontend/src/components/v2/Button/Button.tsx | 48 ++++++++++--------- .../SecretDropzone/SecretDropzone.tsx | 42 ++++++++++++++-- 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index b69545766..7c2a5fd6d 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -51,10 +51,10 @@ const buttonVariants = cva( false: "" }, size: { - xs: ["text-xs", "py-1", "px-1"], - sm: ["text-sm", "py-2", "px-2"], - md: ["text-md", "py-2", "px-4"], - lg: ["text-lg", "py-2", "px-8"] + xs: ["text-xs", "py-1", "px-2"], + sm: ["text-sm", "py-2", "px-4"], + md: ["text-md", "py-2", "px-5"], + lg: ["text-lg", "py-2", "px-6"] } }, compoundVariants: [ @@ -186,16 +186,17 @@ export const Button = forwardRef( className="absolute rounded-xl opacity-80" /> )} -
- {leftIcon} -
+ {leftIcon && ( +
+ {leftIcon} +
+ )} ( > {children} -
- {rightIcon} -
+ {rightIcon && ( +
+ {rightIcon} +
+ )} ); } diff --git a/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx index cac722368..699edea84 100644 --- a/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -1,7 +1,8 @@ import { ChangeEvent, DragEvent, useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { faClone, faSearch, faUpload } from "@fortawesome/free-solid-svg-icons"; +import { faSquareCheck } from "@fortawesome/free-regular-svg-icons"; +import { faClone, faSearch, faSquareXmark, faUpload } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; import { twMerge } from "tailwind-merge"; @@ -14,13 +15,15 @@ import { Button, Checkbox, FormControl, + IconButton, Input, Modal, ModalContent, ModalTrigger, Select, SelectItem, - Skeleton + Skeleton, + Tooltip } from "@app/components/v2"; import { useDebounce, usePopUp, useToggle } from "@app/hooks"; import { useGetProjectSecrets } from "@app/hooks/api"; @@ -174,6 +177,15 @@ export const SecretDropzone = ({ reset(); }; + const handleSecSelectAll = () => { + if (secrets?.secrets) { + setValue( + "secrets", + secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}) + ); + } + }; + return (
@@ -267,7 +279,7 @@ export const SecretDropzone = ({
Secrets
-
+
} onChange={(evt) => setSearchFilter(evt.target.value)} /> + + + + + + + reset()} + > + + +
@@ -308,7 +340,7 @@ export const SecretDropzone = ({
@@ -309,14 +333,18 @@ export const SecretDropzone = ({
+ {!isSecretsLoading && !secrets?.secrets?.length && ( + + )}
{isSecretsLoading && - Array.apply(0, Array(4)).map((_x, i) => ( + Array.apply(0, Array(2)).map((_x, i) => ( ))} + {secrets?.secrets ?.filter(({ key }) => key.toLowerCase().includes(searchFilter.toLowerCase()) @@ -338,8 +366,23 @@ export const SecretDropzone = ({ /> ))}
-
-