mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(secret-import): CSV support (with a base for other matrix-based
formats)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user