mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-03 14:26:02 +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",
|
"project-id": "Project ID",
|
||||||
"save-changes": "Save Changes",
|
"save-changes": "Save Changes",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"drop-zone": "Drag and drop a .env, .json, or .yml file here.",
|
"drop-zone": "Drag and drop a .env, .json, .csv, or .yml file here.",
|
||||||
"drop-zone-keys": "Drag and drop a .env, .json, or .yml file here to add more secrets.",
|
"drop-zone-keys": "Drag and drop a .env, .json, .csv, or .yml file here to add more secrets.",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
"role_admin": "admin",
|
"role_admin": "admin",
|
||||||
"display-name": "Display Name",
|
"display-name": "Display Name",
|
||||||
|
|||||||
@@ -165,3 +165,61 @@ export function parseYaml(src: ArrayBuffer | string) {
|
|||||||
|
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+214
-12
@@ -1,7 +1,14 @@
|
|||||||
import { ChangeEvent, DragEvent } from "react";
|
import { ChangeEvent, Dispatch, DragEvent, SetStateAction, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { subject } from "@casl/ability";
|
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
@@ -9,8 +16,22 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
|
// 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 {
|
||||||
import { Button, Lottie, Modal, ModalContent } from "@app/components/v2";
|
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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
import { usePopUp, useToggle } from "@app/hooks";
|
import { usePopUp, useToggle } from "@app/hooks";
|
||||||
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
|
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
|
||||||
@@ -38,6 +59,84 @@ type Props = {
|
|||||||
isProtectedBranch?: boolean;
|
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 = ({
|
export const SecretDropzone = ({
|
||||||
isSmaller,
|
isSmaller,
|
||||||
environments = [],
|
environments = [],
|
||||||
@@ -50,11 +149,14 @@ export const SecretDropzone = ({
|
|||||||
const [isDragActive, setDragActive] = useToggle();
|
const [isDragActive, setDragActive] = useToggle();
|
||||||
const [isLoading, setIsLoading] = useToggle();
|
const [isLoading, setIsLoading] = useToggle();
|
||||||
|
|
||||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
// Maps matrix columns to parts of a secret
|
||||||
"importSecEnv",
|
const [importSecretMatrixMap, setImportSecretMatrixMap] = useState<SecretMatrixMap>({
|
||||||
"confirmUpload",
|
key: 0,
|
||||||
"pasteSecEnv"
|
value: null,
|
||||||
] as const);
|
comment: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { openPopUp } = usePopUpAction();
|
const { openPopUp } = usePopUpAction();
|
||||||
|
|
||||||
@@ -136,7 +238,7 @@ export const SecretDropzone = ({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// const fileType = file.name.split('.')[1];
|
|
||||||
setIsLoading.on();
|
setIsLoading.on();
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
if (!event?.target?.result) return;
|
if (!event?.target?.result) return;
|
||||||
@@ -154,7 +256,22 @@ export const SecretDropzone = ({
|
|||||||
case "application/yaml":
|
case "application/yaml":
|
||||||
env = parseYaml(src);
|
env = parseYaml(src);
|
||||||
break;
|
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:
|
default:
|
||||||
env = parseDotEnv(src);
|
env = parseDotEnv(src);
|
||||||
break;
|
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) => {
|
const handleDrop = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -293,7 +426,7 @@ export const SecretDropzone = ({
|
|||||||
disabled={!isAllowed}
|
disabled={!isAllowed}
|
||||||
type="file"
|
type="file"
|
||||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
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}
|
onChange={handleFileUpload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -407,6 +540,75 @@ export const SecretDropzone = ({
|
|||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-3
@@ -27,7 +27,10 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import { hasSecretReference, SecretReferenceTree } from "@app/components/secrets/SecretReferenceDetails";
|
import {
|
||||||
|
hasSecretReference,
|
||||||
|
SecretReferenceTree
|
||||||
|
} from "@app/components/secrets/SecretReferenceDetails";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -49,8 +52,12 @@ import {
|
|||||||
Tooltip
|
Tooltip
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
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 { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||||
import { getProjectBaseURL } from "@app/helpers/project";
|
import { getProjectBaseURL } from "@app/helpers/project";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
|
|||||||
Reference in New Issue
Block a user