feat: added pull secret feature in dashboard with env json parsing and multiline parsing

This commit is contained in:
Akhil Mohan
2023-08-02 16:15:04 +05:30
parent 62fa59619b
commit 466dadc611
6 changed files with 287 additions and 45 deletions
+4 -3
View File
@@ -54,13 +54,14 @@ export const useGetProjectSecrets = ({
env, env,
decryptFileKey, decryptFileKey,
isPaused, isPaused,
folderId folderId,
secretPath
}: GetProjectSecretsDTO) => }: GetProjectSecretsDTO) =>
useQuery({ useQuery({
// wait for all values to be available // wait for all values to be available
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused, enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId), queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId || secretPath),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId), queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
select: useCallback( select: useCallback(
(data: EncryptedSecret[]) => { (data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
+1
View File
@@ -1,3 +1,4 @@
export { useDebounce } from "./useDebounce";
export { useLeaveConfirm } from "./useLeaveConfirm"; export { useLeaveConfirm } from "./useLeaveConfirm";
export { usePersistentState } from "./usePersistentState"; export { usePersistentState } from "./usePersistentState";
export { usePopUp } from "./usePopUp"; export { usePopUp } from "./usePopUp";
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
// Ref: https://usehooks.com/useDebounce/
export const useDebounce = <T extends unknown>(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;
};
@@ -740,7 +740,7 @@ export const DashboardPage = () => {
return ( return (
<div className="container mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<form autoComplete="off" className="h-full"> <form autoComplete="off" className="h-full flex flex-col">
{/* breadcrumb row */} {/* breadcrumb row */}
<div className="relative right-6 -top-2 mb-2 ml-6"> <div className="relative right-6 -top-2 mb-2 ml-6">
<NavHeader <NavHeader
@@ -924,8 +924,8 @@ export const DashboardPage = () => {
</div> </div>
<div <div
className={`${ className={`${
isEmptyPage ? "flex flex-col items-center justify-center" : "" isEmptyPage ? "flex flex-col flex-grow items-center justify-center" : ""
} no-scrollbar::-webkit-scrollbar mt-3 h-3/4 overflow-x-hidden overflow-y-scroll no-scrollbar`} } no-scrollbar::-webkit-scrollbar mt-3 flex flex-col overflow-x-hidden overflow-y-scroll no-scrollbar`}
ref={secretContainer} ref={secretContainer}
> >
{!isEmptyPage && ( {!isEmptyPage && (
@@ -935,7 +935,7 @@ export const DashboardPage = () => {
collisionDetection={closestCenter} collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]} modifiers={[restrictToVerticalAxis]}
> >
<TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar"> <TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar flex-grow">
<table className="secret-table relative"> <table className="secret-table relative">
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} /> <SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
<tbody className="max-h-96 overflow-y-auto"> <tbody className="max-h-96 overflow-y-auto">
@@ -1016,9 +1016,12 @@ export const DashboardPage = () => {
</FormProvider> </FormProvider>
<SecretDropzone <SecretDropzone
workspaceId={workspaceId}
isSmaller={!isEmptyPage} isSmaller={!isEmptyPage}
onParsedEnv={handleUploadedEnv} onParsedEnv={handleUploadedEnv}
onAddNewSecret={onAppendSecret} onAddNewSecret={onAppendSecret}
environments={userAvailableEnvs}
decryptFileKey={latestFileKey!}
/> />
</div> </div>
{/* secrets table and drawers, modals */} {/* secrets table and drawers, modals */}
@@ -75,6 +75,13 @@ export type FormData = yup.InferType<typeof schema>;
export type TSecretDetailsOpen = { index: number; id: string }; export type TSecretDetailsOpen = { index: number; id: string };
export type TSecOverwriteOpt = { secrets: Record<string, { comments: string[]; value: string }> }; export type TSecOverwriteOpt = { secrets: Record<string, { comments: string[]; value: string }> };
// 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 = ( export const downloadSecret = (
secrets: FormData["secrets"] = [], secrets: FormData["secrets"] = [],
importedSecrets: { key: string; value?: string; comment?: string }[] = [], importedSecrets: { key: string; value?: string; comment?: string }[] = [],
@@ -86,9 +93,11 @@ export const downloadSecret = (
}); });
const finalSecret = [...importedSecrets]; const finalSecret = [...importedSecrets];
secrets.forEach(({ key, value, valueOverride, overrideAction, comment }) => { secrets.forEach(({ key, value, valueOverride, overrideAction, comment }) => {
const finalVal =
overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value;
const newValue = { const newValue = {
key, key,
value: overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value, value: formatMultiValueEnv(finalVal),
comment comment
}; };
// can also be zero thus failing // can also be zero thus failing
@@ -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 { 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; 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 { parseDotEnv } from "@app/components/utilities/parseDotEnv";
import { Button } from "@app/components/v2"; import {
import { useToggle } from "@app/hooks/useToggle"; 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<string, yup.StringSchema> = {};
Object.keys(val).forEach((key) => {
valSchema[key] = yup.string().trim();
});
return yup.object(valSchema);
})
});
type TFormSchema = yup.InferType<typeof formSchema>;
const parseJson = (src: ArrayBuffer) => {
const file = src.toString();
const formatedData: Record<string, string> = JSON.parse(file);
const env: Record<string, { value: string; comments: string[] }> = {};
Object.keys(formatedData).forEach((key) => {
if (typeof formatedData[key] === "string") {
env[key] = { value: formatedData[key], comments: [] };
}
});
return env;
};
type Props = { type Props = {
isSmaller: boolean; isSmaller: boolean;
onParsedEnv: (env: Record<string, { value: string; comments: string[] }>) => void; onParsedEnv: (env: Record<string, { value: string; comments: string[] }>) => void;
onAddNewSecret?: () => 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 { t } = useTranslation();
const [isDragActive, setDragActive] = useToggle(); const [isDragActive, setDragActive] = useToggle();
const [isLoading, setIsLoading] = useToggle(); const [isLoading, setIsLoading] = useToggle();
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["importSecEnv"] as const);
const [searchFilter, setSearchFilter] = useState("");
const { handleSubmit, control, watch, register, reset, setValue } = useForm<TFormSchema>({
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) => { const handleDrag = (e: DragEvent) => {
e.preventDefault(); 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(); const reader = new FileReader();
if (!file) { if (!file) {
createNotification({ createNotification({
@@ -47,7 +130,9 @@ export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props
reader.onload = (event) => { reader.onload = (event) => {
if (!event?.target?.result) return; if (!event?.target?.result) return;
// parse function's argument looks like to be ArrayBuffer // 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(); setIsLoading.off();
onParsedEnv(env); onParsedEnv(env);
}; };
@@ -74,7 +159,19 @@ export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => { const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault(); 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<string, { value: string; comments: string[] }> = {};
Object.keys(data.secrets || {}).forEach((key) => {
if (data.secrets[key]) {
secretsToBePulled[key] = { value: data.secrets[key] || "", comments: [""] };
}
});
onParsedEnv(secretsToBePulled);
handlePopUpClose("importSecEnv");
reset();
}; };
return ( return (
@@ -84,9 +181,9 @@ export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props
onDragOver={handleDrag} onDragOver={handleDrag}
onDrop={handleDrop} onDrop={handleDrop}
className={twMerge( 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", 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" isLoading && "bg-bunker-800"
)} )}
> >
@@ -95,35 +192,140 @@ export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" /> <img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
</div> </div>
) : ( ) : (
<> <form onSubmit={handleSubmit(handleFormSubmit)}>
<div> <div className="flex items-center justify-cente flex-col space-y-2">
<FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} /> <div>
</div> <FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} />
<div> </div>
<p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p> <div>
</div> <p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p>
<input </div>
id="fileSelect" <input
type="file" id="fileSelect"
className="absolute h-full w-full cursor-pointer opacity-0" type="file"
accept=".txt,.env,.yml,.yaml" className="absolute h-full w-full cursor-pointer opacity-0"
onChange={handleFileUpload} accept=".txt,.env,.yml,.yaml,.json"
/> onChange={handleFileUpload}
{!isSmaller && ( />
<> <div className="flex w-full flex-row items-center justify-center py-4">
<div className="flex w-full flex-row items-center justify-center py-4"> <div className="w-1/5 border-t border-mineshaft-700" />
<div className="w-1/5 border-t border-mineshaft-700" /> <p className="mx-4 text-xs text-mineshaft-400">OR</p>
<p className="mx-4 text-xs text-mineshaft-400">OR</p> <div className="w-1/5 border-t border-mineshaft-700" />
<div className="w-1/5 border-t border-mineshaft-700" /> </div>
</div> <div className="flex items-center justify-center space-x-8">
<div> <Modal
isOpen={popUp.importSecEnv.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("importSecEnv", isOpen);
reset();
setSearchFilter("");
}}
>
<ModalTrigger asChild>
<Button variant="star" size={isSmaller ? "xs" : "sm"}>
Pull Secrets From An Environment
</Button>
</ModalTrigger>
<ModalContent
className="max-w-2xl"
title="Import Secret From An Envronment"
subTitle="This can be used to populate your dashboard with secrets from another board"
>
<form>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="environment"
render={({ field: { value, onChange } }) => (
<FormControl label="Environment" isRequired className="w-1/3">
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
defaultValue={environments?.[0]?.slug}
position="popper"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<FormControl label="Secret Path" className="flex-grow" isRequired>
<Input
{...register("secretPath")}
placeholder="Provide a path, default is /"
/>
</FormControl>
</div>
<div className="border-t border-mineshaft-600 pt-4">
<div className="mb-4 flex items-center justify-between">
<div>Secrets</div>
<div className="w-1/2">
<Input
placeholder="Search for secret"
value={searchFilter}
size="xs"
leftIcon={<FontAwesomeIcon icon={faSearch} />}
onChange={(evt) => setSearchFilter(evt.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 max-h-64 overflow-auto thin-scrollbar ">
{isSecretsLoading &&
Array.apply(0, Array(4)).map((_x, i) => (
<Skeleton
key={`secret-pull-loading-${i + 1}`}
className="bg-mineshaft-700"
/>
))}
{secrets?.secrets
?.filter(({ key }) =>
key.toLowerCase().includes(searchFilter.toLowerCase())
)
?.map(({ _id, key, value: secVal }) => (
<Controller
key={`pull-secret--${_id}`}
control={control}
name={`secrets.${key}`}
render={({ field: { value, onChange } }) => (
<Checkbox
id={`pull-secret-${_id}`}
isChecked={Boolean(value)}
onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
>
{key}
</Checkbox>
)}
/>
))}
</div>
<div className="flex items-center space-x-2 mt-8">
<Button leftIcon={<FontAwesomeIcon icon={faClone} />} type="submit">
Pull Secrets
</Button>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</div>
</div>
</form>
</ModalContent>
</Modal>
{!isSmaller && (
<Button variant="star" onClick={onAddNewSecret}> <Button variant="star" onClick={onAddNewSecret}>
Add a new secret Add a new secret
</Button> </Button>
</div> )}
</> </div>
)}{" "} </div>
</> </form>
)} )}
</div> </div>
); );