diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index 167e1c7b5..fdfefcf93 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -53,8 +53,8 @@ "project-id": "Project ID", "save-changes": "Save Changes", "saved": "Saved", - "drop-zone": "Drag and drop a .env, .json, or .yml file here.", - "drop-zone-keys": "Drag and drop a .env, .json, or .yml file here to add more secrets.", + "drop-zone": "Drag and drop a .env, .json, .csv, or .yml file here.", + "drop-zone-keys": "Drag and drop a .env, .json, .csv, or .yml file here to add more secrets.", "role": "Role", "role_admin": "admin", "display-name": "Display Name", diff --git a/frontend/src/components/utilities/parseSecrets.ts b/frontend/src/components/utilities/parseSecrets.ts index 3e5a57edf..e33f25ace 100644 --- a/frontend/src/components/utilities/parseSecrets.ts +++ b/frontend/src/components/utilities/parseSecrets.ts @@ -165,3 +165,61 @@ export function parseYaml(src: ArrayBuffer | string) { return result; } + +function detectSeparator(csvContent: string): string { + const firstLine = csvContent.split("\n")[0]; + const separators = [",", ";", "\t", "|"]; + + const counts = separators.map((sep) => ({ + separator: sep, + count: (firstLine.match(new RegExp(`\\${sep}`, "g")) || []).length + })); + + const detected = counts.reduce((max, curr) => (curr.count > max.count ? curr : max)); + + return detected.count > 0 ? detected.separator : ","; +} + +export function parseCsvToMatrix(src: ArrayBuffer | string): string[][] { + let csvContent: string; + if (typeof src === "string") { + csvContent = src; + } else { + csvContent = new TextDecoder("utf-8").decode(src); + } + + const separator = detectSeparator(csvContent); + const lines = csvContent.replace(/\r\n?/g, "\n").split("\n"); + const matrix: string[][] = []; + + lines.forEach((line) => { + if (line.trim() !== "") { + const cells: string[] = []; + let currentCell = ""; + let inQuote = false; + + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"') { + if (inQuote && nextChar === '"') { + currentCell += '"'; + i += 1; + } else { + inQuote = !inQuote; + } + } else if (char === separator && !inQuote) { + cells.push(currentCell.trim()); + currentCell = ""; + } else { + currentCell += char; + } + } + cells.push(currentCell.trim()); + matrix.push(cells); + } + }); + + return matrix; +} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx index 92df89cae..126306e3d 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -1,7 +1,14 @@ -import { ChangeEvent, DragEvent } from "react"; +import { ChangeEvent, Dispatch, DragEvent, SetStateAction, useState } from "react"; import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; -import { faPlus, faUpload } from "@fortawesome/free-solid-svg-icons"; +import { + faArrowRight, + faAsterisk, + faComment, + faKey, + faPlus, + faUpload +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQueryClient } from "@tanstack/react-query"; import { twMerge } from "tailwind-merge"; @@ -9,8 +16,22 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; // TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality -import { parseDotEnv, parseJson, parseYaml } from "@app/components/utilities/parseSecrets"; -import { Button, Lottie, Modal, ModalContent } from "@app/components/v2"; +import { + parseCsvToMatrix, + parseDotEnv, + parseJson, + parseYaml +} from "@app/components/utilities/parseSecrets"; +import { + Badge, + Button, + FormLabel, + Lottie, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api"; @@ -38,6 +59,84 @@ type Props = { isProtectedBranch?: boolean; }; +type SecretMatrixMap = { + key: number; + value: number | null; + comment: number | null; +}; + +const popupKeys = ["importSecEnv", "confirmUpload", "pasteSecEnv", "importMatrixMap"] as const; + +const MatrixImportModalTableRow = ({ + importSecretMatrixMap, + setImportSecretMatrixMap, + headers, + mapKey +}: { + importSecretMatrixMap: SecretMatrixMap; + setImportSecretMatrixMap: Dispatch>; + headers: string[]; + mapKey: keyof SecretMatrixMap; +}) => { + return ( + + + + + +
+ +
+ + +
+ + {mapKey === "key" && ( + <> + + Secret Key + + )} + {mapKey === "value" && ( + <> + + Secret Value + + )} + {mapKey === "comment" && ( + <> + + Comment + + )} + +
+ + + ); +}; + export const SecretDropzone = ({ isSmaller, environments = [], @@ -50,11 +149,14 @@ export const SecretDropzone = ({ const [isDragActive, setDragActive] = useToggle(); const [isLoading, setIsLoading] = useToggle(); - const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "importSecEnv", - "confirmUpload", - "pasteSecEnv" - ] as const); + // Maps matrix columns to parts of a secret + const [importSecretMatrixMap, setImportSecretMatrixMap] = useState({ + key: 0, + value: null, + comment: null + }); + + const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys); const queryClient = useQueryClient(); const { openPopUp } = usePopUpAction(); @@ -136,7 +238,7 @@ export const SecretDropzone = ({ }); return; } - // const fileType = file.name.split('.')[1]; + setIsLoading.on(); reader.onload = (event) => { if (!event?.target?.result) return; @@ -154,7 +256,22 @@ export const SecretDropzone = ({ case "application/yaml": env = parseYaml(src); break; - + case "text/csv": { + const fullMatrix = parseCsvToMatrix(src); + if (!fullMatrix.length) { + createNotification({ + type: "error", + text: "Failed to find secrets in CSV file. File might be empty." + }); + setIsLoading.off(); + return; + } + const headers = fullMatrix[0]; + const matrix = fullMatrix.slice(1); + handlePopUpOpen("importMatrixMap", { headers, matrix }); + setIsLoading.off(); + return; + } default: env = parseDotEnv(src); break; @@ -171,6 +288,22 @@ export const SecretDropzone = ({ } }; + const finishMappedMatrixImport = (matrix: string[][]) => { + const env: TParsedEnv = {}; + matrix.forEach((row) => { + const key = row[importSecretMatrixMap.key]; + if (key) { + env[key] = { + value: importSecretMatrixMap.value ? row[importSecretMatrixMap.value] : "", + comments: importSecretMatrixMap.comment ? [row[importSecretMatrixMap.comment]] : [] + }; + } + }); + handlePopUpClose("importMatrixMap"); + setImportSecretMatrixMap({ key: 0, value: null, comment: null }); + handleParsedEnv(env); + }; + const handleDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -293,7 +426,7 @@ export const SecretDropzone = ({ disabled={!isAllowed} type="file" className="absolute h-full w-full cursor-pointer opacity-0" - accept=".txt,.env,.yml,.yaml,.json" + accept=".txt,.env,.yml,.yaml,.json,.csv" onChange={handleFileUpload} /> )} @@ -407,6 +540,75 @@ export const SecretDropzone = ({ )} + + {/* Matrix Import Modal */} + handlePopUpToggle("importMatrixMap", open)} + > + +
+ + + + + + + + + {/* Key */} + + + {/* Value */} + + + {/* Comment */} + + +
+ + + + +
+
+ +
+ +
+
+
); }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index d3845fcbc..953176ff5 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -27,7 +27,10 @@ import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { hasSecretReference, SecretReferenceTree } from "@app/components/secrets/SecretReferenceDetails"; +import { + hasSecretReference, + SecretReferenceTree +} from "@app/components/secrets/SecretReferenceDetails"; import { Button, Drawer, @@ -49,8 +52,12 @@ import { Tooltip } from "@app/components/v2"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission, useWorkspace } from "@app/context"; - +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { getProjectBaseURL } from "@app/helpers/project"; import { usePopUp } from "@app/hooks";