feat(secret-import): CSV support (with a base for other matrix-based

formats)
This commit is contained in:
x032205
2025-08-11 14:09:04 -07:00
parent 88120ed45e
commit 458dcd31c1
4 changed files with 284 additions and 17 deletions

View File

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

View File

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

View File

@@ -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<SetStateAction<SecretMatrixMap>>;
headers: string[];
mapKey: keyof SecretMatrixMap;
}) => {
return (
<tr>
<td className="w-full">
<Select
value={importSecretMatrixMap[mapKey]?.toString() || (null as unknown as string)}
onValueChange={(v) =>
setImportSecretMatrixMap((ism) => ({
...ism,
[mapKey]: v ? parseInt(v, 10) : null
}))
}
className="w-full border border-mineshaft-500"
position="popper"
placeholder="Select an option..."
dropdownContainerClassName="max-w-none"
>
{mapKey !== "key" && <SelectItem value={null as unknown as string}>None</SelectItem>}
{headers.map((header, col) => {
return (
<SelectItem value={col.toString()} key={`${mapKey}-${header}`}>
{header}
</SelectItem>
);
})}
</Select>
</td>
<td className="whitespace-nowrap pl-5 pr-5">
<div className="flex items-center justify-center">
<FontAwesomeIcon className="text-mineshaft-400" icon={faArrowRight} />
</div>
</td>
<td className="whitespace-nowrap">
<div className="flex h-full items-start justify-center">
<Badge className="pointer-events-none flex h-[36px] w-full items-center justify-center gap-1.5 whitespace-nowrap border border-mineshaft-600 bg-mineshaft-600 text-bunker-200">
{mapKey === "key" && (
<>
<FontAwesomeIcon icon={faKey} />
<span>Secret Key</span>
</>
)}
{mapKey === "value" && (
<>
<FontAwesomeIcon icon={faAsterisk} />
<span>Secret Value</span>
</>
)}
{mapKey === "comment" && (
<>
<FontAwesomeIcon icon={faComment} />
<span>Comment</span>
</>
)}
</Badge>
</div>
</td>
</tr>
);
};
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<SecretMatrixMap>({
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 = ({
)}
</ModalContent>
</Modal>
{/* Matrix Import Modal */}
<Modal
isOpen={popUp?.importMatrixMap?.isOpen}
onOpenChange={(open) => handlePopUpToggle("importMatrixMap", open)}
>
<ModalContent
title="Import Column Mapping"
subTitle="Map your data columns to different parts of the secret"
>
<div className="w-full overflow-hidden">
<table className="w-full table-auto">
<thead>
<tr className="text-left">
<th>
<FormLabel tooltipClassName="max-w-sm" label="Import Column" />
</th>
<th />
<th className="whitespace-nowrap">
<FormLabel label="Resulting Import" />
</th>
</tr>
</thead>
<tbody>
{/* Key */}
<MatrixImportModalTableRow
importSecretMatrixMap={importSecretMatrixMap}
setImportSecretMatrixMap={setImportSecretMatrixMap}
headers={popUp?.importMatrixMap.data?.headers || []}
mapKey="key"
/>
{/* Value */}
<MatrixImportModalTableRow
importSecretMatrixMap={importSecretMatrixMap}
setImportSecretMatrixMap={setImportSecretMatrixMap}
headers={popUp?.importMatrixMap.data?.headers || []}
mapKey="value"
/>
{/* Comment */}
<MatrixImportModalTableRow
importSecretMatrixMap={importSecretMatrixMap}
setImportSecretMatrixMap={setImportSecretMatrixMap}
headers={popUp?.importMatrixMap.data?.headers || []}
mapKey="comment"
/>
</tbody>
</table>
</div>
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<Button
onClick={() =>
popUp.importMatrixMap.data?.matrix
? finishMappedMatrixImport(popUp.importMatrixMap.data?.matrix)
: createNotification({
text: "Invalid secret matrix.",
type: "error"
})
}
isFullWidth
variant="outline_bg"
>
Import Secrets
</Button>
</div>
</ModalContent>
</Modal>
</div>
);
};

View File

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