mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 13:28:27 +00:00
Merge pull request #188 from mocherfaoui/import-export-secrets
add ability to import/export secrets with comments
This commit is contained in:
@@ -3,10 +3,11 @@ import Image from "next/image";
|
|||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { faUpload } from "@fortawesome/free-solid-svg-icons";
|
import { faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { parseDocument, Scalar, YAMLMap } from 'yaml';
|
||||||
|
|
||||||
import Button from "../basic/buttons/Button";
|
import Button from "../basic/buttons/Button";
|
||||||
import Error from "../basic/Error";
|
import Error from "../basic/Error";
|
||||||
import parse from "../utilities/file";
|
import { parseDotEnv } from '../utilities/parseDotEnv';
|
||||||
import guidGenerator from "../utilities/randomId";
|
import guidGenerator from "../utilities/randomId";
|
||||||
|
|
||||||
interface DropZoneProps {
|
interface DropZoneProps {
|
||||||
@@ -51,6 +52,53 @@ const DropZone = ({
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const getSecrets = (file: ArrayBuffer, fileType: string) => {
|
||||||
|
let secrets;
|
||||||
|
switch (fileType) {
|
||||||
|
case 'env': {
|
||||||
|
const keyPairs = parseDotEnv(file);
|
||||||
|
secrets = Object.keys(keyPairs).map((key, index) => {
|
||||||
|
return {
|
||||||
|
id: guidGenerator(),
|
||||||
|
pos: numCurrentRows + index,
|
||||||
|
key: key,
|
||||||
|
value: keyPairs[key as keyof typeof keyPairs].value,
|
||||||
|
comment: keyPairs[key as keyof typeof keyPairs].comments.join('\n'),
|
||||||
|
type: 'shared',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'yml': {
|
||||||
|
const parsedFile = parseDocument(file.toString());
|
||||||
|
const keyPairs = parsedFile.contents!.toJSON();
|
||||||
|
|
||||||
|
secrets = Object.keys(keyPairs).map((key, index) => {
|
||||||
|
const fileContent = parsedFile.contents as YAMLMap<Scalar, Scalar>;
|
||||||
|
const comment =
|
||||||
|
fileContent!.items
|
||||||
|
.find((item) => item.key.value === key)
|
||||||
|
?.key?.commentBefore?.split('\n')
|
||||||
|
.map((comment) => comment.trim())
|
||||||
|
.join('\n') ?? '';
|
||||||
|
return {
|
||||||
|
id: guidGenerator(),
|
||||||
|
pos: numCurrentRows + index,
|
||||||
|
key: key,
|
||||||
|
value: keyPairs[key as keyof typeof keyPairs]?.toString() ?? '',
|
||||||
|
comment,
|
||||||
|
type: 'shared',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
secrets = '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return secrets;
|
||||||
|
};
|
||||||
|
|
||||||
// This function function immediately parses the file after it is dropped
|
// This function function immediately parses the file after it is dropped
|
||||||
const handleDrop = async (e: DragEvent) => {
|
const handleDrop = async (e: DragEvent) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -61,20 +109,12 @@ const DropZone = ({
|
|||||||
|
|
||||||
const file = e.dataTransfer.files[0];
|
const file = e.dataTransfer.files[0];
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
const fileType = file.name.split('.')[1];
|
||||||
|
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
if (event.target === null || event.target.result === null) return;
|
if (event.target === null || event.target.result === null) return;
|
||||||
// parse function's argument looks like to be ArrayBuffer
|
// parse function's argument looks like to be ArrayBuffer
|
||||||
const keyPairs = parse(event.target.result as Buffer);
|
const newData = getSecrets(event.target.result as ArrayBuffer, fileType);
|
||||||
const newData = Object.keys(keyPairs).map((key, index) => {
|
|
||||||
return {
|
|
||||||
id: guidGenerator(),
|
|
||||||
pos: numCurrentRows + index,
|
|
||||||
key: key,
|
|
||||||
value: keyPairs[key as keyof typeof keyPairs],
|
|
||||||
type: "shared",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setData(newData);
|
setData(newData);
|
||||||
setButtonReady(true);
|
setButtonReady(true);
|
||||||
};
|
};
|
||||||
@@ -95,25 +135,14 @@ const DropZone = ({
|
|||||||
setTimeout(() => setLoading(false), 5000);
|
setTimeout(() => setLoading(false), 5000);
|
||||||
if (e.currentTarget.files === null) return;
|
if (e.currentTarget.files === null) return;
|
||||||
const file = e.currentTarget.files[0];
|
const file = e.currentTarget.files[0];
|
||||||
|
const fileType = file.name.split('.')[1];
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
if (event.target === null || event.target.result === null) return;
|
if (event.target === null || event.target.result === null) return;
|
||||||
const { result } = event.target;
|
const { result } = event.target;
|
||||||
if (typeof result === "string") {
|
const newData = getSecrets(result as ArrayBuffer, fileType);
|
||||||
const newData = result
|
|
||||||
.split("\n")
|
|
||||||
.map((line: string, index: number) => {
|
|
||||||
return {
|
|
||||||
id: guidGenerator(),
|
|
||||||
pos: numCurrentRows + index,
|
|
||||||
key: line.split("=")[0],
|
|
||||||
value: line.split("=").slice(1, line.split("=").length).join("="),
|
|
||||||
type: "shared",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setData(newData);
|
setData(newData);
|
||||||
setButtonReady(true);
|
setButtonReady(true);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
};
|
};
|
||||||
@@ -139,7 +168,7 @@ const DropZone = ({
|
|||||||
id="fileSelect"
|
id="fileSelect"
|
||||||
type="file"
|
type="file"
|
||||||
className="opacity-0 absolute w-full h-full"
|
className="opacity-0 absolute w-full h-full"
|
||||||
accept=".txt,.env"
|
accept=".txt,.env,.yml"
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
/>
|
/>
|
||||||
{errorDragAndDrop ? (
|
{errorDragAndDrop ? (
|
||||||
@@ -176,7 +205,7 @@ const DropZone = ({
|
|||||||
id="fileSelect"
|
id="fileSelect"
|
||||||
type="file"
|
type="file"
|
||||||
className="opacity-0 absolute w-full h-full"
|
className="opacity-0 absolute w-full h-full"
|
||||||
accept=".txt,.env"
|
accept=".txt,.env,.yml"
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-row w-full items-center justify-center mb-6 mt-5">
|
<div className="flex flex-row w-full items-center justify-center mb-6 mt-5">
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
const LINE =
|
|
||||||
/(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return text that is the buffer parsed
|
|
||||||
* @param {Buffer} src - source buffer
|
|
||||||
* @returns {String} text - text of buffer
|
|
||||||
*/
|
|
||||||
function parse(src: Buffer) {
|
|
||||||
const obj: Record<string, string> = {};
|
|
||||||
|
|
||||||
// Convert buffer to string
|
|
||||||
let lines = src.toString();
|
|
||||||
|
|
||||||
// Convert line breaks to same format
|
|
||||||
lines = lines.replace(/\r\n?/gm, '\n');
|
|
||||||
|
|
||||||
let match;
|
|
||||||
while ((match = LINE.exec(lines)) != null) {
|
|
||||||
const key = match[1];
|
|
||||||
|
|
||||||
// Default undefined or null to empty string
|
|
||||||
let value = match[2] || '';
|
|
||||||
|
|
||||||
// Remove whitespace
|
|
||||||
value = value.trim();
|
|
||||||
|
|
||||||
// Check if double quoted
|
|
||||||
const maybeQuote = value[0];
|
|
||||||
|
|
||||||
// Remove surrounding quotes
|
|
||||||
value = value.replace(/^(['"`])([\s\S]*)\1$/gm, '$2');
|
|
||||||
|
|
||||||
// Expand newlines if double quoted
|
|
||||||
if (maybeQuote === '"') {
|
|
||||||
value = value.replace(/\\n/g, '\n');
|
|
||||||
value = value.replace(/\\r/g, '\r');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to object
|
|
||||||
obj[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default parse;
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const LINE =
|
||||||
|
/(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return text that is the buffer parsed
|
||||||
|
* @param {ArrayBuffer} src - source buffer
|
||||||
|
* @returns {String} text - text of buffer
|
||||||
|
*/
|
||||||
|
export function parseDotEnv(src: ArrayBuffer) {
|
||||||
|
const object: {
|
||||||
|
[key: string]: { value: string; comments: string[] };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
// Convert buffer to string
|
||||||
|
let lines = src.toString();
|
||||||
|
|
||||||
|
// Convert line breaks to same format
|
||||||
|
lines = lines.replace(/\r\n?/gm, '\n');
|
||||||
|
|
||||||
|
let comments: string[] = [];
|
||||||
|
|
||||||
|
lines
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
// collect comments of each env variable
|
||||||
|
if (line.startsWith('#')) {
|
||||||
|
comments.push(line.replace('#', '').trim());
|
||||||
|
} else if (line) {
|
||||||
|
let match;
|
||||||
|
let item: [string, string, string[]] | [] = [];
|
||||||
|
|
||||||
|
while ((match = LINE.exec(line)) !== null) {
|
||||||
|
const key = match[1];
|
||||||
|
|
||||||
|
// Default undefined or null to empty string
|
||||||
|
let value = match[2] || '';
|
||||||
|
|
||||||
|
// Remove whitespace
|
||||||
|
value = value.trim();
|
||||||
|
|
||||||
|
// Check if double quoted
|
||||||
|
const maybeQuote = value[0];
|
||||||
|
|
||||||
|
// Remove surrounding quotes
|
||||||
|
value = value.replace(/^(['"`])([\s\S]*)\1$/gm, '$2');
|
||||||
|
|
||||||
|
// Expand newlines if double quoted
|
||||||
|
if (maybeQuote === '"') {
|
||||||
|
value = value.replace(/\\n/g, '\n');
|
||||||
|
value = value.replace(/\\r/g, '\r');
|
||||||
|
}
|
||||||
|
item = [key, value, comments];
|
||||||
|
}
|
||||||
|
comments = [];
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
.filter((line) => line.length > 1)
|
||||||
|
.forEach((line) => {
|
||||||
|
const [key, value, comments] = line;
|
||||||
|
object[key as string] = { value, comments };
|
||||||
|
});
|
||||||
|
|
||||||
|
return object;
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ class Capturer {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Telemetry {
|
export default class Telemetry {
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!Telemetry.instance) {
|
if (!Telemetry.instance) {
|
||||||
Telemetry.instance = new Capturer();
|
Telemetry.instance = new Capturer();
|
||||||
@@ -40,5 +40,3 @@ class Telemetry {
|
|||||||
return Telemetry.instance;
|
return Telemetry.instance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Telemetry;
|
|
||||||
|
|||||||
Generated
+2208
-2181
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,8 @@
|
|||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"tweetnacl-util": "^0.15.1",
|
"tweetnacl-util": "^0.15.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"uuidv4": "^6.2.13"
|
"uuidv4": "^6.2.13",
|
||||||
|
"yaml": "^2.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.4",
|
"@tailwindcss/typography": "^0.5.4",
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
faPlus,
|
faPlus,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import getProjectSercetSnapshotsCount from 'ee/api/secrets/GetProjectSercetSnapshotsCount';
|
import getProjectSercetSnapshotsCount from 'ee/api/secrets/GetProjectSercetSnapshotsCount';
|
||||||
import PITRecoverySidebar from 'ee/components/PITRecoverySidebar';
|
import PITRecoverySidebar from 'ee/components/PITRecoverySidebar';
|
||||||
|
import { Document, YAMLSeq } from 'yaml';
|
||||||
|
|
||||||
import Button from '~/components/basic/buttons/Button';
|
import Button from '~/components/basic/buttons/Button';
|
||||||
import ListBox from '~/components/basic/Listbox';
|
import ListBox from '~/components/basic/Listbox';
|
||||||
@@ -425,11 +427,42 @@ export default function Dashboard() {
|
|||||||
setData(sortedData);
|
setData(sortedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// check if there are secrets with an override
|
||||||
|
const checkOverrides = (data: SecretDataProps[]) => {
|
||||||
|
let secrets : SecretDataProps[] = data!.map((secret) => Object.create(secret));
|
||||||
|
const overridenSecrets = data!.filter(
|
||||||
|
(secret) => secret.type === 'personal'
|
||||||
|
);
|
||||||
|
if (overridenSecrets.length) {
|
||||||
|
overridenSecrets.forEach((secret) => {
|
||||||
|
const index = secrets!.findIndex(
|
||||||
|
(_secret) => _secret.key === secret.key && _secret.type === 'shared'
|
||||||
|
);
|
||||||
|
secrets![index].value = secret.value;
|
||||||
|
});
|
||||||
|
secrets = secrets!.filter((secret) => secret.type === 'shared');
|
||||||
|
}
|
||||||
|
return secrets;
|
||||||
|
};
|
||||||
// This function downloads the secrets as a .env file
|
// This function downloads the secrets as a .env file
|
||||||
const download = () => {
|
const downloadDotEnv = () => {
|
||||||
const file = data!
|
if (!data) return;
|
||||||
.map((item: SecretDataProps) => [item.key, item.value].join('='))
|
const secrets = checkOverrides(data)
|
||||||
|
|
||||||
|
const file = secrets!
|
||||||
|
.map(
|
||||||
|
(item: SecretDataProps) =>
|
||||||
|
`${
|
||||||
|
item.comment
|
||||||
|
? item.comment
|
||||||
|
.split('\n')
|
||||||
|
.map((comment) => '# '.concat(comment))
|
||||||
|
.join('\n') + '\n'
|
||||||
|
: ''
|
||||||
|
}` + [item.key, item.value].join('=')
|
||||||
|
)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
const blob = new Blob([file]);
|
const blob = new Blob([file]);
|
||||||
const fileDownloadUrl = URL.createObjectURL(blob);
|
const fileDownloadUrl = URL.createObjectURL(blob);
|
||||||
const alink = document.createElement('a');
|
const alink = document.createElement('a');
|
||||||
@@ -438,6 +471,34 @@ export default function Dashboard() {
|
|||||||
alink.click();
|
alink.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// This function downloads the secrets as a .yml file
|
||||||
|
const downloadYaml = () => {
|
||||||
|
if (!data) return;
|
||||||
|
const doc = new Document(new YAMLSeq());
|
||||||
|
const secrets = checkOverrides(data);
|
||||||
|
secrets.forEach((secret) => {
|
||||||
|
const pair = doc.createNode({ [secret.key]: secret.value });
|
||||||
|
pair.commentBefore = secret.comment
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => (line ? ' '.concat(line) : ''))
|
||||||
|
.join('\n');
|
||||||
|
doc.add(pair);
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = doc
|
||||||
|
.toString()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => (line.startsWith('-') ? line.replace('- ', '') : line))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([file]);
|
||||||
|
const fileDownloadUrl = URL.createObjectURL(blob);
|
||||||
|
const alink = document.createElement('a');
|
||||||
|
alink.href = fileDownloadUrl;
|
||||||
|
alink.download = envMapping[env] + '.yml';
|
||||||
|
alink.click();
|
||||||
|
};
|
||||||
|
|
||||||
const deleteCertainRow = ({ ids, secretName }: { ids: string[]; secretName: string; }) => {
|
const deleteCertainRow = ({ ids, secretName }: { ids: string[]; secretName: string; }) => {
|
||||||
deleteRow({ids, secretName});
|
deleteRow({ids, secretName});
|
||||||
};
|
};
|
||||||
@@ -614,12 +675,50 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
{!snapshotData && <div className="ml-2 min-w-max flex flex-row items-start justify-start">
|
{!snapshotData && <div className="ml-2 min-w-max flex flex-row items-start justify-start">
|
||||||
|
<Menu
|
||||||
|
as="div"
|
||||||
|
className="relative inline-block text-left"
|
||||||
|
>
|
||||||
|
<Menu.Button
|
||||||
|
as="div"
|
||||||
|
className="inline-flex w-full justify-center text-sm font-medium text-gray-200 rounded-md hover:bg-white/10 duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
onButtonPressed={download}
|
|
||||||
color="mineshaft"
|
color="mineshaft"
|
||||||
size="icon-md"
|
size="icon-md"
|
||||||
icon={faDownload}
|
icon={faDownload}
|
||||||
|
onButtonPressed={() => {}}
|
||||||
/>
|
/>
|
||||||
|
</Menu.Button>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items className="absolute z-50 drop-shadow-xl right-0 mt-0.5 w-[20rem] origin-top-right rounded-md bg-bunker border border-mineshaft-500 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none p-2 space-y-2">
|
||||||
|
<Menu.Item>
|
||||||
|
<Button
|
||||||
|
color="mineshaft"
|
||||||
|
onButtonPressed={downloadDotEnv}
|
||||||
|
size="md"
|
||||||
|
text="Download as .env"
|
||||||
|
/>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
<Button
|
||||||
|
color="mineshaft"
|
||||||
|
onButtonPressed={downloadYaml}
|
||||||
|
size="md"
|
||||||
|
text="Download as .yml"
|
||||||
|
/>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
</div>}
|
</div>}
|
||||||
<div className="ml-2 min-w-max flex flex-row items-start justify-start">
|
<div className="ml-2 min-w-max flex flex-row items-start justify-start">
|
||||||
<Button
|
<Button
|
||||||
@@ -672,7 +771,9 @@ export default function Dashboard() {
|
|||||||
modifyValue={listenChangeValue}
|
modifyValue={listenChangeValue}
|
||||||
modifyKey={listenChangeKey}
|
modifyKey={listenChangeKey}
|
||||||
isBlurred={blurred}
|
isBlurred={blurred}
|
||||||
isDuplicate={findDuplicates(data?.map((item) => item.key + item.type))?.includes(keyPair.key + keyPair.type)}
|
isDuplicate={findDuplicates(
|
||||||
|
data?.map((item) => item.key + item.type)
|
||||||
|
)?.includes(keyPair.key + keyPair.type)}
|
||||||
toggleSidebar={toggleSidebar}
|
toggleSidebar={toggleSidebar}
|
||||||
sidebarSecretId={sidebarSecretId}
|
sidebarSecretId={sidebarSecretId}
|
||||||
isSnapshot={false}
|
isSnapshot={false}
|
||||||
@@ -694,7 +795,9 @@ export default function Dashboard() {
|
|||||||
modifyValue={listenChangeValue}
|
modifyValue={listenChangeValue}
|
||||||
modifyKey={listenChangeKey}
|
modifyKey={listenChangeKey}
|
||||||
isBlurred={blurred}
|
isBlurred={blurred}
|
||||||
isDuplicate={findDuplicates(data?.map((item) => item.key + item.type))?.includes(keyPair.key + keyPair.type)}
|
isDuplicate={findDuplicates(
|
||||||
|
data?.map((item) => item.key + item.type)
|
||||||
|
)?.includes(keyPair.key + keyPair.type)}
|
||||||
toggleSidebar={toggleSidebar}
|
toggleSidebar={toggleSidebar}
|
||||||
sidebarSecretId={sidebarSecretId}
|
sidebarSecretId={sidebarSecretId}
|
||||||
isSnapshot={true}
|
isSnapshot={true}
|
||||||
|
|||||||
Reference in New Issue
Block a user