import { ChangeEvent, Dispatch, DragEvent, SetStateAction, useState } from "react"; import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; 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"; 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 { 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"; import { dashboardKeys, fetchDashboardProjectSecretsByKeys } from "@app/hooks/api/dashboard/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries"; import { SecretType } from "@app/hooks/api/types"; import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store"; import { CopySecretsFromBoard } from "./CopySecretsFromBoard"; import { PasteSecretEnvModal } from "./PasteSecretEnvModal"; type TParsedEnv = Record; type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv }; type Props = { isSmaller: boolean; environments?: { name: string; slug: string }[]; projectId: string; environment: string; secretPath: string; 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 = [], projectId, environment, secretPath, isProtectedBranch = false }: Props): JSX.Element => { const { t } = useTranslation(); const [isDragActive, setDragActive] = useToggle(); const [isLoading, setIsLoading] = useToggle(); // 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(); const { mutateAsync: updateSecretBatch, isPending: isUpdatingSecrets } = useUpdateSecretBatch({ options: { onSuccess: undefined } }); const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({ options: { onSuccess: undefined } }); // hide copy secrets from board due to import folders feature const shouldRenderCopySecrets = false; const isSubmitting = isCreatingSecrets || isUpdatingSecrets; 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 handleParsedEnv = async (env: TParsedEnv) => { const envSecretKeys = Object.keys(env); if (!envSecretKeys.length) { createNotification({ type: "error", text: "Failed to find secrets" }); return; } try { setIsLoading.on(); const { secrets: existingSecrets } = await fetchDashboardProjectSecretsByKeys({ secretPath, environment, projectId, keys: envSecretKeys }); const secretsGroupedByKey = existingSecrets.reduce>( (prev, curr) => ({ ...prev, [curr.secretKey]: true }), {} ); const updateSecrets = Object.keys(env) .filter((secKey) => secretsGroupedByKey[secKey]) .reduce((prev, curr) => ({ ...prev, [curr]: env[curr] }), {}); const createSecrets = Object.keys(env) .filter((secKey) => !secretsGroupedByKey[secKey]) .reduce((prev, curr) => ({ ...prev, [curr]: env[curr] }), {}); handlePopUpOpen("confirmUpload", { update: updateSecrets, create: createSecrets }); } catch (e) { console.error(e); createNotification({ text: "Failed to check for secret conflicts", type: "error" }); handlePopUpClose("confirmUpload"); } finally { setIsLoading.off(); } }; const parseFile = (file?: File) => { const reader = new FileReader(); if (!file) { createNotification({ text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.", type: "error" }); return; } setIsLoading.on(); reader.onload = (event) => { if (!event?.target?.result) { createNotification({ type: "error", text: "Invalid file contents." }); setIsLoading.off(); return; } let env: TParsedEnv; const src = event.target.result as ArrayBuffer; switch (file.type) { case "application/json": env = parseJson(src); break; case "text/yaml": case "application/x-yaml": 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; } setIsLoading.off(); handleParsedEnv(env); }; // If something is wrong show an error try { reader.readAsText(file); } catch (error) { console.log(error); } }; 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(); if (!e.dataTransfer) { return; } e.dataTransfer.dropEffect = "copy"; setDragActive.off(); parseFile(e.dataTransfer.files[0]); }; const handleFileUpload = (e: ChangeEvent) => { e.preventDefault(); parseFile(e.target?.files?.[0]); }; const handleSaveSecrets = async () => { const { update, create } = popUp?.confirmUpload?.data as TSecOverwriteOpt; try { if (Object.keys(create || {}).length) { await createSecretBatch({ secretPath, projectId, environment, secrets: Object.entries(create).map(([secretKey, secData]) => ({ type: SecretType.Shared, secretComment: secData.comments.join("\n"), secretValue: secData.value, secretKey })) }); } if (Object.keys(update || {}).length) { await updateSecretBatch({ secretPath, projectId, environment, secrets: Object.entries(update).map(([secretKey, secData]) => ({ type: SecretType.Shared, secretComment: secData.comments.join("\n"), secretValue: secData.value, secretKey })) }); } queryClient.invalidateQueries({ queryKey: secretKeys.getProjectSecret({ projectId, environment, secretPath }) }); queryClient.invalidateQueries({ queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath }) }); queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ projectId }) }); handlePopUpClose("confirmUpload"); createNotification({ type: "success", text: isProtectedBranch ? "Uploaded changes have been sent for review" : "Successfully uploaded secrets" }); } catch (err) { console.log(err); createNotification({ type: "error", text: "Failed to upload secrets" }); } }; const createSecretCount = Object.keys( (popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {} ).length; const updateSecretCount = Object.keys( (popUp.confirmUpload?.data as TSecOverwriteOpt)?.update || {} ).length; const isNonConflictingUpload = !updateSecretCount; return (
{isLoading ? (
) : (

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

{(isAllowed) => ( )}

OR

handlePopUpToggle("pasteSecEnv", isOpen)} onParsedEnv={handleParsedEnv} environment={environment} secretPath={secretPath} isSmaller={isSmaller} /> {shouldRenderCopySecrets && ( handlePopUpToggle("importSecEnv", isOpen)} onParsedEnv={handleParsedEnv} environment={environment} environments={environments} projectId={projectId} secretPath={secretPath} isSmaller={isSmaller} /> )} {!isSmaller && ( {(isAllowed) => ( )} )}
)}
handlePopUpToggle("confirmUpload", open)} > {isNonConflictingUpload ? "Upload" : "Overwrite"} , ]} > {isNonConflictingUpload ? (
Are you sure you want to import {createSecretCount} secret {createSecretCount > 1 ? "s" : ""} to this environment?
) : (
Your project already contains the following {updateSecretCount} secrets:
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {}) ?.map((key) => key) .join(", ")}
Are you sure you want to overwrite these secrets {createSecretCount > 0 ? ` and import ${createSecretCount} new one${createSecretCount > 1 ? "s" : ""}` : ""} ?
)}
{/* Matrix Import Modal */} handlePopUpToggle("importMatrixMap", open)} >
{/* Key */} {/* Value */} {/* Comment */}
); };