mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(dashboard-v3): implemented new the dashboard with v3 support
This commit is contained in:
24
frontend/package-lock.json
generated
24
frontend/package-lock.json
generated
@@ -51,6 +51,7 @@
|
||||
"cookies": "^0.8.0",
|
||||
"cva": "npm:class-variance-authority@^0.4.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^6.2.3",
|
||||
"fs": "^0.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -105,6 +106,7 @@
|
||||
"@storybook/react": "^7.0.23",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@tailwindcss/typography": "^0.5.4",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@types/jsrp": "^0.2.4",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/picomatch": "^2.3.0",
|
||||
@@ -8208,6 +8210,12 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/find-cache-dir": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz",
|
||||
@@ -13444,6 +13452,11 @@
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||
},
|
||||
"node_modules/file-system-cache": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-2.3.0.tgz",
|
||||
@@ -29227,6 +29240,12 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"@types/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/find-cache-dir": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz",
|
||||
@@ -33358,6 +33377,11 @@
|
||||
"flat-cache": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||
},
|
||||
"file-system-cache": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-2.3.0.tgz",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"cookies": "^0.8.0",
|
||||
"cva": "npm:class-variance-authority@^0.4.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^6.2.3",
|
||||
"fs": "^0.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -113,6 +114,7 @@
|
||||
"@storybook/react": "^7.0.23",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@tailwindcss/typography": "^0.5.4",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@types/jsrp": "^0.2.4",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/picomatch": "^2.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
|
||||
import { DashboardPage } from "@app/views/DashboardPage";
|
||||
import { SecretMainPage } from "@app/views/SecretMainPage";
|
||||
|
||||
const Dashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,7 +16,7 @@ const Dashboard = () => {
|
||||
<meta name="og:description" content={String(t("dashboard.og-description"))} />
|
||||
</Head>
|
||||
<div className="h-full">
|
||||
<DashboardPage />
|
||||
<SecretMainPage />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
27
frontend/src/pages/project/[id]/secrets/v2/[env].tsx
Normal file
27
frontend/src/pages/project/[id]/secrets/v2/[env].tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
|
||||
import { SecretMainPage } from "@app/views/SecretMainPage";
|
||||
|
||||
const Dashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{t("common.head-title", { title: t("dashboard.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
<meta property="og:title" content={String(t("dashboard.og-title"))} />
|
||||
<meta name="og:description" content={String(t("dashboard.og-description"))} />
|
||||
</Head>
|
||||
<div className="h-full">
|
||||
<SecretMainPage />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
Dashboard.requireAuth = true;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,289 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import crypto from "crypto";
|
||||
|
||||
import * as yup from "yup";
|
||||
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptSymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import { BatchSecretDTO, DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
|
||||
export enum SecretActionType {
|
||||
Created = "created",
|
||||
Modified = "modified",
|
||||
Deleted = "deleted"
|
||||
}
|
||||
|
||||
export const DEFAULT_SECRET_VALUE = {
|
||||
_id: undefined,
|
||||
overrideAction: undefined,
|
||||
idOverride: undefined,
|
||||
valueOverride: undefined,
|
||||
comment: "",
|
||||
key: "",
|
||||
value: "",
|
||||
tags: []
|
||||
};
|
||||
|
||||
const secretSchema = yup.object({
|
||||
_id: yup.string(),
|
||||
key: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required()
|
||||
.label("Secret key")
|
||||
.test("starts-with-number", "Should start with an alphabet", (val) =>
|
||||
Boolean(val?.charAt(0)?.match(/[a-zA-Z]/i))
|
||||
)
|
||||
.test({
|
||||
name: "duplicate-keys",
|
||||
// TODO:(akhilmhdh) ts keeps throwing from not found need to see how to resolve this
|
||||
test: (val, ctx: any) => {
|
||||
const secrets: Array<{ key: string }> = ctx?.from?.[1]?.value?.secrets || [];
|
||||
const duplicateKeys: Record<number, boolean> = {};
|
||||
secrets?.forEach(({ key }, index) => {
|
||||
if (key === val) duplicateKeys[index + 1] = true;
|
||||
});
|
||||
const pos = Object.keys(duplicateKeys);
|
||||
if (pos.length <= 1) {
|
||||
return true;
|
||||
}
|
||||
return ctx.createError({ message: `Same key in row ${pos.join(", ")}` });
|
||||
}
|
||||
}),
|
||||
value: yup.string().trim(),
|
||||
comment: yup.string().trim(),
|
||||
tags: yup.array(
|
||||
yup.object({
|
||||
_id: yup.string().required(),
|
||||
name: yup.string().required(),
|
||||
slug: yup.string().required(),
|
||||
tagColor: yup.string().nullable(),
|
||||
})
|
||||
),
|
||||
overrideAction: yup.string().notRequired().oneOf(Object.values(SecretActionType)),
|
||||
idOverride: yup.string().notRequired(),
|
||||
valueOverride: yup.string().trim().notRequired()
|
||||
});
|
||||
|
||||
export const schema = yup.object({
|
||||
isSnapshotMode: yup.bool().notRequired(),
|
||||
secrets: yup.array(secretSchema)
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
export type TSecretDetailsOpen = { index: number; id: string };
|
||||
export type TSecOverwriteOpt = { secrets: Record<string, { comments: string[]; value: string }> };
|
||||
|
||||
// to convert multi line into single line ones by quoting them and changing to string \n
|
||||
const formatMultiValueEnv = (val?: string) => {
|
||||
if (!val) return "";
|
||||
if (!val.match("\n")) return val;
|
||||
return `"${val.replace(/\n/g, "\\n")}"`;
|
||||
};
|
||||
|
||||
export const downloadSecret = (
|
||||
secrets: FormData["secrets"] = [],
|
||||
importedSecrets: { key: string; value?: string; comment?: string }[] = [],
|
||||
env: string = "unknown"
|
||||
) => {
|
||||
const importSecPos: Record<string, number> = {};
|
||||
importedSecrets.forEach((el, index) => {
|
||||
importSecPos[el.key] = index;
|
||||
});
|
||||
const finalSecret = [...importedSecrets];
|
||||
secrets.forEach(({ key, value, valueOverride, overrideAction, comment }) => {
|
||||
const finalVal =
|
||||
overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value;
|
||||
const newValue = {
|
||||
key,
|
||||
value: formatMultiValueEnv(finalVal),
|
||||
comment
|
||||
};
|
||||
// can also be zero thus failing
|
||||
if (typeof importSecPos?.[key] === "undefined") {
|
||||
finalSecret.push(newValue);
|
||||
} else {
|
||||
finalSecret[importSecPos[key]] = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
let file = "";
|
||||
finalSecret.forEach(({ key, value, comment }) => {
|
||||
if (comment) {
|
||||
file += `# ${comment}\n${key}=${value}\n`;
|
||||
return;
|
||||
}
|
||||
file += `${key}=${value}\n`;
|
||||
});
|
||||
|
||||
const blob = new Blob([file]);
|
||||
const fileDownloadUrl = URL.createObjectURL(blob);
|
||||
const alink = document.createElement("a");
|
||||
alink.href = fileDownloadUrl;
|
||||
alink.download = `${env}.env`;
|
||||
alink.click();
|
||||
};
|
||||
|
||||
/*
|
||||
* Below functions are used convert the dashboard secrets to the bulk secret creation request format
|
||||
* They are encrypted back
|
||||
* Formatted to [ { request: "", secret:{} } ]
|
||||
*/
|
||||
const encryptASecret = (randomBytes: string, key: string, value?: string, comment?: string) => {
|
||||
// encrypt key
|
||||
const {
|
||||
ciphertext: secretKeyCiphertext,
|
||||
iv: secretKeyIV,
|
||||
tag: secretKeyTag
|
||||
} = encryptSymmetric({
|
||||
plaintext: key,
|
||||
key: randomBytes
|
||||
});
|
||||
|
||||
// encrypt value
|
||||
const {
|
||||
ciphertext: secretValueCiphertext,
|
||||
iv: secretValueIV,
|
||||
tag: secretValueTag
|
||||
} = encryptSymmetric({
|
||||
plaintext: value ?? "",
|
||||
key: randomBytes
|
||||
});
|
||||
|
||||
// encrypt comment
|
||||
const {
|
||||
ciphertext: secretCommentCiphertext,
|
||||
iv: secretCommentIV,
|
||||
tag: secretCommentTag
|
||||
} = encryptSymmetric({
|
||||
plaintext: comment ?? "",
|
||||
key: randomBytes
|
||||
});
|
||||
|
||||
return {
|
||||
secretKeyCiphertext,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretValueCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretCommentCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag
|
||||
};
|
||||
};
|
||||
|
||||
const deepCompareSecrets = (lhs: DecryptedSecret, rhs: any) =>
|
||||
lhs.key === rhs.key &&
|
||||
lhs.value === rhs.value &&
|
||||
lhs.comment === rhs.comment &&
|
||||
lhs?.valueOverride === rhs?.valueOverride &&
|
||||
JSON.stringify(lhs.tags) === JSON.stringify(rhs.tags);
|
||||
|
||||
export const transformSecretsToBatchSecretReq = (
|
||||
deletedSecretIds: { id: string; secretName: string; }[],
|
||||
latestFileKey: any,
|
||||
secrets: FormData["secrets"],
|
||||
intialValues: DecryptedSecret[] = []
|
||||
) => {
|
||||
// deleted secrets
|
||||
const secretsToBeDeleted: BatchSecretDTO["requests"] = deletedSecretIds.map(({ id, secretName }) => ({
|
||||
method: "DELETE",
|
||||
secret: {
|
||||
_id: id,
|
||||
secretName
|
||||
}
|
||||
}));
|
||||
|
||||
const secretsToBeUpdated: BatchSecretDTO["requests"] = [];
|
||||
const secretsToBeCreated: BatchSecretDTO["requests"] = [];
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
|
||||
const randomBytes = latestFileKey
|
||||
? decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
: crypto.randomBytes(16).toString("hex");
|
||||
|
||||
secrets?.forEach((secret) => {
|
||||
const {
|
||||
_id,
|
||||
idOverride,
|
||||
value,
|
||||
valueOverride,
|
||||
overrideAction,
|
||||
tags = [],
|
||||
comment,
|
||||
key
|
||||
} = secret;
|
||||
if (!idOverride && overrideAction === SecretActionType.Created) {
|
||||
secretsToBeCreated.push({
|
||||
method: "POST",
|
||||
secret: {
|
||||
type: "personal",
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, valueOverride, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
// to be created ones as they don't have server generated id
|
||||
if (!_id) {
|
||||
secretsToBeCreated.push({
|
||||
method: "POST",
|
||||
secret: {
|
||||
type: "shared",
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, value, comment)
|
||||
}
|
||||
});
|
||||
return; // exit as updated and delete case won't happen when created
|
||||
}
|
||||
// has an id means this is updated one
|
||||
if (_id) {
|
||||
// check value has changed or not
|
||||
const initialSecretValue = intialValues?.find(({ _id: secId }) => secId === _id)!;
|
||||
if (!deepCompareSecrets(initialSecretValue, secret)) {
|
||||
secretsToBeUpdated.push({
|
||||
method: "PATCH",
|
||||
secret: {
|
||||
_id,
|
||||
type: "shared",
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, value, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (idOverride) {
|
||||
// if action is deleted meaning override has been removed but id is kept to collect at this point
|
||||
if (overrideAction === SecretActionType.Deleted) {
|
||||
secretsToBeDeleted.push({ method: "DELETE", secret: { _id: idOverride, secretName: key } });
|
||||
} else {
|
||||
// if not deleted action then as id is there its an updated
|
||||
const initialSecretValue = intialValues?.find(({ _id: secId }) => secId === _id)!;
|
||||
if (!deepCompareSecrets(initialSecretValue, secret)) {
|
||||
secretsToBeUpdated.push({
|
||||
method: "PATCH",
|
||||
secret: {
|
||||
_id: idOverride,
|
||||
type: "personal",
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, valueOverride, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return secretsToBeCreated.concat(secretsToBeUpdated, secretsToBeDeleted);
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { FormControl, Input, Spinner } from "@app/components/v2";
|
||||
import { useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api";
|
||||
|
||||
type SecretValueProps = {
|
||||
workspaceId: string;
|
||||
envName: string;
|
||||
env: string;
|
||||
secretKey: string;
|
||||
};
|
||||
|
||||
const SecretValue = ({ workspaceId, env, envName, secretKey }: SecretValueProps) => {
|
||||
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
|
||||
const { data: secret, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
env,
|
||||
decryptFileKey: latestFileKey!
|
||||
});
|
||||
|
||||
const getValue = useCallback(
|
||||
(data: typeof secret) => {
|
||||
const sec = data?.secrets?.find(({ key: secKey }) => secKey === secretKey);
|
||||
return sec?.value || "Not found";
|
||||
},
|
||||
[secretKey]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl label={envName}>
|
||||
<Input
|
||||
className={`w-full text-ellipsis font-mono focus:ring-transparent ${getValue(secret) === "Not found" && "text-mineshaft-500"}`}
|
||||
value={getValue(secret)}
|
||||
isReadOnly
|
||||
rightIcon={isSecretsLoading ? <Spinner /> : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
workspaceId: string;
|
||||
secretKey: string;
|
||||
envs: Array<{ name: string; slug: string }>;
|
||||
};
|
||||
|
||||
export const CompareSecret = ({ workspaceId, secretKey, envs }: Props): JSX.Element => {
|
||||
// should not do anything until secretKey is available
|
||||
if (!secretKey) return <div />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{envs.map(({ name, slug }) => (
|
||||
<SecretValue
|
||||
workspaceId={workspaceId}
|
||||
key={`secret-comparison-${slug}`}
|
||||
envName={name}
|
||||
env={slug}
|
||||
secretKey={secretKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { CompareSecret } from "./CompareSecret";
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
faCheck
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose, Tooltip } from "@app/components/v2";
|
||||
|
||||
import { isValidHexColor } from "../../../../components/utilities/isValidHexColor";
|
||||
import { secretTagsColors } from "../../../../const"
|
||||
import { TagColor } from "../../../../hooks/api/tags/types";
|
||||
|
||||
|
||||
type Props = {
|
||||
onCreateTag: (tagName: string, tagColor: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const createTagSchema = yup.object({
|
||||
name: yup.string().required().trim().label("Tag Name")
|
||||
});
|
||||
type FormData = yup.InferType<typeof createTagSchema>;
|
||||
|
||||
export const CreateTagModal = ({ onCreateTag }: Props): JSX.Element => {
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(createTagSchema)
|
||||
});
|
||||
|
||||
const [tagsColors] = useState<TagColor[]>(secretTagsColors)
|
||||
const [selectedTagColor, setSelectedTagColor] = useState<TagColor>(tagsColors[0])
|
||||
const [showHexInput, setShowHexInput] = useState<boolean>(false)
|
||||
const [tagColor, setTagColor] = useState<string>("")
|
||||
|
||||
|
||||
const onFormSubmit = async ({ name }: FormData) => {
|
||||
await onCreateTag(name, tagColor);
|
||||
reset();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const clonedTagColors = [...tagsColors]
|
||||
const selectedTagBgColor = clonedTagColors.find($tagColor => $tagColor.selected);
|
||||
|
||||
if (selectedTagBgColor) {
|
||||
setSelectedTagColor(selectedTagBgColor);
|
||||
setTagColor(selectedTagBgColor.hex);
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const tagsList = document.querySelector(".secret-tags-wrapper")
|
||||
const tagsHexWrapper = document.querySelector(".tags-hex-wrapper")
|
||||
|
||||
if (showHexInput) {
|
||||
tagsList?.classList.add("hide-tags")
|
||||
tagsList?.classList.remove("show-tags")
|
||||
tagsHexWrapper?.classList.add("show-hex-input")
|
||||
tagsHexWrapper?.classList.remove("hide-hex-input")
|
||||
} else {
|
||||
tagsList?.classList.remove("hide-tags")
|
||||
tagsList?.classList.add("show-tags")
|
||||
tagsHexWrapper?.classList.remove("show-hex-input")
|
||||
tagsHexWrapper?.classList.add("hide-hex-input")
|
||||
}
|
||||
}, [showHexInput])
|
||||
|
||||
|
||||
const handleColorChange = (clickedTagColor: TagColor) => {
|
||||
const updatedTagColors = [...tagsColors];
|
||||
const clickedTagColorIndex = updatedTagColors.findIndex(($tagColor) => $tagColor.id === clickedTagColor.id);
|
||||
const updatedClickedTagColor = updatedTagColors[clickedTagColorIndex];
|
||||
|
||||
updatedTagColors.forEach((tgColor) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
tgColor.selected = false;
|
||||
});
|
||||
|
||||
if (selectedTagColor.id !== clickedTagColor.id) {
|
||||
updatedClickedTagColor.selected = !updatedClickedTagColor.selected;
|
||||
setSelectedTagColor(updatedClickedTagColor);
|
||||
setTagColor(updatedClickedTagColor.hex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Tag Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="Type your tag name" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="mb-0.5 ml-1 block text-sm font-normal text-mineshaft-400">Tag Color</div>
|
||||
<div className="flex gap-2 h-[50px]">
|
||||
<div className="w-[12%] h-[2.813rem] inline-flex font-inter items-center justify-center border relative rounded-md border-mineshaft-500 bg-mineshaft-900 hover:bg-mineshaft-800">
|
||||
<div className="w-[26px] h-[26px] rounded-full" style={{ background: `${tagColor}` }} />
|
||||
</div>
|
||||
|
||||
<div className="w-[88%] h-[2.813rem] flex-wrap inline-flex gap-3 items-center border rounded-md border-mineshaft-500 bg-mineshaft-900 hover:bg-mineshaft-800 relative">
|
||||
<div className="flex-wrap inline-flex gap-3 items-center secret-tags-wrapper pl-3">
|
||||
{
|
||||
tagsColors.map(($tagColor: TagColor) => {
|
||||
return (
|
||||
<div key={`tag-color-${$tagColor.id}`}>
|
||||
<Tooltip content={`${$tagColor.name}`}>
|
||||
<div className=" flex items-center justify-center w-[26px] h-[26px] hover:ring-offset-2 hover:ring-2 bg-[#bec2c8] border-2 p-2 hover:shadow-lg border-transparent hover:border-black rounded-full"
|
||||
key={`tag-${$tagColor.id}`}
|
||||
style={{ backgroundColor: `${$tagColor.hex}` }}
|
||||
|
||||
onClick={() => handleColorChange($tagColor)}
|
||||
tabIndex={0} role="button"
|
||||
onKeyDown={() => { }}
|
||||
>
|
||||
{
|
||||
$tagColor.selected && <FontAwesomeIcon icon={faCheck} style={{ color: "#00000070" }} />
|
||||
}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-2 tags-hex-wrapper" >
|
||||
<div className="w-1/6 flex items-center relative rounded-md hover:bg-mineshaft-800">
|
||||
{
|
||||
isValidHexColor(tagColor) && (
|
||||
<div className="w-[26px] h-[26px] rounded-full flex items-center justify-center" style={{ background: `${tagColor}` }}>
|
||||
<FontAwesomeIcon icon={faCheck} style={{ color: "#00000070" }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!isValidHexColor(tagColor) && (
|
||||
<div className="border-dashed border bg-blue rounded-full w-[26px] h-[26px] border-mineshaft-500" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="w-10/12">
|
||||
<Input
|
||||
variant="plain"
|
||||
className="w-full focus:text-bunker-100 focus:ring-transparent bg-transparent"
|
||||
autoCapitalization={false}
|
||||
value={tagColor}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTagColor(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[26px] h-[26px] flex items-center justify-center absolute top-[10px] right-[-4px] translate-x-[-50%]">
|
||||
<div className="border-mineshaft-500 border h-[2.1rem] mr-4 absolute right-5" />
|
||||
<div className={`flex items-center justify-center w-[26px] h-[26px] bg-transparent cursor-pointer hover:ring-offset-1 hover:ring-2 border-mineshaft-500 border bg-mineshaft-900 rounded-[3px] p-2 ${showHexInput ? "tags-conic-bg rounded-full" : ""}`} onClick={() => setShowHexInput((prev) => !prev)} style={{ border: "1px solid rgba(220, 216, 254, 0.376)" }}
|
||||
tabIndex={0} role="button"
|
||||
onKeyDown={() => { }}>
|
||||
{
|
||||
!showHexInput && <span>#</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
||||
Create
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export {CreateTagModal} from "./CreateTagModal"
|
||||
@@ -1,108 +0,0 @@
|
||||
import { memo } from "react";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { IconButton, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
|
||||
type Props = {
|
||||
folders?: Array<{ id: string; name: string }>;
|
||||
search?: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onFolderUpdate: (folderId: string, name: string) => void;
|
||||
onFolderDelete: (folderId: string, name: string) => void;
|
||||
onFolderOpen: (folderId: string) => void;
|
||||
};
|
||||
|
||||
export const FolderSection = memo(
|
||||
({
|
||||
onFolderUpdate: handleFolderUpdate,
|
||||
onFolderDelete: handleFolderDelete,
|
||||
onFolderOpen: handleFolderOpen,
|
||||
search = "",
|
||||
folders = [],
|
||||
environment,
|
||||
secretPath
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
{folders
|
||||
.filter(({ name }) => name.toLowerCase().includes(search.toLowerCase()))
|
||||
.map(({ id, name }) => (
|
||||
<tr
|
||||
key={id}
|
||||
className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700"
|
||||
>
|
||||
<td className="ml-0.5 flex h-10 w-10 items-center justify-center border-none px-4">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
|
||||
</td>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="relative flex w-full min-w-[220px] items-center justify-between overflow-hidden text-ellipsis lg:min-w-[240px] xl:min-w-[280px]"
|
||||
style={{ paddingTop: "0", paddingBottom: "0" }}
|
||||
>
|
||||
<div
|
||||
className="flex-grow cursor-default p-2"
|
||||
onKeyDown={() => null}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={() => handleFolderOpen(id)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Settings" className="capitalize">
|
||||
<IconButton
|
||||
size="md"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => handleFolderUpdate(id, name)}
|
||||
ariaLabel="expand"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Delete" className="capitalize">
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => handleFolderDelete(id, name)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FolderSection.displayName = "FolderSection";
|
||||
@@ -1,3 +0,0 @@
|
||||
export { FolderForm } from "./FolderForm";
|
||||
export { FolderSection } from "./FolderSection";
|
||||
export * from "./types";
|
||||
@@ -1,2 +0,0 @@
|
||||
export type TEditFolderForm = { id: string; name: string };
|
||||
export type TDeleteFolderForm = { id: string; name: string };
|
||||
@@ -1,237 +0,0 @@
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faCircle, faCircleDot, faShuffle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
FormControl,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Switch,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
import { FormData, SecretActionType } from "../../DashboardPage.utils";
|
||||
import { GenRandomNumber } from "./GenRandomNumber";
|
||||
|
||||
type Props = {
|
||||
isDrawerOpen: boolean;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
index: number;
|
||||
isReadOnly?: boolean;
|
||||
onEnvCompare: (secretKey: string) => void;
|
||||
secretVersion?: Array<{ id: string; createdAt: string; value: string }>;
|
||||
// to record the ids of deleted ones
|
||||
onSecretDelete: (index: number, secretName: string, id?: string, overrideId?: string) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export const SecretDetailDrawer = ({
|
||||
isDrawerOpen,
|
||||
onOpenChange,
|
||||
index,
|
||||
secretVersion = [],
|
||||
isReadOnly,
|
||||
onSecretDelete,
|
||||
onSave,
|
||||
onEnvCompare,
|
||||
environment,
|
||||
secretPath
|
||||
}: Props): JSX.Element => {
|
||||
const [canRevealSecVal, setCanRevealSecVal] = useToggle();
|
||||
const [canRevealSecOverride, setCanRevealSecOverride] = useToggle();
|
||||
|
||||
const { register, setValue, control, getValues } = useFormContext<FormData>();
|
||||
|
||||
const overrideAction = useWatch({ control, name: `secrets.${index}.overrideAction` });
|
||||
const isOverridden =
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
|
||||
const onSecretOverride = () => {
|
||||
const secret = getValues(`secrets.${index}`);
|
||||
if (isOverridden) {
|
||||
// when user created a new override but then removes
|
||||
if (SecretActionType.Created) {
|
||||
setValue(`secrets.${index}.valueOverride`, "", { shouldDirty: true });
|
||||
}
|
||||
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, { shouldDirty: true });
|
||||
} else {
|
||||
setValue(
|
||||
`secrets.${index}.overrideAction`,
|
||||
secret?.idOverride ? SecretActionType.Modified : SecretActionType.Created,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer onOpenChange={onOpenChange} isOpen={isDrawerOpen}>
|
||||
<DrawerContent
|
||||
className="dark border-l border-mineshaft-500 bg-bunker"
|
||||
title="Secret"
|
||||
footerContent={
|
||||
<div className="flex flex-col space-y-2 pt-4 shadow-md">
|
||||
<div>
|
||||
<Button
|
||||
variant="star"
|
||||
onClick={() => onEnvCompare(getValues(`secrets.${index}.key`))}
|
||||
isFullWidth
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
Compare secret across environments
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button isFullWidth onClick={onSave} isDisabled={isReadOnly || !isAllowed}>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
isDisabled={isReadOnly || !isAllowed}
|
||||
onClick={() => {
|
||||
const secret = getValues(`secrets.${index}`);
|
||||
|
||||
onSecretDelete(index, secret.key, secret._id, secret.idOverride);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="dark:[color-scheme:dark]">
|
||||
<FormControl label="Key">
|
||||
<Input isDisabled {...register(`secrets.${index}.key`)} />
|
||||
</FormControl>
|
||||
<FormControl label="Value">
|
||||
<Popover>
|
||||
<Input
|
||||
isReadOnly={isOverridden || isReadOnly}
|
||||
{...register(`secrets.${index}.value`)}
|
||||
placeholder="EMPTY"
|
||||
onBlur={setCanRevealSecVal.off}
|
||||
onFocus={setCanRevealSecVal.on}
|
||||
type={canRevealSecVal ? "text" : "password"}
|
||||
rightIcon={
|
||||
<PopoverTrigger disabled={isOverridden || isReadOnly}>
|
||||
<FontAwesomeIcon icon={faShuffle} />
|
||||
</PopoverTrigger>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
hideCloseBtn
|
||||
className="w-auto border-mineshaft-500 bg-bunker p-0"
|
||||
align="end"
|
||||
>
|
||||
<GenRandomNumber
|
||||
onGenerate={(val) =>
|
||||
setValue(`secrets.${index}.value`, val, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
<div className="mb-2 border-t border-mineshaft-600 pt-4">
|
||||
<Switch
|
||||
id="personal-override"
|
||||
onCheckedChange={onSecretOverride}
|
||||
isChecked={isOverridden}
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
Override with a personal value
|
||||
</Switch>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Popover>
|
||||
<Input
|
||||
isReadOnly={!isOverridden || isReadOnly}
|
||||
{...register(`secrets.${index}.valueOverride`)}
|
||||
placeholder="EMPTY"
|
||||
type={canRevealSecOverride ? "text" : "password"}
|
||||
onBlur={setCanRevealSecOverride.off}
|
||||
onFocus={setCanRevealSecOverride.on}
|
||||
rightIcon={
|
||||
<PopoverTrigger disabled={!isOverridden || isReadOnly}>
|
||||
<FontAwesomeIcon icon={faShuffle} />
|
||||
</PopoverTrigger>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
hideCloseBtn
|
||||
className="w-auto border-mineshaft-500 bg-bunker p-0"
|
||||
align="end"
|
||||
>
|
||||
<GenRandomNumber
|
||||
onGenerate={(val) =>
|
||||
setValue(`secrets.${index}.valueOverride`, val, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
<div className="dark mb-4 text-sm text-bunker-300">
|
||||
<div className="mb-2">Version History</div>
|
||||
<div className="flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-bunker-800 p-2 dark:[color-scheme:dark]">
|
||||
{secretVersion?.map(({ createdAt, value, id }, i) => (
|
||||
<div key={id} className="flex flex-col space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={i === 0 ? faCircleDot : faCircle} size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
{new Date(createdAt).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
|
||||
<div className="self-start rounded-sm bg-primary-500/30 px-1">Value:</div>
|
||||
<div className="break-all font-mono">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<FormControl label="Comments & Notes">
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
isDisabled={isReadOnly}
|
||||
{...register(`secrets.${index}.comment`)}
|
||||
rows={5}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export {SecretDetailDrawer} from "./SecretDetailDrawer"
|
||||
@@ -1,451 +0,0 @@
|
||||
import { ChangeEvent, DragEvent, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faClone,
|
||||
faKey,
|
||||
faSearch,
|
||||
faSquareXmark,
|
||||
faUpload
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import * as yup from "yup";
|
||||
|
||||
import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples";
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
|
||||
import { parseDotEnv } from "@app/components/utilities/parseDotEnv";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { useDebounce, usePopUp, useToggle } from "@app/hooks";
|
||||
import { useGetProjectSecrets } from "@app/hooks/api";
|
||||
import { UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
const formSchema = yup.object({
|
||||
environment: yup.string().required().label("Environment").trim(),
|
||||
secretPath: yup
|
||||
.string()
|
||||
.required()
|
||||
.label("Secret Path")
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
),
|
||||
secrets: yup.lazy((val) => {
|
||||
const valSchema: Record<string, yup.StringSchema> = {};
|
||||
Object.keys(val).forEach((key) => {
|
||||
valSchema[key] = yup.string().trim();
|
||||
});
|
||||
return yup.object(valSchema);
|
||||
})
|
||||
});
|
||||
|
||||
type TFormSchema = yup.InferType<typeof formSchema>;
|
||||
|
||||
const parseJson = (src: ArrayBuffer) => {
|
||||
const file = src.toString();
|
||||
const formatedData: Record<string, string> = JSON.parse(file);
|
||||
const env: Record<string, { value: string; comments: string[] }> = {};
|
||||
Object.keys(formatedData).forEach((key) => {
|
||||
if (typeof formatedData[key] === "string") {
|
||||
env[key] = { value: formatedData[key], comments: [] };
|
||||
}
|
||||
});
|
||||
return env;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isSmaller: boolean;
|
||||
onParsedEnv: (env: Record<string, { value: string; comments: string[] }>) => void;
|
||||
onAddNewSecret?: () => void;
|
||||
environments?: { name: string; slug: string }[];
|
||||
workspaceId: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export const SecretDropzone = ({
|
||||
isSmaller,
|
||||
onParsedEnv,
|
||||
onAddNewSecret,
|
||||
environments = [],
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
environment,
|
||||
secretPath
|
||||
}: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragActive, setDragActive] = useToggle();
|
||||
const [isLoading, setIsLoading] = useToggle();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["importSecEnv"] as const);
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
register,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: yupResolver(formSchema),
|
||||
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
|
||||
});
|
||||
|
||||
const envCopySecPath = watch("secretPath");
|
||||
const selectedEnvSlug = watch("environment");
|
||||
const debouncedEnvCopySecretPath = useDebounce(envCopySecPath);
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
env: selectedEnvSlug,
|
||||
secretPath: debouncedEnvCopySecretPath,
|
||||
isPaused:
|
||||
!(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedEnvCopySecretPath)) &&
|
||||
!popUp.importSecEnv.isOpen,
|
||||
decryptFileKey
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setValue("secrets", {});
|
||||
setSearchFilter("");
|
||||
}, [debouncedEnvCopySecretPath]);
|
||||
|
||||
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 parseFile = (file?: File, isJson?: boolean) => {
|
||||
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",
|
||||
timeoutMs: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
// const fileType = file.name.split('.')[1];
|
||||
setIsLoading.on();
|
||||
reader.onload = (event) => {
|
||||
if (!event?.target?.result) return;
|
||||
// parse function's argument looks like to be ArrayBuffer
|
||||
const env = isJson
|
||||
? parseJson(event.target.result as ArrayBuffer)
|
||||
: parseDotEnv(event.target.result as ArrayBuffer);
|
||||
setIsLoading.off();
|
||||
onParsedEnv(env);
|
||||
};
|
||||
|
||||
// If something is wrong show an error
|
||||
try {
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json");
|
||||
};
|
||||
|
||||
const handleFormSubmit = (data: TFormSchema) => {
|
||||
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
|
||||
Object.keys(data.secrets || {}).forEach((key) => {
|
||||
if (data.secrets[key]) {
|
||||
secretsToBePulled[key] = {
|
||||
value: (shouldIncludeValues && data.secrets[key]) || "",
|
||||
comments: [""]
|
||||
};
|
||||
}
|
||||
});
|
||||
onParsedEnv(secretsToBePulled);
|
||||
handlePopUpClose("importSecEnv");
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleSecSelectAll = () => {
|
||||
if (secrets?.secrets) {
|
||||
setValue(
|
||||
"secrets",
|
||||
secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={twMerge(
|
||||
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 py-4 px-2 text-sm text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100",
|
||||
isDragActive && "opacity-100",
|
||||
!isSmaller && "w-full max-w-3xl flex-col space-y-4 py-20",
|
||||
isLoading && "bg-bunker-800"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="mb-16 flex items-center justify-center pt-16">
|
||||
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="justify-cente flex flex-col items-center space-y-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<input
|
||||
id="fileSelect"
|
||||
disabled={!isAllowed}
|
||||
type="file"
|
||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||
accept=".txt,.env,.yml,.yaml,.json"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex w-full flex-row items-center justify-center py-4",
|
||||
isSmaller && "py-1"
|
||||
)}
|
||||
>
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
<p className="mx-4 text-xs text-mineshaft-400">OR</p>
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-8">
|
||||
<Modal
|
||||
isOpen={popUp.importSecEnv.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("importSecEnv", isOpen);
|
||||
reset();
|
||||
setSearchFilter("");
|
||||
}}
|
||||
>
|
||||
<ModalTrigger asChild>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
variant="star"
|
||||
size={isSmaller ? "xs" : "sm"}
|
||||
>
|
||||
Copy Secrets From An Environment
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Copy Secret From An Environment"
|
||||
subTitle="Copy/paste secrets from other environments into this context"
|
||||
>
|
||||
<form>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormControl label="Environment" isRequired className="w-1/3">
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
position="popper"
|
||||
>
|
||||
{environments.map((sourceEnvironment) => (
|
||||
<SelectItem
|
||||
value={sourceEnvironment.slug}
|
||||
key={`source-environment-${sourceEnvironment.slug}`}
|
||||
>
|
||||
{sourceEnvironment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormControl
|
||||
label="Secret Path"
|
||||
className="flex-grow"
|
||||
isRequired
|
||||
icon={<GlobPatternExamples />}
|
||||
>
|
||||
<Input
|
||||
{...register("secretPath")}
|
||||
placeholder="Provide a path, default is /"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className="border-t border-mineshaft-600 pt-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>Secrets</div>
|
||||
<div className="flex w-1/2 items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search for secret"
|
||||
value={searchFilter}
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
onChange={(evt) => setSearchFilter(evt.target.value)}
|
||||
/>
|
||||
<Tooltip content="Select All">
|
||||
<IconButton
|
||||
ariaLabel="Select all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={handleSecSelectAll}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Unselect All">
|
||||
<IconButton
|
||||
ariaLabel="UnSelect all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{!isSecretsLoading && !secrets?.secrets?.length && (
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
)}
|
||||
<div className="thin-scrollbar grid max-h-64 grid-cols-2 gap-4 overflow-auto ">
|
||||
{isSecretsLoading &&
|
||||
Array.apply(0, Array(2)).map((_x, i) => (
|
||||
<Skeleton
|
||||
key={`secret-pull-loading-${i + 1}`}
|
||||
className="bg-mineshaft-700"
|
||||
/>
|
||||
))}
|
||||
|
||||
{secrets?.secrets
|
||||
?.filter(({ key }) =>
|
||||
key.toLowerCase().includes(searchFilter.toLowerCase())
|
||||
)
|
||||
?.map(({ _id, key, value: secVal }) => (
|
||||
<Controller
|
||||
key={`pull-secret--${_id}`}
|
||||
control={control}
|
||||
name={`secrets.${key}`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Checkbox
|
||||
id={`pull-secret-${_id}`}
|
||||
isChecked={Boolean(value)}
|
||||
onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
|
||||
>
|
||||
{key}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 mb-4">
|
||||
<Checkbox
|
||||
id="populate-include-value"
|
||||
isChecked={shouldIncludeValues}
|
||||
onCheckedChange={(isChecked) =>
|
||||
setShouldIncludeValues(isChecked as boolean)
|
||||
}
|
||||
>
|
||||
Include secret values
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faClone} />}
|
||||
type="submit"
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Paste Secrets
|
||||
</Button>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{!isSmaller && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button variant="star" onClick={onAddNewSecret} isDisabled={!isAllowed}>
|
||||
Add a new secret
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
onCreate: (environment: string, secretPath: string) => Promise<void>;
|
||||
environments?: Array<{ slug: string; name: string }>;
|
||||
};
|
||||
|
||||
const formSchema = yup.object({
|
||||
environment: yup.string().required().label("Environment").trim(),
|
||||
secretPath: yup
|
||||
.string()
|
||||
.required()
|
||||
.label("Secret Path")
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
)
|
||||
});
|
||||
|
||||
type TFormData = yup.InferType<typeof formSchema>;
|
||||
|
||||
export const SecretImportForm = ({ onCreate, environments = [] }: Props): JSX.Element => {
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TFormData>({
|
||||
resolver: yupResolver(formSchema)
|
||||
});
|
||||
|
||||
const onSubmit = async ({ environment, secretPath }: TFormData) => {
|
||||
await onCreate(environment, secretPath);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{environments.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
||||
Create
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { SecretImportForm } from "./SecretImportForm";
|
||||
@@ -1,200 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { subject } from "@casl/ability";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import {
|
||||
faFileImport,
|
||||
faFolder,
|
||||
faKey,
|
||||
faUpDown,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { EmptyState, IconButton, SecretInput, TableContainer, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
onDelete: (environment: string, secretPath: string) => void;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
importedEnv: string;
|
||||
importedSecPath: string;
|
||||
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
// to show the environment and folder icon
|
||||
export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => (
|
||||
<div className="inline-flex items-center space-x-2">
|
||||
<div style={{ minWidth: "96px" }}>{env || "-"}</div>
|
||||
{secretPath && (
|
||||
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-green-700 text-md" />
|
||||
<span>{secretPath}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SecretImportItem = ({
|
||||
importedEnv,
|
||||
importedSecPath,
|
||||
onDelete,
|
||||
importedSecrets = [],
|
||||
searchTerm = "",
|
||||
secretPath,
|
||||
environment
|
||||
}: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useToggle();
|
||||
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: `${importedEnv}-${importedSecPath}`
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv);
|
||||
|
||||
useEffect(() => {
|
||||
const filteredSecrets = importedSecrets.filter((secret) =>
|
||||
secret.key.toUpperCase().includes(searchTerm.toUpperCase())
|
||||
);
|
||||
|
||||
if (filteredSecrets.length > 0 && searchTerm) {
|
||||
setIsExpanded.on();
|
||||
} else {
|
||||
setIsExpanded.off();
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
setIsExpanded.off();
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
const style = {
|
||||
transform: transform ? `translateY(${transform.y ? Math.round(transform.y) : 0}px)` : "",
|
||||
transition
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700"
|
||||
onClick={() => setIsExpanded.toggle()}
|
||||
>
|
||||
<td
|
||||
className={`ml-0.5 flex h-10 w-10 items-center justify-center border-none px-4 ${
|
||||
isExpanded && "border-t-2 border-mineshaft-500"
|
||||
}`}
|
||||
>
|
||||
<Tooltip content="Secret Import" className="capitalize">
|
||||
<FontAwesomeIcon icon={faFileImport} className="text-green-700" />
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="relative flex w-full min-w-[220px] items-center justify-between overflow-hidden text-ellipsis lg:min-w-[240px] xl:min-w-[280px]"
|
||||
style={{ paddingTop: "0", paddingBottom: "0" }}
|
||||
>
|
||||
<div className="flex-grow p-2">
|
||||
<EnvFolderIcon env={rowEnv?.name || ""} secretPath={importedSecPath} />
|
||||
</div>
|
||||
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Change Order" className="capitalize">
|
||||
<IconButton
|
||||
size="md"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="expand"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUpDown} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Delete" className="capitalize">
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
onDelete(importedEnv, importedSecPath);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{isExpanded && !isDragging && (
|
||||
<td
|
||||
colSpan={3}
|
||||
className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
|
||||
>
|
||||
<div className="rounded-md bg-bunker-700 p-1">
|
||||
<TableContainer>
|
||||
<table className="secret-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Key</td>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Value</td>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Override</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importedSecrets?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{importedSecrets
|
||||
.filter((secret) =>
|
||||
secret.key.toUpperCase().includes(searchTerm.toUpperCase())
|
||||
)
|
||||
.map(({ key, value, overriden }, index) => (
|
||||
<tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
{key}
|
||||
</td>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
<SecretInput value={value} isDisabled isVisible />
|
||||
</td>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
<EnvFolderIcon
|
||||
env={overriden?.env}
|
||||
secretPath={overriden?.secretPath}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
import { memo } from "react";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
|
||||
import { SecretImportItem } from "./SecretImportItem";
|
||||
|
||||
type TImportedSecrets = Array<{
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
folderId: string;
|
||||
secrets: DecryptedSecret[];
|
||||
}>;
|
||||
|
||||
const SECRET_IN_DASHBOARD = "Present In Dashboard";
|
||||
|
||||
export const computeImportedSecretRows = (
|
||||
importedSecEnv: string,
|
||||
importedSecPath: string,
|
||||
importSecrets: TImportedSecrets = [],
|
||||
secrets: DecryptedSecret[] = [],
|
||||
environments: { name: string; slug: string }[] = []
|
||||
) => {
|
||||
const importedSecIndex = importSecrets.findIndex(
|
||||
({ secretPath, environment }) =>
|
||||
secretPath === importedSecPath && importedSecEnv === environment
|
||||
);
|
||||
if (importedSecIndex === -1) return [];
|
||||
|
||||
const importedSec = importSecrets[importedSecIndex];
|
||||
|
||||
const overridenSec: Record<string, { env: string; secretPath: string }> = {};
|
||||
const envSlug2Name: Record<string, string> = {};
|
||||
environments.forEach((el) => {
|
||||
envSlug2Name[el.slug] = el.name;
|
||||
});
|
||||
|
||||
for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) {
|
||||
importSecrets[i].secrets.forEach((el) => {
|
||||
overridenSec[el.key] = {
|
||||
env: envSlug2Name?.[importSecrets[i].environment] || "unknown",
|
||||
secretPath: importSecrets[i].secretPath
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
secrets.forEach((el) => {
|
||||
overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" };
|
||||
});
|
||||
|
||||
return importedSec.secrets.map(({ key, value }) => ({
|
||||
key,
|
||||
value,
|
||||
overriden: overridenSec?.[key]
|
||||
}));
|
||||
};
|
||||
|
||||
type Props = {
|
||||
secrets?: DecryptedSecret[];
|
||||
importedSecrets?: TImportedSecrets;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onSecretImportDelete: (env: string, secPath: string) => void;
|
||||
items: { id: string; environment: string; secretPath: string }[];
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export const SecretImportSection = memo(
|
||||
({
|
||||
secrets = [],
|
||||
environment,
|
||||
secretPath,
|
||||
importedSecrets = [],
|
||||
onSecretImportDelete,
|
||||
items = [],
|
||||
searchTerm = ""
|
||||
}: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
|
||||
return (
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
{items.map(({ secretPath: impSecPath, environment: importSecEnv, id }) => (
|
||||
<SecretImportItem
|
||||
key={id}
|
||||
importedEnv={importSecEnv}
|
||||
importedSecrets={computeImportedSecretRows(
|
||||
importSecEnv,
|
||||
impSecPath,
|
||||
importedSecrets,
|
||||
secrets,
|
||||
environments
|
||||
)}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
onDelete={onSecretImportDelete}
|
||||
importedSecPath={impSecPath}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SecretImportSection.displayName = "SecretImportSection";
|
||||
@@ -1 +0,0 @@
|
||||
export { SecretImportSection } from "./SecretImportSection";
|
||||
@@ -1,525 +0,0 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Control,
|
||||
Controller,
|
||||
useFieldArray,
|
||||
UseFormRegister,
|
||||
UseFormSetValue,
|
||||
useWatch
|
||||
} from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faCheck,
|
||||
faCodeBranch,
|
||||
faComment,
|
||||
faCopy,
|
||||
faEllipsis,
|
||||
faInfoCircle,
|
||||
faTags,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { cx } from "cva";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// TODO:(akhilmhdh): Refactor this
|
||||
import AddTagPopoverContent from "@app/components/AddTagPopoverContent/AddTagPopoverContent";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
SecretInput,
|
||||
Tag,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { FormData, SecretActionType } from "../../DashboardPage.utils";
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
// backend generated unique id
|
||||
secUniqId?: string;
|
||||
// permission and external state's that decided to hide or show
|
||||
isReadOnly?: boolean;
|
||||
isAddOnly?: boolean;
|
||||
isRollbackMode?: boolean;
|
||||
isSecretValueHidden: boolean;
|
||||
searchTerm: string;
|
||||
// to record the ids of deleted ones
|
||||
onSecretDelete: (index: number, secretName: string, id?: string, overrideId?: string) => void;
|
||||
// sidebar control props
|
||||
onRowExpand: (secId: string | undefined, index: number) => void;
|
||||
// tag props
|
||||
wsTags?: WsTag[];
|
||||
onCreateTagOpen: () => void;
|
||||
// rhf specific functions, dont put this using useFormContext. This is passed as props to avoid re-rendering
|
||||
control: Control<FormData>;
|
||||
register: UseFormRegister<FormData>;
|
||||
setValue: UseFormSetValue<FormData>;
|
||||
isKeyError?: boolean;
|
||||
keyError?: string;
|
||||
autoCapitalization?: boolean;
|
||||
};
|
||||
|
||||
export const SecretInputRow = memo(
|
||||
({
|
||||
index,
|
||||
secretPath,
|
||||
environment,
|
||||
isSecretValueHidden,
|
||||
onRowExpand,
|
||||
isReadOnly,
|
||||
isRollbackMode,
|
||||
isAddOnly,
|
||||
wsTags,
|
||||
onCreateTagOpen,
|
||||
onSecretDelete,
|
||||
searchTerm,
|
||||
control,
|
||||
register,
|
||||
setValue,
|
||||
isKeyError,
|
||||
keyError,
|
||||
secUniqId,
|
||||
autoCapitalization
|
||||
}: Props): JSX.Element => {
|
||||
const isKeySubDisabled = useRef<boolean>(false);
|
||||
// comment management in a row
|
||||
const {
|
||||
fields: secretTags,
|
||||
remove,
|
||||
append
|
||||
} = useFieldArray({ control, name: `secrets.${index}.tags` });
|
||||
|
||||
// display the tags in alphabetical order
|
||||
secretTags.sort((a, b) => a?.name?.localeCompare(b?.name));
|
||||
|
||||
// to get details on a secret
|
||||
const overrideAction = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.overrideAction`,
|
||||
exact: true
|
||||
});
|
||||
const idOverride = useWatch({ control, name: `secrets.${index}.idOverride`, exact: true });
|
||||
const secComment = useWatch({ control, name: `secrets.${index}.comment`, exact: true });
|
||||
const hasComment = Boolean(secComment);
|
||||
const secKey = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.key`,
|
||||
disabled: isKeySubDisabled.current,
|
||||
exact: true
|
||||
});
|
||||
const secValue = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.value`,
|
||||
disabled: isKeySubDisabled.current,
|
||||
exact: true
|
||||
});
|
||||
const secValueOverride = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.valueOverride`,
|
||||
disabled: isKeySubDisabled.current,
|
||||
exact: true
|
||||
});
|
||||
// when secret is override by personal values
|
||||
const isOverridden =
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
const [hoveredTag, setHoveredTag] = useState<WsTag | null>(null);
|
||||
|
||||
const handleTagOnMouseEnter = (wsTag: WsTag) => {
|
||||
setHoveredTag(wsTag);
|
||||
};
|
||||
|
||||
const handleTagOnMouseLeave = () => {
|
||||
setHoveredTag(null);
|
||||
};
|
||||
|
||||
const checkIfTagIsVisible = (wsTag: WsTag) => wsTag._id === hoveredTag?._id;
|
||||
|
||||
const secId = useWatch({ control, name: `secrets.${index}._id`, exact: true });
|
||||
const tags =
|
||||
useWatch({ control, name: `secrets.${index}.tags`, exact: true, defaultValue: [] }) || [];
|
||||
|
||||
const selectedTagIds = tags.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr.slug]: true }),
|
||||
{}
|
||||
);
|
||||
|
||||
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isSecValueCopied) {
|
||||
timer = setTimeout(() => setIsSecValueCopied.off(), 2000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSecValueCopied]);
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
navigator.clipboard.writeText((secValueOverride || secValue) as string);
|
||||
setIsSecValueCopied.on();
|
||||
};
|
||||
|
||||
const onSecretOverride = () => {
|
||||
if (isOverridden) {
|
||||
// when user created a new override but then removes
|
||||
if (overrideAction === SecretActionType.Created)
|
||||
setValue(`secrets.${index}.valueOverride`, "");
|
||||
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, {
|
||||
shouldDirty: true
|
||||
});
|
||||
} else {
|
||||
setValue(`secrets.${index}.valueOverride`, "");
|
||||
setValue(
|
||||
`secrets.${index}.overrideAction`,
|
||||
idOverride ? SecretActionType.Modified : SecretActionType.Created,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectTag = (selectedTag: WsTag) => {
|
||||
const shouldAppend = !selectedTagIds[selectedTag.slug];
|
||||
if (shouldAppend) {
|
||||
const { _id: id, name, slug, tagColor } = selectedTag;
|
||||
append({ _id: id, name, slug, tagColor });
|
||||
} else {
|
||||
const pos = tags.findIndex(({ slug }: { slug: string }) => selectedTag.slug === slug);
|
||||
remove(pos);
|
||||
}
|
||||
};
|
||||
const isCreatedSecret = !secId;
|
||||
const shouldBeBlockedInAddOnly = !isCreatedSecret && isAddOnly;
|
||||
|
||||
// Why this instead of filter in parent
|
||||
// Because rhf field.map has default values so basically
|
||||
// keys are not updated there and index needs to kept so that we can monitor
|
||||
// values individually here
|
||||
if (
|
||||
!(
|
||||
secKey?.toUpperCase().includes(searchTerm?.toUpperCase()) ||
|
||||
tags
|
||||
?.map((tag) => tag.name)
|
||||
.join(" ")
|
||||
?.toUpperCase()
|
||||
.includes(searchTerm?.toUpperCase()) ||
|
||||
secComment?.toUpperCase().includes(searchTerm?.toUpperCase())
|
||||
)
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="group flex flex-row hover:bg-mineshaft-700" key={index}>
|
||||
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
|
||||
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div>
|
||||
</td>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={`secrets.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<HoverCard openDelay={0} open={isKeyError ? undefined : false}>
|
||||
<HoverCardTrigger asChild>
|
||||
<td className={cx(isKeyError ? "rounded ring ring-red/50" : null)}>
|
||||
<div className="relative flex w-full min-w-[220px] items-center justify-end lg:min-w-[240px] xl:min-w-[280px]">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
onFocus={() => {
|
||||
isKeySubDisabled.current = true;
|
||||
}}
|
||||
variant="plain"
|
||||
isDisabled={isReadOnly || shouldBeBlockedInAddOnly || isRollbackMode}
|
||||
className="w-full focus:text-bunker-100 focus:ring-transparent"
|
||||
{...field}
|
||||
onBlur={() => {
|
||||
isKeySubDisabled.current = false;
|
||||
field.onBlur();
|
||||
}}
|
||||
autoCapitalization={autoCapitalization}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-auto py-2 pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-red" />
|
||||
</div>
|
||||
<div className="text-sm">{keyError}</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
/>
|
||||
<td
|
||||
className="flex w-full flex-grow flex-row border-r border-none border-red"
|
||||
style={{ padding: "0.5rem 0 0.5rem 1rem" }}
|
||||
>
|
||||
<div className="w-full flex items-center">
|
||||
{isOverridden ? (
|
||||
<Controller
|
||||
control={control}
|
||||
key={`secrets.${index}.valueOverride`}
|
||||
name={`secrets.${index}.valueOverride`}
|
||||
render={({ field }) => (
|
||||
<SecretInput
|
||||
key={`secrets.${index}.valueOverride`}
|
||||
isDisabled={
|
||||
isReadOnly ||
|
||||
isRollbackMode ||
|
||||
(isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
|
||||
}
|
||||
isVisible={!isSecretValueHidden}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
control={control}
|
||||
key={`secrets.${index}.value`}
|
||||
name={`secrets.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<SecretInput
|
||||
key={`secrets.${index}.value`}
|
||||
isVisible={!isSecretValueHidden}
|
||||
isDisabled={
|
||||
isReadOnly ||
|
||||
isRollbackMode ||
|
||||
(isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="min-w-sm flex">
|
||||
<div className="flex h-8 items-center pl-2">
|
||||
{secretTags.map(({ id, slug, tagColor }) => {
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<Tag
|
||||
// isDisabled={isReadOnly || isAddOnly || isRollbackMode}
|
||||
// onClose={() => remove(i)}
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="rounded-full border-mineshaft-500 bg-transparent flex items-center gap-1.5 justify-around">
|
||||
<div
|
||||
className="w-[10px] h-[10px] rounded-full"
|
||||
style={{ background: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
{slug}
|
||||
</div>
|
||||
</Tag>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<AddTagPopoverContent
|
||||
wsTags={wsTags}
|
||||
secKey={secKey || "this secret"}
|
||||
selectedTagIds={selectedTagIds}
|
||||
handleSelectTag={(wsTag: WsTag) => onSelectTag(wsTag)}
|
||||
handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)}
|
||||
handleTagOnMouseLeave={() => handleTagOnMouseLeave()}
|
||||
checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)}
|
||||
handleOnCreateTagOpen={() => onCreateTagOpen()}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
<div className="w-0 overflow-hidden group-hover:w-6">
|
||||
<Tooltip content="Copy value">
|
||||
<IconButton
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-tag"
|
||||
className="py-[0.42rem]"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!(isReadOnly || isAddOnly || isRollbackMode) && (
|
||||
<div className="duration-0 ml-1 overflow-hidden">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="w-0 group-hover:w-6 data-[state=open]:w-6">
|
||||
<ProjectPermissionCan
|
||||
renderTooltip
|
||||
allowedLabel="Add Tags"
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-tags"
|
||||
className="py-[0.42rem]"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTags} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<AddTagPopoverContent
|
||||
wsTags={wsTags}
|
||||
secKey={secKey || "this secret"}
|
||||
selectedTagIds={selectedTagIds}
|
||||
handleSelectTag={(wsTag: WsTag) => onSelectTag(wsTag)}
|
||||
handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)}
|
||||
handleTagOnMouseLeave={() => handleTagOnMouseLeave()}
|
||||
checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)}
|
||||
handleOnCreateTagOpen={() => onCreateTagOpen()}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-8 flex-row items-center pr-2">
|
||||
{!isAddOnly && (
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
renderTooltip
|
||||
allowedLabel="Override with a personal value"
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={twMerge(
|
||||
"mt-0.5 w-0 overflow-hidden p-0 group-hover:ml-1 group-hover:w-7",
|
||||
isOverridden && "ml-1 w-7 text-primary"
|
||||
)}
|
||||
onClick={onSecretOverride}
|
||||
size="md"
|
||||
isDisabled={isRollbackMode || isReadOnly || !isAllowed}
|
||||
ariaLabel="info"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="text-base" />
|
||||
</div>
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 overflow-hidden ">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
renderTooltip
|
||||
allowedLabel="Comment"
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
className={twMerge(
|
||||
"w-7 overflow-hidden p-0",
|
||||
"w-0 group-hover:w-7 data-[state=open]:w-7",
|
||||
hasComment ? "w-7 text-primary" : "group-hover:w-7"
|
||||
)}
|
||||
isDisabled={!isAllowed}
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-comment"
|
||||
>
|
||||
<FontAwesomeIcon icon={faComment} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
|
||||
sticky="always"
|
||||
>
|
||||
<FormControl label="Comment" className="mb-0">
|
||||
<TextArea
|
||||
isDisabled={isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly}
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
{...register(`secrets.${index}.comment`)}
|
||||
rows={8}
|
||||
cols={30}
|
||||
/>
|
||||
</FormControl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="duration-0 flex w-16 justify-center overflow-hidden border-l border-mineshaft-600 pl-2 transition-all">
|
||||
<div className="flex h-8 items-center space-x-2.5">
|
||||
{!isAddOnly && (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Settings">
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
onClick={() => onRowExpand(secUniqId, index)}
|
||||
ariaLabel="expand"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsis} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="lg"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={isReadOnly || isRollbackMode || !isAllowed}
|
||||
onClick={() => {
|
||||
onSecretDelete(index, secKey, secId, idOverride);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SecretInputRow.displayName = "SecretInputRow";
|
||||
@@ -1 +0,0 @@
|
||||
export { SecretInputRow } from "./SecretInputRow";
|
||||
@@ -1,36 +0,0 @@
|
||||
import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
sortDir: "asc" | "desc";
|
||||
onSort: () => void;
|
||||
};
|
||||
|
||||
export const SecretTableHeader = ({ sortDir, onSort }: Props): JSX.Element => (
|
||||
<thead className="sticky top-0 z-50 bg-mineshaft-800">
|
||||
<tr className="top-0 flex flex-row">
|
||||
<td className="flex w-10 items-center justify-center border-none px-4">
|
||||
<div className="w-10 text-center text-xs text-transparent">{0}</div>
|
||||
</td>
|
||||
<td className="flex items-center">
|
||||
<div className="relative flex w-full min-w-[220px] items-center justify-start pl-2.5 lg:min-w-[240px] xl:min-w-[280px]">
|
||||
<div className="text-md inline-flex items-end font-medium">
|
||||
Key
|
||||
<IconButton variant="plain" className="ml-2" ariaLabel="sort" onClick={onSort}>
|
||||
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex w-max flex-row items-center justify-end">
|
||||
<div className="mt-1 w-5 overflow-hidden group-hover:w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<th className="flex w-full flex-row">
|
||||
<div className="text-sm font-medium">Value</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="h-0 w-full border border-mineshaft-600" />
|
||||
</thead>
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
export { SecretTableHeader } from "./SecretTableHeader";
|
||||
@@ -1 +0,0 @@
|
||||
export { DashboardPage } from "./DashboardPage";
|
||||
358
frontend/src/views/SecretMainPage/SecretMainPage.tsx
Normal file
358
frontend/src/views/SecretMainPage/SecretMainPage.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
import { PermissionDeniedBanner } from "@app/components/permissions";
|
||||
import { ContentLoader } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useGetImportedSecrets,
|
||||
useGetProjectFolders,
|
||||
useGetProjectSecrets,
|
||||
useGetSecretImports,
|
||||
useGetUserWsKey,
|
||||
useGetWorkspaceSnapshotList,
|
||||
useGetWsSnapshotCount,
|
||||
useGetWsTags
|
||||
} from "@app/hooks/api";
|
||||
|
||||
import { ActionBar } from "./components/ActionBar";
|
||||
import { FolderListView } from "./components/FolderListView";
|
||||
import { PitDrawer } from "./components/PitDrawer";
|
||||
import { SecretDropzone } from "./components/SecretDropzone";
|
||||
import { SecretImportListView } from "./components/SecretImportListView";
|
||||
import { SecretListView } from "./components/SecretListView";
|
||||
import { SnapshotView } from "./components/SnapshotView";
|
||||
import { Filter, GroupBy, SortDir } from "./SecretMainPage.types";
|
||||
|
||||
const LOADER_TEXT = [
|
||||
"Retriving and decrypting your E2EE encrypted secrets",
|
||||
"Fetching folders",
|
||||
"Getting secret import links"
|
||||
];
|
||||
|
||||
export const SecretMainPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const router = useRouter();
|
||||
const permission = useProjectPermission();
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(SortDir.ASC);
|
||||
const [filter, setFilter] = useState<Filter>({
|
||||
tags: {},
|
||||
searchFilter: ""
|
||||
});
|
||||
const [selectedSecret, setSelectedSecret] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [snapshotId, setSnapshotId] = useState<string | null>(null);
|
||||
const isRollbackMode = Boolean(snapshotId);
|
||||
const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["snapshots"] as const);
|
||||
|
||||
// env slug
|
||||
const environment = router.query.env as string;
|
||||
const workspaceId = currentWorkspace?._id || "";
|
||||
const secretPath = (router.query.secretPath as string) || "/";
|
||||
const canReadSecret = permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
const canDoReadRollback = permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SecretRollback
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onRouteChangeStart = () => {
|
||||
setSelectedSecret({});
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", onRouteChangeStart);
|
||||
return () => {
|
||||
router.events.off("routeChangeStart", onRouteChangeStart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
|
||||
|
||||
// fetch secrets
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
decryptFileKey: decryptFileKey!,
|
||||
options: {
|
||||
enabled: canReadSecret
|
||||
}
|
||||
});
|
||||
// fetch folders
|
||||
const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory: secretPath
|
||||
});
|
||||
// fetch secret imports
|
||||
const {
|
||||
data: secretImports,
|
||||
isLoading: isSecretImportsLoading,
|
||||
isFetching: isSecretImportsFetching
|
||||
} = useGetSecretImports({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory: secretPath,
|
||||
options: {
|
||||
enabled: canReadSecret
|
||||
}
|
||||
});
|
||||
|
||||
// fetch imported secrets to show user the overriden ones
|
||||
const { data: importedSecrets } = useGetImportedSecrets({
|
||||
workspaceId,
|
||||
environment,
|
||||
decryptFileKey: decryptFileKey!,
|
||||
directory: secretPath,
|
||||
options: {
|
||||
enabled: canReadSecret
|
||||
}
|
||||
});
|
||||
// fetch tags
|
||||
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
|
||||
|
||||
const {
|
||||
data: snapshotList,
|
||||
isFetchingNextPage: isFetchingNextSnapshotList,
|
||||
fetchNextPage: fetchNextSnapshotList,
|
||||
hasNextPage: hasNextSnapshotListPage
|
||||
} = useGetWorkspaceSnapshotList({
|
||||
workspaceId,
|
||||
directory: secretPath,
|
||||
environment,
|
||||
isPaused: !popUp.snapshots.isOpen || !canDoReadRollback,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
const { data: snapshotCount, isLoading: isSnapshotCountLoading } = useGetWsSnapshotCount({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory: secretPath,
|
||||
isPaused: !canDoReadRollback
|
||||
});
|
||||
|
||||
const isNotEmtpy = Boolean(secrets?.length || folders?.length || secretImports?.imports?.length);
|
||||
|
||||
const handleSortToggle = () =>
|
||||
setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC));
|
||||
|
||||
const handleEnvChange = (slug: string) => {
|
||||
const query: Record<string, string> = { ...router.query, env: slug };
|
||||
delete query.secretPath;
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query
|
||||
});
|
||||
};
|
||||
|
||||
const handleGroupByChange = useCallback(
|
||||
(groupBy?: GroupBy) => setFilter((state) => ({ ...state, groupBy })),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTagToggle = useCallback(
|
||||
(tagId: string) =>
|
||||
setFilter((state) => {
|
||||
const isTagPresent = Boolean(state.tags?.[tagId]);
|
||||
const newTagFilter = { ...state.tags };
|
||||
if (isTagPresent) delete newTagFilter[tagId];
|
||||
else newTagFilter[tagId] = true;
|
||||
return { ...state, tags: newTagFilter };
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleToggleVisibility = useCallback(() => setIsVisible((state) => !state), []);
|
||||
|
||||
const handleToggleSecretSelect = useCallback(
|
||||
(id: string) =>
|
||||
setSelectedSecret((state) => {
|
||||
const isChecked = Boolean(state?.[id]);
|
||||
const newChecks = { ...state };
|
||||
if (isChecked) delete newChecks[id];
|
||||
else newChecks[id] = true;
|
||||
return newChecks;
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleResetSecretSelect = useCallback(() => setSelectedSecret({}), []);
|
||||
|
||||
// snapshot functions
|
||||
const handleSelectSnapshot = useCallback((snapId: string) => {
|
||||
setSnapshotId(snapId);
|
||||
}, []);
|
||||
|
||||
const handleResetSnapshot = useCallback(() => {
|
||||
setSnapshotId(null);
|
||||
handlePopUpClose("snapshots");
|
||||
}, []);
|
||||
|
||||
// loading screen when u have permission
|
||||
const loadingOnAccess =
|
||||
canReadSecret && (isSecretsLoading || isSecretImportsLoading || isFoldersLoading);
|
||||
// loading screen when you don't have permission but as folder's is viewable need to wait for that
|
||||
const loadingOnDenied = !canReadSecret && isFoldersLoading;
|
||||
if (loadingOnAccess || loadingOnDenied) {
|
||||
return <ContentLoader text={LOADER_TEXT} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container flex flex-col mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<div className="relative right-6 -top-2 mb-2 ml-6">
|
||||
<NavHeader
|
||||
pageName={t("dashboard.title")}
|
||||
currentEnv={environment}
|
||||
userAvailableEnvs={currentWorkspace?.environments}
|
||||
isFolderMode
|
||||
secretPath={secretPath}
|
||||
isProjectRelated
|
||||
onEnvChange={handleEnvChange}
|
||||
/>
|
||||
</div>
|
||||
{!isRollbackMode ? (
|
||||
<>
|
||||
<ActionBar
|
||||
secrets={secrets}
|
||||
importedSecrets={importedSecrets}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isVisible={isVisible}
|
||||
decryptFileKey={decryptFileKey!}
|
||||
filter={filter}
|
||||
tags={tags}
|
||||
selectedSecrets={selectedSecret}
|
||||
onVisiblilityToggle={handleToggleVisibility}
|
||||
onGroupByChange={handleGroupByChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
onToggleTagFilter={handleTagToggle}
|
||||
onResetSelectedSecret={handleResetSecretSelect}
|
||||
snapshotCount={snapshotCount || 0}
|
||||
isSnapshotCountLoading={isSnapshotCountLoading}
|
||||
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-6 overflow-auto thin-scrollbar bg-mineshaft-800 text-left text-bunker-300 rounded-md text-sm"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col ">
|
||||
{isNotEmtpy && (
|
||||
<div className="flex font-medium border-b border-mineshaft-600">
|
||||
<div className="w-12 flex-shrink-0" />
|
||||
<div
|
||||
className="w-80 flex-shrink-0 border-r flex items-center border-mineshaft-600 px-4 py-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleSortToggle}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") handleSortToggle();
|
||||
}}
|
||||
>
|
||||
Key
|
||||
<FontAwesomeIcon
|
||||
icon={sortDir === SortDir.ASC ? faArrowDown : faArrowUp}
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow px-4 py-2">Value</div>
|
||||
</div>
|
||||
)}
|
||||
{canReadSecret && (
|
||||
<SecretImportListView
|
||||
searchTerm={filter.searchFilter}
|
||||
secretImports={secretImports}
|
||||
isFetching={isSecretImportsLoading || isSecretImportsFetching}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
secrets={secrets}
|
||||
importedSecrets={importedSecrets}
|
||||
/>
|
||||
)}
|
||||
<FolderListView
|
||||
folders={folders}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
sortDir={sortDir}
|
||||
/>
|
||||
{canReadSecret && (
|
||||
<SecretListView
|
||||
secrets={secrets}
|
||||
tags={tags}
|
||||
filter={filter}
|
||||
sortDir={sortDir}
|
||||
isVisible={isVisible}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
decryptFileKey={decryptFileKey!}
|
||||
selectedSecrets={selectedSecret}
|
||||
onToggleSecretSelect={handleToggleSecretSelect}
|
||||
/>
|
||||
)}
|
||||
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
|
||||
</div>
|
||||
</div>
|
||||
<SecretDropzone
|
||||
secrets={secrets}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
decryptFileKey={decryptFileKey!}
|
||||
secretPath={secretPath}
|
||||
isSmaller={isNotEmtpy}
|
||||
environments={currentWorkspace?.environments}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SnapshotView
|
||||
snapshotId={snapshotId || ""}
|
||||
decryptFileKey={decryptFileKey!}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
secrets={secrets}
|
||||
folders={folders}
|
||||
snapshotCount={snapshotCount}
|
||||
onGoBack={handleResetSnapshot}
|
||||
onClickListSnapshot={() => handlePopUpToggle("snapshots", true)}
|
||||
/>
|
||||
)}
|
||||
<PitDrawer
|
||||
secretSnaphots={snapshotList}
|
||||
snapshotId={snapshotId}
|
||||
isDrawerOpen={popUp.snapshots.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("snapshots", isOpen)}
|
||||
hasNextPage={hasNextSnapshotListPage}
|
||||
fetchNextPage={fetchNextSnapshotList}
|
||||
onSelectSnapshot={handleSelectSnapshot}
|
||||
isFetchingNextPage={isFetchingNextSnapshotList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
frontend/src/views/SecretMainPage/SecretMainPage.types.ts
Normal file
14
frontend/src/views/SecretMainPage/SecretMainPage.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type Filter = {
|
||||
tags: Record<string, boolean>;
|
||||
searchFilter: string;
|
||||
groupBy?: GroupBy | null;
|
||||
};
|
||||
|
||||
export enum SortDir {
|
||||
ASC = "asc",
|
||||
DESC = "desc"
|
||||
}
|
||||
|
||||
export enum GroupBy {
|
||||
PREFIX = "prefix"
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faAnglesDown,
|
||||
faCheckCircle,
|
||||
faChevronRight,
|
||||
faClose,
|
||||
faCodeCommit,
|
||||
faDownload,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileImport,
|
||||
faFilter,
|
||||
faFolderPlus,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import FileSaver from "file-saver";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Tooltip,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useCreateFolder, useDeleteSecretBatch } from "@app/hooks/api";
|
||||
import { DecryptedSecret, TImportedSecrets, UserWsKeyPair, WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { Filter, GroupBy } from "../../SecretMainPage.types";
|
||||
import { CreateSecretForm } from "./CreateSecretForm";
|
||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||
import { FolderForm } from "./FolderForm";
|
||||
|
||||
type Props = {
|
||||
secrets?: DecryptedSecret[];
|
||||
// swtich the secrets type as it gets decrypted after api call
|
||||
importedSecrets?: Array<Omit<TImportedSecrets, "secrets"> & { secrets: DecryptedSecret[] }>;
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
filter: Filter;
|
||||
tags?: WsTag[];
|
||||
isVisible?: boolean;
|
||||
selectedSecrets: Record<string, boolean>;
|
||||
snapshotCount: number;
|
||||
isSnapshotCountLoading?: boolean;
|
||||
onGroupByChange: (opt?: GroupBy) => void;
|
||||
onSearchChange: (term: string) => void;
|
||||
onToggleTagFilter: (tagId: string) => void;
|
||||
onVisiblilityToggle: () => void;
|
||||
onResetSelectedSecret: () => void;
|
||||
onClickRollbackMode: () => void;
|
||||
};
|
||||
|
||||
export const ActionBar = ({
|
||||
secrets = [],
|
||||
importedSecrets = [],
|
||||
environment,
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
secretPath = "/",
|
||||
filter,
|
||||
tags = [],
|
||||
isVisible,
|
||||
selectedSecrets,
|
||||
snapshotCount,
|
||||
isSnapshotCountLoading,
|
||||
onSearchChange,
|
||||
onToggleTagFilter,
|
||||
onGroupByChange,
|
||||
onVisiblilityToggle,
|
||||
onResetSelectedSecret,
|
||||
onClickRollbackMode
|
||||
}: Props) => {
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
"addSecret",
|
||||
"addFolder",
|
||||
"addSecretImport",
|
||||
"bulkDeleteSecrets",
|
||||
"misc",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
const { subscription } = useSubscription();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||
|
||||
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
|
||||
|
||||
const handleFolderCreate = async (folderName: string) => {
|
||||
try {
|
||||
await createFolder({
|
||||
folderName,
|
||||
directory: secretPath,
|
||||
environment,
|
||||
workspaceId
|
||||
});
|
||||
handlePopUpClose("addFolder");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created folder"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create folder"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecretDownload = async () => {
|
||||
const secPriority: Record<string, boolean> = {};
|
||||
const downloadedSecrets: Array<{ key: string; value: string; comment?: string }> = [];
|
||||
// load up secrets in dashboard
|
||||
secrets?.forEach(({ key, value, comment }) => {
|
||||
secPriority[key] = true;
|
||||
downloadedSecrets.push({ key, value, comment });
|
||||
});
|
||||
// now load imported secrets with secPriority
|
||||
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
|
||||
importedSecrets[i].secrets.forEach(({ key, value, comment }) => {
|
||||
if (secPriority?.[key]) return;
|
||||
downloadedSecrets.unshift({ key, value, comment });
|
||||
secPriority[key] = true;
|
||||
});
|
||||
}
|
||||
|
||||
const file = downloadedSecrets
|
||||
.sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase()))
|
||||
.reduce(
|
||||
(prev, { key, value, comment }, index) =>
|
||||
prev +
|
||||
(comment
|
||||
? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n`
|
||||
: `${key}=${value}\n`),
|
||||
""
|
||||
);
|
||||
const blob = new Blob([file], { type: "text/plain;charset=utf-8" });
|
||||
FileSaver.saveAs(blob, `${environment}.env`);
|
||||
};
|
||||
|
||||
const handleSecretBulkDelete = async () => {
|
||||
const bulkDeletedSecrets = secrets.filter(({ _id }) => Boolean(selectedSecrets?.[_id]));
|
||||
try {
|
||||
await deleteBatchSecretV3({
|
||||
secretPath,
|
||||
workspaceId,
|
||||
environment,
|
||||
secrets: bulkDeletedSecrets.map(({ key }) => ({ secretName: key, type: "shared" }))
|
||||
});
|
||||
handlePopUpClose("bulkDeleteSecrets");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted secrets"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<div className="w-2/5">
|
||||
<Input
|
||||
className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
placeholder="Search by folder name, key name, comment..."
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
value={filter.searchFilter}
|
||||
onChange={(evt) => onSearchChange(evt.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
ariaLabel="Download"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
Object.keys(filter.tags).length && "text-primary border-primary/50"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0">
|
||||
<DropdownMenuGroup>Group By</DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
iconPos="right"
|
||||
icon={
|
||||
filter?.groupBy === GroupBy.PREFIX && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
onClick={() => onGroupByChange(!filter.groupBy ? GroupBy.PREFIX : undefined)}
|
||||
>
|
||||
Prefix
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>Filter By</DropdownMenuGroup>
|
||||
<DropdownSubMenu>
|
||||
<DropdownSubMenuTrigger
|
||||
iconPos="right"
|
||||
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||
>
|
||||
Tags
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="rounded-l-none">
|
||||
<DropdownMenuLabel>Apply tags to filter secrets</DropdownMenuLabel>
|
||||
{tags.map(({ _id, name, tagColor }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleTagFilter(_id)}
|
||||
key={_id}
|
||||
icon={filter?.tags[_id] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full mr-2"
|
||||
style={{ background: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
{name}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
<div>
|
||||
<IconButton variant="outline_bg" ariaLabel="Download" onClick={handleSecretDownload}>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton variant="outline_bg" ariaLabel="Reveal" onClick={onVisiblilityToggle}>
|
||||
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Read}
|
||||
a={ProjectPermissionSub.SecretRollback}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
if (subscription && subscription.pitRecovery) {
|
||||
onClickRollbackMode();
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
isLoading={isSnapshotCountLoading}
|
||||
className="h-10"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
{snapshotCount} Commits
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addSecret")}
|
||||
className="rounded-r-none h-10"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Secret
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
<DropdownMenu
|
||||
open={popUp.misc.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="add-folder-or-import"
|
||||
variant="outline_bg"
|
||||
className="rounded-l-none bg-mineshaft-600 p-3"
|
||||
>
|
||||
<FontAwesomeIcon icon={faAnglesDown} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="p-2 flex flex-col space-y-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addFolder");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
isFullWidth
|
||||
>
|
||||
Add Folder
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFileImport} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addSecretImport");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
isFullWidth
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Import
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"overflow-hidden transition-all h-0 flex-shrink-0",
|
||||
isMultiSelectActive && "h-16"
|
||||
)}
|
||||
>
|
||||
<div className="text-bunker-300 flex items-center bg-mineshaft-800 mt-4 p-2 pl-4 rounded-md">
|
||||
<Tooltip content="Clear">
|
||||
<IconButton variant="plain" ariaLabel="clear-selection" onClick={onResetSelectedSecret}>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="text-sm ml-4 px-2 border-x border-mineshaft-600">
|
||||
{Object.keys(selectedSecrets).length} Secrets
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
variant="plain"
|
||||
ariaLabel="delete"
|
||||
className="ml-4 text-red-700"
|
||||
onClick={() => handlePopUpOpen("bulkDeleteSecrets")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
{/* all the side triggers from actions like modals etc */}
|
||||
<CreateSecretForm
|
||||
secrets={secrets}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
decryptFileKey={decryptFileKey!}
|
||||
isOpen={popUp.addSecret.isOpen}
|
||||
onClose={() => handlePopUpClose("addSecret")}
|
||||
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecret", isOpen)}
|
||||
/>
|
||||
<CreateSecretImportForm
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isOpen={popUp.addSecretImport.isOpen}
|
||||
onClose={() => handlePopUpClose("addSecretImport")}
|
||||
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.addFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
||||
>
|
||||
<ModalContent title="Create Folder">
|
||||
<FolderForm onCreateFolder={handleFolderCreate} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.bulkDeleteSecrets.isOpen}
|
||||
deleteKey="delete"
|
||||
title="Do you want to delete these secrets?"
|
||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
|
||||
onDeleteApproved={handleSecretBulkDelete}
|
||||
/>
|
||||
{subscription && (
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={
|
||||
subscription.slug === null
|
||||
? "You can perform point-in-time recovery under an Enterprise license"
|
||||
: "You can perform point-in-time recovery if you switch to Infisical's Team plan"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ActionBar.displayName = "ActionBar";
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { Button, FormControl, Input, Modal, ModalContent, SecretInput } from "@app/components/v2";
|
||||
import { useCreateSecretV3 } from "@app/hooks/api";
|
||||
import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
const typeSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string()
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof typeSchema>;
|
||||
|
||||
type Props = {
|
||||
secrets?: DecryptedSecret[];
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
secretPath?: string;
|
||||
// modal props
|
||||
isOpen?: boolean;
|
||||
onClose: () => void;
|
||||
onTogglePopUp: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const CreateSecretForm = ({
|
||||
environment,
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
secretPath = "/",
|
||||
isOpen,
|
||||
onClose,
|
||||
onTogglePopUp
|
||||
}: Props) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors, isSubmitting }
|
||||
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||
|
||||
const handleFormSubmit = async ({ key, value }: TFormSchema) => {
|
||||
try {
|
||||
await createSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretName: key,
|
||||
secretValue: value,
|
||||
secretComment: "",
|
||||
type: "shared",
|
||||
latestFileKey: decryptFileKey
|
||||
});
|
||||
onClose();
|
||||
reset();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created secret"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
|
||||
<ModalContent
|
||||
title="Create secret"
|
||||
subTitle="Add a secret to the particular environment and folder"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<FormControl label="Key" isError={Boolean(errors?.key)} errorText={errors?.key?.message}>
|
||||
<Input {...register("key")} placeholder="Type your secret name" />
|
||||
</FormControl>
|
||||
<Controller
|
||||
control={control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
label="Value"
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
<SecretInput
|
||||
{...field}
|
||||
className="bg-mineshaft-900 text-gray-400 hover:border-primary-400/50 focus:border px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-7 flex items-center">
|
||||
<Button
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
key="layout-create-project-submit"
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
>
|
||||
Create Secret
|
||||
</Button>
|
||||
<Button
|
||||
key="layout-cancel-create-project"
|
||||
onClick={onClose}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useCreateSecretImport } from "@app/hooks/api";
|
||||
|
||||
const typeSchema = z.object({
|
||||
environment: z.string().trim(),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
)
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof typeSchema>;
|
||||
|
||||
type Props = {
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
// modal props
|
||||
isOpen?: boolean;
|
||||
onClose: () => void;
|
||||
onTogglePopUp: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const CreateSecretImportForm = ({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath = "/",
|
||||
isOpen,
|
||||
onClose,
|
||||
onTogglePopUp
|
||||
}: Props) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync: createSecretImport } = useCreateSecretImport();
|
||||
|
||||
const handleFormSubmit = async ({
|
||||
environment: importedEnv,
|
||||
secretPath: importedSecPath
|
||||
}: TFormSchema) => {
|
||||
try {
|
||||
await createSecretImport({
|
||||
environment,
|
||||
workspaceId,
|
||||
directory: secretPath,
|
||||
secretImport: {
|
||||
environment: importedEnv,
|
||||
secretPath: importedSecPath
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
reset();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully linked"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to link secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
|
||||
<ModalContent
|
||||
title="Add Secret Link"
|
||||
subTitle="To inherit secrets from another environment or folder"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{environments.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-7 flex items-center">
|
||||
<Button
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
key="layout-create-project-submit"
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
>
|
||||
Create Link
|
||||
</Button>
|
||||
<Button
|
||||
key="layout-cancel-create-project"
|
||||
onClick={onClose}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +1,28 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
onCreateFolder: (folderName: string) => Promise<void>;
|
||||
onUpdateFolder: (folderName: string) => Promise<void>;
|
||||
onCreateFolder?: (folderName: string) => Promise<void>;
|
||||
onUpdateFolder?: (folderName: string) => Promise<void>;
|
||||
isEdit?: boolean;
|
||||
defaultFolderName?: string;
|
||||
};
|
||||
|
||||
const formSchema = yup.object({
|
||||
name: yup
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.required()
|
||||
.trim()
|
||||
.matches(/^[a-zA-Z0-9-_]+$/, "Folder name cannot contain spaces. Only underscore and dashes")
|
||||
.label("Tag Name")
|
||||
.regex(/^[a-zA-Z0-9-_]+$/, "Folder name cannot contain spaces. Only underscore and dashes")
|
||||
});
|
||||
type TFormData = yup.InferType<typeof formSchema>;
|
||||
type TFormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const FolderForm = ({
|
||||
isEdit,
|
||||
onCreateFolder,
|
||||
defaultFolderName,
|
||||
onCreateFolder,
|
||||
onUpdateFolder
|
||||
}: Props): JSX.Element => {
|
||||
const {
|
||||
@@ -33,7 +31,7 @@ export const FolderForm = ({
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TFormData>({
|
||||
resolver: yupResolver(formSchema),
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: defaultFolderName
|
||||
}
|
||||
@@ -41,9 +39,9 @@ export const FolderForm = ({
|
||||
|
||||
const onSubmit = async ({ name }: TFormData) => {
|
||||
if (isEdit) {
|
||||
await onUpdateFolder(name);
|
||||
await onUpdateFolder?.(name);
|
||||
} else {
|
||||
await onCreateFolder(name);
|
||||
await onCreateFolder?.(name);
|
||||
}
|
||||
reset();
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ActionBar } from "./ActionBar";
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faClose, faFolder, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
||||
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
|
||||
import { SortDir } from "../../SecretMainPage.types";
|
||||
import { FolderForm } from "../ActionBar/FolderForm";
|
||||
|
||||
type Props = {
|
||||
folders?: TSecretFolder[];
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
sortDir: SortDir;
|
||||
};
|
||||
|
||||
export const FolderListView = ({
|
||||
folders = [],
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath = "/",
|
||||
sortDir = SortDir.ASC
|
||||
}: Props) => {
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"updateFolder",
|
||||
"deleteFolder"
|
||||
] as const);
|
||||
const router = useRouter();
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync: updateFolder } = useUpdateFolder();
|
||||
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
||||
|
||||
const handleFolderUpdate = async (newFolderName: string) => {
|
||||
try {
|
||||
await updateFolder({
|
||||
folderName: popUp.updateFolder.data as string,
|
||||
name: newFolderName,
|
||||
directory: secretPath,
|
||||
environment,
|
||||
workspaceId
|
||||
});
|
||||
handlePopUpClose("updateFolder");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully saved folder"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to save folder"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderDelete = async () => {
|
||||
try {
|
||||
await deleteFolder({
|
||||
folderName: popUp.deleteFolder.data as string,
|
||||
directory: secretPath,
|
||||
environment,
|
||||
workspaceId
|
||||
});
|
||||
handlePopUpClose("deleteFolder");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted folder"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete folder"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderClick = (name: string) => {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
secretPath: `${router.query?.secretPath || ""}/${name}`
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{folders
|
||||
.sort((a, b) =>
|
||||
sortDir === SortDir.ASC
|
||||
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||||
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
|
||||
)
|
||||
.map(({ name, id }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
|
||||
>
|
||||
<div className="w-12 px-4 py-2 text-yellow-700 flex items-center">
|
||||
<FontAwesomeIcon icon={faFolder} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-grow px-4 py-2 flex items-center"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") handleFolderClick(name);
|
||||
}}
|
||||
onClick={() => handleFolderClick(name)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div className="px-3 py-2 flex items-center space-x-4 border-l border-mineshaft-600">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Edit"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="edit-folder"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
onClick={() => handlePopUpOpen("updateFolder", name)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="delete-folder"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
onClick={() => handlePopUpOpen("deleteFolder", name)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Modal
|
||||
isOpen={popUp.updateFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("updateFolder", isOpen)}
|
||||
>
|
||||
<ModalContent title="Create Folder">
|
||||
<FolderForm
|
||||
isEdit
|
||||
defaultFolderName={popUp.updateFolder.data as string}
|
||||
onUpdateFolder={handleFolderUpdate}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteFolder.isOpen}
|
||||
deleteKey={popUp.deleteFolder?.data as string}
|
||||
title="Do you want to delete this folder?"
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
|
||||
onDeleteApproved={handleFolderDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { FolderListView } from "./FolderListView";
|
||||
@@ -3,12 +3,12 @@ import { InfiniteData } from "@tanstack/react-query";
|
||||
|
||||
import { Button, Drawer, DrawerContent } from "@app/components/v2";
|
||||
import timeSince from "@app/ee/utilities/timeSince";
|
||||
import { TWorkspaceSecretSnapshot } from "@app/hooks/api/secretSnapshots/types";
|
||||
import { TSecretSnapshot } from "@app/hooks/api/secretSnapshots/types";
|
||||
|
||||
type Props = {
|
||||
isDrawerOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
secretSnaphots?: InfiniteData<TWorkspaceSecretSnapshot[]>;
|
||||
secretSnaphots?: InfiniteData<TSecretSnapshot[]>;
|
||||
onSelectSnapshot: (id: string) => void;
|
||||
snapshotId: string | null;
|
||||
isFetchingNextPage?: boolean;
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faClone,
|
||||
faKey,
|
||||
faSearch,
|
||||
faSquareCheck,
|
||||
faSquareXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useGetProjectSecrets } from "@app/hooks/api";
|
||||
import { UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
environment: z.string().trim(),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
),
|
||||
secrets: z.record(z.string())
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
onToggle: (isOpen: boolean) => void;
|
||||
onParsedEnv: (env: Record<string, { value: string; comments: string[] }>) => void;
|
||||
environments?: { name: string; slug: string }[];
|
||||
workspaceId: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export const CopySecretsFromBoard = ({
|
||||
environments = [],
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
environment,
|
||||
secretPath,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onParsedEnv
|
||||
}: Props) => {
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
register,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
|
||||
});
|
||||
|
||||
const envCopySecPath = watch("secretPath");
|
||||
const selectedEnvSlug = watch("environment");
|
||||
const debouncedEnvCopySecretPath = useDebounce(envCopySecPath);
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
environment: selectedEnvSlug,
|
||||
secretPath: debouncedEnvCopySecretPath,
|
||||
decryptFileKey,
|
||||
options: {
|
||||
enabled:
|
||||
Boolean(workspaceId) &&
|
||||
Boolean(selectedEnvSlug) &&
|
||||
Boolean(debouncedEnvCopySecretPath) &&
|
||||
isOpen
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setValue("secrets", {});
|
||||
setSearchFilter("");
|
||||
}, [debouncedEnvCopySecretPath]);
|
||||
|
||||
const handleSecSelectAll = () => {
|
||||
if (secrets) {
|
||||
setValue(
|
||||
"secrets",
|
||||
secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
|
||||
Object.keys(data.secrets || {}).forEach((key) => {
|
||||
if (data.secrets[key]) {
|
||||
secretsToBePulled[key] = {
|
||||
value: (shouldIncludeValues && data.secrets[key]) || "",
|
||||
comments: [""]
|
||||
};
|
||||
}
|
||||
});
|
||||
onParsedEnv(secretsToBePulled);
|
||||
onToggle(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(state) => {
|
||||
onToggle(state);
|
||||
reset();
|
||||
setSearchFilter("");
|
||||
}}
|
||||
>
|
||||
<ModalTrigger asChild>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => onToggle(true)}
|
||||
isDisabled={!isAllowed}
|
||||
variant="star"
|
||||
size="xs"
|
||||
>
|
||||
Copy Secrets From An Environment
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Copy Secret From An Environment"
|
||||
subTitle="Copy/paste secrets from other environments into this context"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormControl label="Environment" isRequired className="w-1/3">
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
position="popper"
|
||||
>
|
||||
{environments.map((sourceEnvironment) => (
|
||||
<SelectItem
|
||||
value={sourceEnvironment.slug}
|
||||
key={`source-environment-${sourceEnvironment.slug}`}
|
||||
>
|
||||
{sourceEnvironment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormControl label="Secret Path" className="flex-grow" isRequired>
|
||||
<Input {...register("secretPath")} placeholder="Provide a path, default is /" />
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className="border-t border-mineshaft-600 pt-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>Secrets</div>
|
||||
<div className="w-1/2 flex items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search for secret"
|
||||
value={searchFilter}
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
onChange={(evt) => setSearchFilter(evt.target.value)}
|
||||
/>
|
||||
<Tooltip content="Select All">
|
||||
<IconButton
|
||||
ariaLabel="Select all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={handleSecSelectAll}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Unselect All">
|
||||
<IconButton
|
||||
ariaLabel="UnSelect all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{!isSecretsLoading && !secrets?.length && (
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4 max-h-64 overflow-auto thin-scrollbar ">
|
||||
{isSecretsLoading &&
|
||||
Array.apply(0, Array(2)).map((_x, i) => (
|
||||
<Skeleton key={`secret-pull-loading-${i + 1}`} className="bg-mineshaft-700" />
|
||||
))}
|
||||
|
||||
{secrets
|
||||
?.filter(({ key }) => key.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
?.map(({ _id, key, value: secVal }) => (
|
||||
<Controller
|
||||
key={`pull-secret--${_id}`}
|
||||
control={control}
|
||||
name={`secrets.${key}`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Checkbox
|
||||
id={`pull-secret-${_id}`}
|
||||
isChecked={Boolean(value)}
|
||||
onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
|
||||
>
|
||||
{key}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 mb-4">
|
||||
<Checkbox
|
||||
id="populate-include-value"
|
||||
isChecked={shouldIncludeValues}
|
||||
onCheckedChange={(isChecked) => setShouldIncludeValues(isChecked as boolean)}
|
||||
>
|
||||
Include secret values
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faClone} />}
|
||||
type="submit"
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Paste Secrets
|
||||
</Button>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,338 @@
|
||||
import { ChangeEvent, DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subject } from "@casl/ability";
|
||||
import { 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 { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
|
||||
import { parseDotEnv } from "@app/components/utilities/parseDotEnv";
|
||||
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
import { CopySecretsFromBoard } from "./CopySecretsFromBoard";
|
||||
|
||||
const parseJson = (src: ArrayBuffer) => {
|
||||
const file = src.toString();
|
||||
const formatedData: Record<string, string> = JSON.parse(file);
|
||||
const env: Record<string, { value: string; comments: string[] }> = {};
|
||||
Object.keys(formatedData).forEach((key) => {
|
||||
if (typeof formatedData[key] === "string") {
|
||||
env[key] = { value: formatedData[key], comments: [] };
|
||||
}
|
||||
});
|
||||
return env;
|
||||
};
|
||||
|
||||
type TParsedEnv = Record<string, { value: string; comments: string[] }>;
|
||||
type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv };
|
||||
|
||||
type Props = {
|
||||
isSmaller: boolean;
|
||||
environments?: { name: string; slug: string }[];
|
||||
workspaceId: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secrets?: DecryptedSecret[];
|
||||
};
|
||||
|
||||
export const SecretDropzone = ({
|
||||
isSmaller,
|
||||
environments = [],
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
environment,
|
||||
secretPath,
|
||||
secrets = []
|
||||
}: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragActive, setDragActive] = useToggle();
|
||||
const [isLoading, setIsLoading] = useToggle();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"importSecEnv",
|
||||
"overlapKeyWarning"
|
||||
] as const);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync: updateSecretBatch, isLoading: isUpdatingSecrets } = useUpdateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const { mutateAsync: createSecretBatch, isLoading: isCreatingSecrets } = useCreateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
|
||||
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 = (env: TParsedEnv) => {
|
||||
const secretsGroupedByKey = secrets?.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr.key]: true }),
|
||||
{}
|
||||
);
|
||||
const overlappedSecrets = Object.keys(env)
|
||||
.filter((secKey) => secretsGroupedByKey?.[secKey])
|
||||
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
|
||||
|
||||
const nonOverlappedSecrets = Object.keys(env)
|
||||
.filter((secKey) => !secretsGroupedByKey?.[secKey])
|
||||
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
|
||||
|
||||
if (!Object.keys(overlappedSecrets).length && !Object.keys(nonOverlappedSecrets).length) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to find secrets"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("overlapKeyWarning", {
|
||||
update: overlappedSecrets,
|
||||
create: nonOverlappedSecrets
|
||||
});
|
||||
};
|
||||
|
||||
const parseFile = (file?: File, isJson?: boolean) => {
|
||||
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",
|
||||
timeoutMs: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
// const fileType = file.name.split('.')[1];
|
||||
setIsLoading.on();
|
||||
reader.onload = (event) => {
|
||||
if (!event?.target?.result) return;
|
||||
// parse function's argument looks like to be ArrayBuffer
|
||||
const env = isJson
|
||||
? parseJson(event.target.result as ArrayBuffer)
|
||||
: parseDotEnv(event.target.result as ArrayBuffer);
|
||||
setIsLoading.off();
|
||||
handleParsedEnv(env);
|
||||
};
|
||||
|
||||
// If something is wrong show an error
|
||||
try {
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json");
|
||||
};
|
||||
|
||||
const handleSaveSecrets = async () => {
|
||||
const { update, create } = popUp?.overlapKeyWarning?.data as TSecOverwriteOpt;
|
||||
try {
|
||||
if (Object.keys(create || {}).length) {
|
||||
await createSecretBatch({
|
||||
latestFileKey: decryptFileKey!,
|
||||
secretPath,
|
||||
workspaceId,
|
||||
environment,
|
||||
secrets: Object.entries(create).map(([secretName, secData]) => ({
|
||||
type: "shared",
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretName
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (Object.keys(update || {}).length) {
|
||||
await updateSecretBatch({
|
||||
latestFileKey: decryptFileKey!,
|
||||
secretPath,
|
||||
workspaceId,
|
||||
environment,
|
||||
secrets: Object.entries(update).map(([secretName, secData]) => ({
|
||||
type: "shared",
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretName
|
||||
}))
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
handlePopUpClose("overlapKeyWarning");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully uploaded secrets"
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to upload secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isUploadedDuplicateSecretsEmpty = !Object.keys(
|
||||
(popUp.overlapKeyWarning?.data as TSecOverwriteOpt)?.update || {}
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={twMerge(
|
||||
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 py-4 text-sm px-2 text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100",
|
||||
isDragActive && "opacity-100",
|
||||
!isSmaller && "w-full max-w-3xl flex-col space-y-4 py-20 mx-auto",
|
||||
isLoading && "bg-bunker-800"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="mb-16 flex items-center justify-center pt-16">
|
||||
<img
|
||||
src="/images/loading/loading.gif"
|
||||
height={70}
|
||||
width={120}
|
||||
alt="loading animation"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-cente flex-col space-y-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<input
|
||||
id="fileSelect"
|
||||
disabled={!isAllowed}
|
||||
type="file"
|
||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||
accept=".txt,.env,.yml,.yaml,.json"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex w-full flex-row items-center justify-center py-4",
|
||||
isSmaller && "py-1"
|
||||
)}
|
||||
>
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
<p className="mx-4 text-xs text-mineshaft-400">OR</p>
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-8">
|
||||
<CopySecretsFromBoard
|
||||
isOpen={popUp.importSecEnv.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
|
||||
onParsedEnv={handleParsedEnv}
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
workspaceId={workspaceId}
|
||||
decryptFileKey={decryptFileKey}
|
||||
secretPath={secretPath}
|
||||
/>
|
||||
{!isSmaller && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button variant="star" isDisabled={!isAllowed}>
|
||||
Add a new secret
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={popUp?.overlapKeyWarning?.isOpen}
|
||||
onOpenChange={(open) => handlePopUpToggle("overlapKeyWarning", open)}
|
||||
>
|
||||
<ModalContent
|
||||
title={isUploadedDuplicateSecretsEmpty ? "Confirmation" : "Duplicate Secrets!!"}
|
||||
footerContent={[
|
||||
<Button
|
||||
key="keep-old-btn"
|
||||
className="mr-4"
|
||||
onClick={() => handlePopUpClose("overlapKeyWarning")}
|
||||
variant="outline_bg"
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Keep old
|
||||
</Button>,
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema={isUploadedDuplicateSecretsEmpty ? "primary" : "danger"}
|
||||
key="overwrite-btn"
|
||||
onClick={handleSaveSecrets}
|
||||
>
|
||||
{isUploadedDuplicateSecretsEmpty ? "Create" : "Overwrite"}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
{isUploadedDuplicateSecretsEmpty ? (
|
||||
<div>Uploaded secrets will be created.</div>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-2 text-gray-300">
|
||||
<div>Your file contains following duplicate secrets</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{Object.keys((popUp?.overlapKeyWarning?.data as TSecOverwriteOpt)?.update || {})
|
||||
?.map((key) => key)
|
||||
.join(", ")}
|
||||
</div>
|
||||
<div>Are you sure you want to overwrite these secrets and create other ones?</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect } from "react";
|
||||
import { subject } from "@casl/ability";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import {
|
||||
faClose,
|
||||
faFileImport,
|
||||
faFolder,
|
||||
faKey,
|
||||
faUpDown
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { EmptyState, IconButton, SecretInput, TableContainer } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
onDelete: (environment: string, secretPath: string) => void;
|
||||
environment: string;
|
||||
secretPath?: string;
|
||||
importedEnv: string;
|
||||
importedSecPath: string;
|
||||
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
// to show the environment and folder icon
|
||||
export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => (
|
||||
<div className="inline-flex items-center space-x-2">
|
||||
<div style={{ minWidth: "96px" }}>{env || "-"}</div>
|
||||
{secretPath && (
|
||||
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-green-700 text-md" />
|
||||
<span>{secretPath}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SecretImportItem = ({
|
||||
importedEnv,
|
||||
importedSecPath,
|
||||
onDelete,
|
||||
importedSecrets = [],
|
||||
searchTerm = "",
|
||||
secretPath,
|
||||
environment
|
||||
}: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useToggle();
|
||||
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: `${importedEnv}-${importedSecPath}`
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv);
|
||||
|
||||
useEffect(() => {
|
||||
const filteredSecrets = importedSecrets.filter((secret) =>
|
||||
secret.key.toUpperCase().includes(searchTerm.toUpperCase())
|
||||
);
|
||||
|
||||
if (filteredSecrets.length > 0 && searchTerm) {
|
||||
setIsExpanded.on();
|
||||
} else {
|
||||
setIsExpanded.off();
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
setIsExpanded.off();
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
const style = {
|
||||
transform: transform ? `translateY(${transform.y ? Math.round(transform.y) : 0}px)` : "",
|
||||
transition
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
|
||||
role="button"
|
||||
ref={setNodeRef}
|
||||
tabIndex={0}
|
||||
style={style}
|
||||
onClick={() => setIsExpanded.toggle()}
|
||||
onKeyDown={() => setIsExpanded.toggle()}
|
||||
>
|
||||
<div className="w-12 px-4 py-2 flex items-center text-green-700">
|
||||
<FontAwesomeIcon icon={faFileImport} />
|
||||
</div>
|
||||
<div className="flex-grow px-4 py-2 flex items-center">
|
||||
<EnvFolderIcon env={rowEnv?.name || ""} secretPath={importedSecPath} />
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center space-x-4 border-l border-mineshaft-600">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Change order"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="md"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="expand"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUpDown} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
onDelete(importedEnv, importedSecPath);
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && !isDragging && (
|
||||
<td
|
||||
colSpan={3}
|
||||
className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
|
||||
>
|
||||
<div className="rounded-md bg-bunker-700 p-1">
|
||||
<TableContainer>
|
||||
<table className="secret-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Key</td>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Value</td>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Override</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importedSecrets?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{importedSecrets
|
||||
.filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase()))
|
||||
.map(({ key, value, overriden }, index) => (
|
||||
<tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
{key}
|
||||
</td>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
<SecretInput value={value} isReadOnly />
|
||||
</td>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api";
|
||||
import { TSecretImports } from "@app/hooks/api/secretImports/types";
|
||||
import { DecryptedSecret } from "@app/hooks/api/types";
|
||||
|
||||
import { SecretImportItem } from "./SecretImportItem";
|
||||
|
||||
const SECRET_IN_DASHBOARD = "Present In Dashboard";
|
||||
|
||||
type TImportedSecrets = Array<{
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
folderId: string;
|
||||
secrets: DecryptedSecret[];
|
||||
}>;
|
||||
|
||||
export const computeImportedSecretRows = (
|
||||
importedSecEnv: string,
|
||||
importedSecPath: string,
|
||||
importSecrets: TImportedSecrets = [],
|
||||
secrets: DecryptedSecret[] = [],
|
||||
environments: { name: string; slug: string }[] = []
|
||||
) => {
|
||||
const importedSecIndex = importSecrets.findIndex(
|
||||
({ secretPath, environment }) =>
|
||||
secretPath === importedSecPath && importedSecEnv === environment
|
||||
);
|
||||
if (importedSecIndex === -1) return [];
|
||||
|
||||
const importedSec = importSecrets[importedSecIndex];
|
||||
|
||||
const overridenSec: Record<string, { env: string; secretPath: string }> = {};
|
||||
const envSlug2Name: Record<string, string> = {};
|
||||
environments.forEach((el) => {
|
||||
envSlug2Name[el.slug] = el.name;
|
||||
});
|
||||
|
||||
for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) {
|
||||
importSecrets[i].secrets.forEach((el) => {
|
||||
overridenSec[el.key] = {
|
||||
env: envSlug2Name?.[importSecrets[i].environment] || "unknown",
|
||||
secretPath: importSecrets[i].secretPath
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
secrets.forEach((el) => {
|
||||
overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" };
|
||||
});
|
||||
|
||||
return importedSec.secrets.map(({ key, value }) => ({
|
||||
key,
|
||||
value,
|
||||
overriden: overridenSec?.[key]
|
||||
}));
|
||||
};
|
||||
|
||||
type Props = {
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
secretImports?: TSecretImports;
|
||||
isFetching?: boolean;
|
||||
secrets?: DecryptedSecret[];
|
||||
importedSecrets?: TImportedSecrets;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
type TDeleteSecretImport = { environment: string; secretPath: string };
|
||||
|
||||
export const SecretImportListView = ({
|
||||
secretImports,
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
importedSecrets,
|
||||
secrets = [],
|
||||
isFetching,
|
||||
searchTerm
|
||||
}: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"deleteSecretImport"
|
||||
] as const);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
);
|
||||
|
||||
const [items, setItems] = useState(
|
||||
(secretImports?.imports || [])?.map((dto) => ({
|
||||
id: `${dto.environment}-${dto.secretPath}`,
|
||||
...dto
|
||||
}))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching) {
|
||||
setItems(
|
||||
(secretImports?.imports || [])?.map((dto) => ({
|
||||
id: `${dto.environment}-${dto.secretPath}`,
|
||||
...dto
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [isFetching]);
|
||||
|
||||
const { mutateAsync: deleteSecretImport } = useDeleteSecretImport();
|
||||
const { mutate: updateSecretImport } = useUpdateSecretImport();
|
||||
|
||||
const handleSecretImportDelete = async () => {
|
||||
const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport
|
||||
?.data as TDeleteSecretImport;
|
||||
try {
|
||||
if (secretImports?._id) {
|
||||
await deleteSecretImport({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory: secretPath,
|
||||
id: secretImports?._id,
|
||||
secretImportEnv: importEnv,
|
||||
secretImportPath: impSecPath
|
||||
});
|
||||
handlePopUpClose("deleteSecretImport");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed secret link"
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to remove secret link",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecretImportReorder = ({ over, active }: DragEndEvent) => {
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = items.findIndex(({ id }) => id === active.id);
|
||||
const newIndex = items.findIndex(({ id }) => id === over?.id);
|
||||
const newImportOrder = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(newImportOrder);
|
||||
updateSecretImport({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory: secretPath,
|
||||
id: secretImports?._id || "",
|
||||
secretImports: newImportOrder
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext
|
||||
onDragEnd={handleSecretImportReorder}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
{items?.map(({ secretPath: importedSecPath, environment: importedEnv }) => (
|
||||
<SecretImportItem
|
||||
searchTerm={searchTerm}
|
||||
key={`${importedEnv}-${importedSecPath}`}
|
||||
importedEnv={importedEnv}
|
||||
importedSecPath={importedSecPath}
|
||||
importedSecrets={computeImportedSecretRows(
|
||||
importedEnv,
|
||||
importedSecPath,
|
||||
importedSecrets,
|
||||
secrets,
|
||||
environments
|
||||
)}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
onDelete={(env, secPath) =>
|
||||
handlePopUpOpen("deleteSecretImport", { environment: env, secretPath: secPath })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteSecretImport.isOpen}
|
||||
deleteKey="unlink"
|
||||
title="Do you want to remove this secret import?"
|
||||
subTitle={`This will unlink secrets from environment ${
|
||||
(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.environment
|
||||
} of path ${(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.secretPath}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
|
||||
onDeleteApproved={handleSecretImportDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretImportListView } from "./SecretImportListView";
|
||||
@@ -0,0 +1,389 @@
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faCircle,
|
||||
faCircleDot,
|
||||
faPlus,
|
||||
faTag
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
SecretInput,
|
||||
Switch,
|
||||
Tag,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { useGetSecretVersion } from "@app/hooks/api";
|
||||
import { DecryptedSecret, UserWsKeyPair, WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onToggle: (isOpen: boolean) => void;
|
||||
onClose: () => void;
|
||||
secret: DecryptedSecret;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
onDeleteSecret: () => void;
|
||||
onSaveSecret: (
|
||||
orgSec: DecryptedSecret,
|
||||
modSec: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] }
|
||||
) => Promise<void>;
|
||||
tags: WsTag[];
|
||||
onCreateTag: () => void;
|
||||
};
|
||||
|
||||
export const SecretDetailSidebar = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
decryptFileKey,
|
||||
secret,
|
||||
onDeleteSecret,
|
||||
onSaveSecret,
|
||||
tags,
|
||||
onCreateTag,
|
||||
environment,
|
||||
secretPath
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
watch,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { isDirty, isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: secret
|
||||
});
|
||||
const permission = useProjectPermission();
|
||||
const cannotEditSecret = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
const isReadOnly =
|
||||
permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
) && cannotEditSecret;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "tags"
|
||||
});
|
||||
const selectedTags = watch("tags", []);
|
||||
const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr._id]: true }),
|
||||
{}
|
||||
);
|
||||
|
||||
const overrideAction = watch("overrideAction");
|
||||
const isOverridden =
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
|
||||
const { data: secretVersion } = useGetSecretVersion({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
secretId: secret?._id,
|
||||
decryptFileKey
|
||||
});
|
||||
|
||||
const handleOverrideClick = () => {
|
||||
if (isOverridden) {
|
||||
// override need not be flagged delete if it was never saved in server
|
||||
// meaning a new unsaved personal secret but user toggled back later
|
||||
const isUnsavedOverride = !secret.idOverride;
|
||||
setValue(
|
||||
"overrideAction",
|
||||
isUnsavedOverride ? secret?.overrideAction : SecretActionType.Deleted,
|
||||
{
|
||||
shouldDirty: !isUnsavedOverride
|
||||
}
|
||||
);
|
||||
setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride });
|
||||
} else {
|
||||
setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true });
|
||||
setValue("valueOverride", "", { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagSelect = (tag: WsTag) => {
|
||||
if (selectedTagsGroupById?.[tag._id]) {
|
||||
const tagPos = selectedTags.findIndex(({ _id }) => _id === tag._id);
|
||||
if (tagPos !== -1) {
|
||||
remove(tagPos);
|
||||
}
|
||||
} else {
|
||||
append(tag);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
await onSaveSecret(secret, { ...secret, ...data });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
onOpenChange={(state) => {
|
||||
if (isOpen && isDirty) {
|
||||
if (
|
||||
// eslint-disable-next-line no-alert
|
||||
window.confirm("You have edited the secret. Are you sure you want to reset the change?")
|
||||
) {
|
||||
onToggle(false);
|
||||
reset();
|
||||
} else return;
|
||||
}
|
||||
onToggle(state);
|
||||
}}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<DrawerContent title="Secret">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="h-full">
|
||||
<div className="flex flex-col h-full">
|
||||
<FormControl label="Key">
|
||||
<Input isDisabled {...register("key")} />
|
||||
</FormControl>
|
||||
<Controller
|
||||
name="value"
|
||||
key="secret-value"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl label="Value">
|
||||
<SecretInput
|
||||
isReadOnly={isReadOnly}
|
||||
key="secret-value"
|
||||
isDisabled={isOverridden}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
|
||||
{...field}
|
||||
autoFocus={false}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mb-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
isDisabled={!isAllowed}
|
||||
id="personal-override"
|
||||
onCheckedChange={handleOverrideClick}
|
||||
isChecked={isOverridden}
|
||||
>
|
||||
Override with a personal value
|
||||
</Switch>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
{isOverridden && (
|
||||
<Controller
|
||||
name="valueOverride"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl label="Value Override">
|
||||
<SecretInput
|
||||
isReadOnly={isReadOnly}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="my-2 mb-6 border-t border-mineshaft-600 pt-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="skipMultilineEncoding"
|
||||
render={({ field: { value, onChange, onBlur } }) => (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
id="skipmultiencoding-option"
|
||||
onCheckedChange={onChange}
|
||||
isChecked={value}
|
||||
onBlur={onBlur}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Disable multi line encoding
|
||||
</Switch>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormControl label="Tags" className="">
|
||||
<div className="overflow-hidden grid gap-2 grid-flow-col auto-cols-min pt-2">
|
||||
{fields.map(({ tagColor, id: formId, name, _id }) => (
|
||||
<Tag
|
||||
className="flex items-center space-x-2 w-min"
|
||||
key={formId}
|
||||
onClose={() => {
|
||||
if (cannotEditSecret) {
|
||||
createNotification({ type: "error", text: "Access denied" });
|
||||
return;
|
||||
}
|
||||
const tag = tags?.find(({ _id: id }) => id === _id);
|
||||
if (tag) handleTagSelect(tag);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
<div className="text-sm">{name}</div>
|
||||
</Tag>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="add"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="rounded-md"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenuContent align="end" className="z-[100]">
|
||||
<DropdownMenuLabel>Apply tags to this secrets</DropdownMenuLabel>
|
||||
{tags.map((tag) => {
|
||||
const { _id: tagId, name, tagColor } = tag;
|
||||
|
||||
const isSelected = selectedTagsGroupById?.[tagId];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
key={tagId}
|
||||
icon={isSelected && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full mr-2"
|
||||
style={{ background: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
{name}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.Tags}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
size="xs"
|
||||
className="w-full"
|
||||
leftIcon={<FontAwesomeIcon icon={faTag} />}
|
||||
onClick={onCreateTag}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create a tag
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormControl label="Comments & Notes">
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
{...register("comment")}
|
||||
readOnly={isReadOnly}
|
||||
rows={5}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="dark mb-4 text-sm text-bunker-300 flex-grow">
|
||||
<div className="mb-2">Version History</div>
|
||||
<div className="flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-bunker-800 p-2 dark:[color-scheme:dark]">
|
||||
{secretVersion?.map(({ createdAt, value, id }, i) => (
|
||||
<div key={id} className="flex flex-col space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={i === 0 ? faCircleDot : faCircle} size="sm" />
|
||||
</div>
|
||||
<div>{format(new Date(createdAt), "Pp")}</div>
|
||||
</div>
|
||||
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
|
||||
<div className="self-start rounded-sm bg-primary-500/30 px-1">Value:</div>
|
||||
<div className="break-all font-mono">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex space-x-4 items-center">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isFullWidth
|
||||
type="submit"
|
||||
isDisabled={isSubmitting || !isDirty || !isAllowed}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button colorSchema="danger" isDisabled={!isAllowed} onClick={onDeleteSecret}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,481 @@
|
||||
import { memo, useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faCheck,
|
||||
faClose,
|
||||
faCodeBranch,
|
||||
faComment,
|
||||
faCopy,
|
||||
faEllipsis,
|
||||
faKey,
|
||||
faSave,
|
||||
faTag,
|
||||
faTags
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
SecretInput,
|
||||
Spinner,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
import { WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
|
||||
|
||||
type Props = {
|
||||
secret: DecryptedSecret;
|
||||
onSaveSecret: (
|
||||
orgSec: DecryptedSecret,
|
||||
modSec: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] }
|
||||
) => Promise<void>;
|
||||
onDeleteSecret: (sec: DecryptedSecret) => void;
|
||||
onDetailViewSecret: (sec: DecryptedSecret) => void;
|
||||
isVisible?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSecretSelect: (id: string) => void;
|
||||
tags: WsTag[];
|
||||
onCreateTag: () => void;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export const SecretItem = memo(
|
||||
({
|
||||
secret,
|
||||
onSaveSecret,
|
||||
onDeleteSecret,
|
||||
onDetailViewSecret,
|
||||
isVisible,
|
||||
isSelected,
|
||||
tags = [],
|
||||
onCreateTag,
|
||||
onToggleSecretSelect,
|
||||
environment,
|
||||
secretPath
|
||||
}: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const permission = useProjectPermission();
|
||||
const isReadOnly =
|
||||
permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
) &&
|
||||
permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
register,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { isDirty, isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
defaultValues: secret,
|
||||
values: secret,
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const overrideAction = watch("overrideAction");
|
||||
const hasComment = Boolean(watch("comment"));
|
||||
|
||||
const selectedTags = watch("tags", []);
|
||||
const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr._id]: true }),
|
||||
{}
|
||||
);
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "tags"
|
||||
});
|
||||
|
||||
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isSecValueCopied) {
|
||||
timer = setTimeout(() => setIsSecValueCopied.off(), 2000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSecValueCopied]);
|
||||
|
||||
const isOverriden =
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
const hasTagsApplied = Boolean(fields.length);
|
||||
|
||||
const handleOverrideClick = () => {
|
||||
if (isOverriden) {
|
||||
// override need not be flagged delete if it was never saved in server
|
||||
// meaning a new unsaved personal secret but user toggled back later
|
||||
const isUnsavedOverride = !secret.idOverride;
|
||||
setValue(
|
||||
"overrideAction",
|
||||
isUnsavedOverride ? secret?.overrideAction : SecretActionType.Deleted,
|
||||
{
|
||||
shouldDirty: !isUnsavedOverride
|
||||
}
|
||||
);
|
||||
setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride });
|
||||
} else {
|
||||
setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true });
|
||||
setValue("valueOverride", "", { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
await onSaveSecret(secret, { ...secret, ...data });
|
||||
};
|
||||
|
||||
const handleTagSelect = (tag: WsTag) => {
|
||||
if (selectedTagsGroupById?.[tag._id]) {
|
||||
const tagPos = selectedTags.findIndex(({ _id }) => _id === tag._id);
|
||||
if (tagPos !== -1) {
|
||||
remove(tagPos);
|
||||
}
|
||||
} else {
|
||||
append(tag);
|
||||
}
|
||||
};
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
const [overrideValue, value] = getValues(["value", "valueOverride"]);
|
||||
navigator.clipboard.writeText((overrideValue || value) as string);
|
||||
setIsSecValueCopied.on();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div
|
||||
className={twMerge(
|
||||
"shadow-none border-b border-mineshaft-600 bg-mineshaft-800 hover:bg-mineshaft-700",
|
||||
isDirty && "border-primary-400/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex group">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center justify-center w-11 px-4 py-3 h-11",
|
||||
isDirty && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`checkbox-${secret._id}`}
|
||||
isChecked={isSelected}
|
||||
onCheckedChange={() => onToggleSecretSelect(secret._id)}
|
||||
className={twMerge("group-hover:flex hidden", isSelected && "flex")}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon={faKey}
|
||||
className={twMerge("group-hover:hidden block", isSelected && "hidden")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-80 h-11 flex items-center px-4 py-2 flex-shrink-0">
|
||||
<Controller
|
||||
name="key"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
autoComplete="off"
|
||||
isReadOnly={isReadOnly}
|
||||
autoCapitalization={currentWorkspace?.autoCapitalization}
|
||||
variant="plain"
|
||||
{...field}
|
||||
className="w-full focus:text-bunker-100 hover:ring-primary-400/50 hover:ring-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex-grow flex items-center border-x border-mineshaft-600 pl-4 pr-2 py-1"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
{isOverriden ? (
|
||||
<Controller
|
||||
name="valueOverride"
|
||||
key="value-overriden"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SecretInput
|
||||
key="value-overriden"
|
||||
isVisible={isVisible}
|
||||
isReadOnly={isReadOnly}
|
||||
{...field}
|
||||
containerClassName="hover:border-primary-400/50 border border-transparent px-2 py-1.5 rounded-md transition-all group-hover:mr-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
name="value"
|
||||
key="secret-value"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SecretInput
|
||||
isReadOnly={isReadOnly}
|
||||
key="secret-value"
|
||||
isVisible={isVisible}
|
||||
{...field}
|
||||
containerClassName="hover:border-primary-400/50 border border-transparent px-2 py-1.5 rounded-md transition-all group-hover:mr-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div key="actions" className="h-8 flex self-start flex-shrink-0 transition-all">
|
||||
<Tooltip content="Copy secret">
|
||||
<IconButton
|
||||
ariaLabel="copy-value"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuTrigger asChild disabled={!isAllowed}>
|
||||
<IconButton
|
||||
ariaLabel="tags"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0 data-[state=open]:w-5",
|
||||
hasTagsApplied && "w-5 text-primary"
|
||||
)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Tooltip content="Tags">
|
||||
<FontAwesomeIcon icon={faTags} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Apply tags to this secrets</DropdownMenuLabel>
|
||||
{tags.map((tag) => {
|
||||
const { _id: tagId, name, tagColor } = tag;
|
||||
|
||||
const isTagSelected = selectedTagsGroupById?.[tagId];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
key={tagId}
|
||||
icon={isTagSelected && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full mr-2"
|
||||
style={{ background: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
{name}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
size="xs"
|
||||
className="w-full"
|
||||
leftIcon={<FontAwesomeIcon icon={faTag} />}
|
||||
onClick={onCreateTag}
|
||||
>
|
||||
Create a tag
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Override"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="override-value"
|
||||
isDisabled={!isAllowed}
|
||||
variant="plain"
|
||||
size="sm"
|
||||
onClick={handleOverrideClick}
|
||||
className={twMerge(
|
||||
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0",
|
||||
isOverriden && "w-5 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCodeBranch} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Popover>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<PopoverTrigger asChild disabled={!isAllowed}>
|
||||
<IconButton
|
||||
className={twMerge(
|
||||
"overflow-hidden w-0 p-0 group-hover:w-5 group-hover:mr-2 data-[state=open]:w-6",
|
||||
hasComment && "w-5 text-primary"
|
||||
)}
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-comment"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Tooltip content="Comment">
|
||||
<FontAwesomeIcon icon={faComment} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<PopoverContent
|
||||
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
|
||||
sticky="always"
|
||||
>
|
||||
<FormControl label="Comment" className="mb-0">
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
rows={8}
|
||||
cols={30}
|
||||
{...register("comment")}
|
||||
/>
|
||||
</FormControl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence exitBeforeEnter>
|
||||
{!isDirty ? (
|
||||
<motion.div
|
||||
key="options"
|
||||
className="h-10 flex items-center space-x-4 flex-shrink-0 px-3"
|
||||
initial={{ x: 0, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 10, opacity: 0 }}
|
||||
>
|
||||
<Tooltip content="More">
|
||||
<IconButton
|
||||
ariaLabel="more"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
onClick={() => onDetailViewSecret(secret)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsis} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="delete-value"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
onClick={() => onDeleteSecret(secret)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="options-save"
|
||||
className="h-10 flex items-center space-x-4 flex-shrink-0 px-3"
|
||||
initial={{ x: -10, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -10, opacity: 0 }}
|
||||
>
|
||||
<Tooltip content="Save">
|
||||
<IconButton
|
||||
ariaLabel="more"
|
||||
variant="plain"
|
||||
type="submit"
|
||||
size="md"
|
||||
className={twMerge(
|
||||
"group-hover:opacity-100 opacity-0 p-0 text-primary",
|
||||
isDirty && "opacity-100"
|
||||
)}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Spinner className="w-4 h-4 p-0 m-0" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faSave} size="lg" className="text-primary" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Cancel">
|
||||
<IconButton
|
||||
ariaLabel="more"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className={twMerge(
|
||||
"group-hover:opacity-100 opacity-0 p-0",
|
||||
isDirty && "opacity-100"
|
||||
)}
|
||||
onClick={() => reset()}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SecretItem.displayName = "SecretItem";
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { CreateTagModal } from "@app/components/tags/CreateTagModal";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
import { UserWsKeyPair, WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { Filter, GroupBy, SortDir } from "../../SecretMainPage.types";
|
||||
import { SecretDetailSidebar } from "./SecretDetaiSidebar";
|
||||
import { SecretItem } from "./SecretItem";
|
||||
|
||||
type Props = {
|
||||
secrets?: DecryptedSecret[];
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
secretPath?: string;
|
||||
filter: Filter;
|
||||
sortDir?: SortDir;
|
||||
tags?: WsTag[];
|
||||
isVisible?: boolean;
|
||||
selectedSecrets: Record<string, boolean>;
|
||||
onToggleSecretSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
const reorderSecretGroupByUnderscore = (secrets: DecryptedSecret[], sortDir: SortDir) => {
|
||||
const groupedSecrets: Record<string, DecryptedSecret[]> = {};
|
||||
secrets.forEach((secret) => {
|
||||
const lastSeperatorIndex = secret.key.lastIndexOf("_");
|
||||
const namespace =
|
||||
lastSeperatorIndex !== -1 ? secret.key.substring(0, lastSeperatorIndex) : "misc";
|
||||
if (!groupedSecrets?.[namespace]) groupedSecrets[namespace] = [];
|
||||
groupedSecrets[namespace].push(secret);
|
||||
});
|
||||
|
||||
return Object.keys(groupedSecrets)
|
||||
.sort((a, b) =>
|
||||
sortDir === SortDir.ASC
|
||||
? a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
: b.toLowerCase().localeCompare(a.toLowerCase())
|
||||
)
|
||||
.map((namespace) => ({ namespace, secrets: groupedSecrets[namespace] }));
|
||||
};
|
||||
|
||||
const reorderSecret = (secrets: DecryptedSecret[], sortDir: SortDir, filter?: GroupBy | null) => {
|
||||
console.log(secrets);
|
||||
if (filter === GroupBy.PREFIX) {
|
||||
return reorderSecretGroupByUnderscore(secrets, sortDir);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
namespace: "",
|
||||
secrets: secrets?.sort((a, b) =>
|
||||
sortDir === SortDir.ASC
|
||||
? a.key.toLowerCase().localeCompare(b.key.toLowerCase())
|
||||
: b.key.toLowerCase().localeCompare(a.key.toLowerCase())
|
||||
)
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export const filterSecrets = (secrets: DecryptedSecret[], filter: Filter) =>
|
||||
secrets.filter(({ key, value, tags }) => {
|
||||
const isTagFilterActive = Boolean(Object.keys(filter.tags).length);
|
||||
const searchTerm = filter.searchFilter.toLowerCase();
|
||||
return (
|
||||
(!isTagFilterActive || tags.some(({ _id }) => filter.tags?.[_id])) &&
|
||||
(key.toLowerCase().includes(searchTerm) || value.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
});
|
||||
|
||||
export const SecretListView = ({
|
||||
secrets = [],
|
||||
environment,
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
secretPath = "/",
|
||||
filter,
|
||||
sortDir = SortDir.ASC,
|
||||
tags: wsTags = [],
|
||||
isVisible,
|
||||
selectedSecrets,
|
||||
onToggleSecretSelect
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const queryClient = useQueryClient();
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"deleteSecret",
|
||||
"secretDetail",
|
||||
"createTag"
|
||||
] as const);
|
||||
|
||||
// strip of side effect queries
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3({
|
||||
options: {
|
||||
onSuccess: undefined
|
||||
}
|
||||
});
|
||||
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3({
|
||||
options: {
|
||||
onSuccess: undefined
|
||||
}
|
||||
});
|
||||
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3({
|
||||
options: {
|
||||
onSuccess: undefined
|
||||
}
|
||||
});
|
||||
|
||||
const handleSecretOperation = async (
|
||||
operation: "create" | "update" | "delete",
|
||||
type: "shared" | "personal",
|
||||
key: string,
|
||||
{
|
||||
value,
|
||||
comment,
|
||||
tags,
|
||||
skipMultilineEncoding,
|
||||
newKey
|
||||
}: Partial<{
|
||||
value: string;
|
||||
comment: string;
|
||||
tags: string[];
|
||||
skipMultilineEncoding: boolean;
|
||||
newKey: string;
|
||||
}> = {}
|
||||
) => {
|
||||
if (operation === "delete") {
|
||||
await deleteSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretName: key,
|
||||
type
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation === "update") {
|
||||
await updateSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretName: key,
|
||||
secretValue: value || "",
|
||||
type,
|
||||
latestFileKey: decryptFileKey,
|
||||
tags,
|
||||
secretComment: comment,
|
||||
skipMultilineEncoding,
|
||||
newSecretName: newKey
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createSecretV3(
|
||||
{
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretName: key,
|
||||
secretValue: value || "",
|
||||
secretComment: "",
|
||||
skipMultilineEncoding,
|
||||
type,
|
||||
latestFileKey: decryptFileKey
|
||||
},
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveSecret = useCallback(
|
||||
async (
|
||||
orgSecret: DecryptedSecret,
|
||||
modSecret: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] }
|
||||
) => {
|
||||
const { key: oldKey } = orgSecret;
|
||||
const { key, value, overrideAction, idOverride, valueOverride, tags, comment } = modSecret;
|
||||
const hasKeyChanged = oldKey !== key;
|
||||
|
||||
const tagIds = tags.map(({ _id }) => _id);
|
||||
const oldTagIds = orgSecret.tags.map(({ _id }) => _id);
|
||||
const isSameTags = JSON.stringify(tagIds) === JSON.stringify(oldTagIds);
|
||||
const isSharedSecUnchanged =
|
||||
(["key", "value", "comment", "skipMultilineEncoding"] as const).every(
|
||||
(el) => orgSecret[el] === modSecret[el]
|
||||
) && isSameTags;
|
||||
|
||||
try {
|
||||
// personal secret change
|
||||
if (overrideAction === "deleted") await handleSecretOperation("delete", "personal", key);
|
||||
else if (overrideAction && idOverride)
|
||||
await handleSecretOperation("update", "personal", oldKey, {
|
||||
value: valueOverride,
|
||||
newKey: hasKeyChanged ? key : undefined,
|
||||
skipMultilineEncoding: modSecret.skipMultilineEncoding
|
||||
});
|
||||
else if (overrideAction)
|
||||
await handleSecretOperation("create", "personal", key, { value: valueOverride });
|
||||
|
||||
// shared secret change
|
||||
if (!isSharedSecUnchanged)
|
||||
await handleSecretOperation("update", "shared", oldKey, {
|
||||
value,
|
||||
tags: tagIds,
|
||||
comment,
|
||||
newKey: hasKeyChanged ? key : undefined,
|
||||
skipMultilineEncoding: modSecret.skipMultilineEncoding
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
handlePopUpClose("secretDetail");
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully saved secrets"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to save secret"
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSecretDelete = useCallback(async () => {
|
||||
const { key } = popUp.deleteSecret?.data as DecryptedSecret;
|
||||
try {
|
||||
await handleSecretOperation("delete", "shared", key);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
handlePopUpClose("deleteSecret");
|
||||
handlePopUpClose("secretDetail");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted secret"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete secret"
|
||||
});
|
||||
}
|
||||
}, [(popUp.deleteSecret?.data as DecryptedSecret)?.key]);
|
||||
|
||||
// for optimization on minimise re-rendering of secret items
|
||||
const onCreateTag = useCallback(() => handlePopUpOpen("createTag"), []);
|
||||
const onDeleteSecret = useCallback(
|
||||
(sec: DecryptedSecret) => handlePopUpOpen("deleteSecret", sec),
|
||||
[]
|
||||
);
|
||||
const onDetailViewSecret = useCallback(
|
||||
(sec: DecryptedSecret) => handlePopUpOpen("secretDetail", sec),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{reorderSecret(secrets, sortDir, filter.groupBy).map(
|
||||
({ namespace, secrets: groupedSecrets }) => {
|
||||
const filteredSecrets = filterSecrets(groupedSecrets, filter);
|
||||
return (
|
||||
<div className="flex flex-col" key={`${namespace}-${groupedSecrets.length}`}>
|
||||
<div
|
||||
className={twMerge(
|
||||
"bg-bunker-600 capitalize text-md h-0 transition-all",
|
||||
Boolean(namespace) && Boolean(filteredSecrets.length) && "h-11 py-3 pl-4 "
|
||||
)}
|
||||
key={namespace}
|
||||
>
|
||||
{namespace}
|
||||
</div>
|
||||
|
||||
{filteredSecrets.map((secret) => (
|
||||
<SecretItem
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
tags={wsTags}
|
||||
isSelected={selectedSecrets?.[secret._id]}
|
||||
onToggleSecretSelect={onToggleSecretSelect}
|
||||
isVisible={isVisible}
|
||||
secret={secret}
|
||||
key={secret._id}
|
||||
onSaveSecret={handleSaveSecret}
|
||||
onDeleteSecret={onDeleteSecret}
|
||||
onDetailViewSecret={onDetailViewSecret}
|
||||
onCreateTag={onCreateTag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteSecret.isOpen}
|
||||
deleteKey={(popUp.deleteSecret?.data as DecryptedSecret)?.key}
|
||||
title="Do you want to delete this secret?"
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
|
||||
onDeleteApproved={handleSecretDelete}
|
||||
/>
|
||||
<SecretDetailSidebar
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
isOpen={popUp.secretDetail.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("secretDetail", isOpen)}
|
||||
decryptFileKey={decryptFileKey}
|
||||
secret={popUp.secretDetail.data as DecryptedSecret}
|
||||
onDeleteSecret={() => handlePopUpOpen("deleteSecret", popUp.secretDetail.data)}
|
||||
onClose={() => handlePopUpClose("secretDetail")}
|
||||
onSaveSecret={handleSaveSecret}
|
||||
tags={wsTags}
|
||||
onCreateTag={() => handlePopUpOpen("createTag")}
|
||||
/>
|
||||
<CreateTagModal
|
||||
isOpen={popUp.createTag.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export enum SecretActionType {
|
||||
Created = "created",
|
||||
Modified = "modified",
|
||||
Deleted = "deleted"
|
||||
}
|
||||
|
||||
export const formSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
idOverride: z.string().optional(),
|
||||
valueOverride: z.string().optional(),
|
||||
overrideAction: z.string().optional(),
|
||||
comment: z.string().trim().optional(),
|
||||
skipMultilineEncoding: z.boolean().optional(),
|
||||
tags: z
|
||||
.object({
|
||||
_id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tagColor: z.string().optional()
|
||||
})
|
||||
.array()
|
||||
.default([])
|
||||
});
|
||||
|
||||
export type TFormSchema = z.infer<typeof formSchema>;
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretListView } from "./SecretListView";
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
faChevronRight,
|
||||
faKey,
|
||||
faMinusSquare,
|
||||
faPencilSquare,
|
||||
faPlusSquare,
|
||||
faSquare
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
SecretInput,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { DecryptedSecret } from "@app/hooks/api/types";
|
||||
|
||||
export enum TDiffModes {
|
||||
NoChange = "no change",
|
||||
Deleted = "deleted",
|
||||
Modified = "modified",
|
||||
Created = "created"
|
||||
}
|
||||
|
||||
type Props = {
|
||||
mode: TDiffModes;
|
||||
preSecret?: DecryptedSecret;
|
||||
postSecret: DecryptedSecret;
|
||||
};
|
||||
export type TDiffView<T> = {
|
||||
mode: TDiffModes;
|
||||
pre?: T;
|
||||
post: T;
|
||||
};
|
||||
|
||||
export const renderIcon = (mode: TDiffModes) => {
|
||||
if (mode === TDiffModes.NoChange)
|
||||
return <FontAwesomeIcon icon={faSquare} className="text-gray-700" size="lg" />;
|
||||
if (mode === TDiffModes.Deleted)
|
||||
return <FontAwesomeIcon icon={faMinusSquare} className="text-red-700" size="lg" />;
|
||||
if (mode === TDiffModes.Modified)
|
||||
return <FontAwesomeIcon icon={faPencilSquare} className="text-orange-700" size="lg" />;
|
||||
|
||||
return <FontAwesomeIcon icon={faPlusSquare} className="text-green-700" size="lg" />;
|
||||
};
|
||||
|
||||
export const SecretItem = ({ mode, preSecret, postSecret }: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useToggle();
|
||||
|
||||
const isModified = mode === "modified";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={setIsExpanded.toggle}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setIsExpanded.toggle();
|
||||
}}
|
||||
>
|
||||
<div className="w-12 flex-shrink-0 px-4 py-3">
|
||||
<Tooltip content={mode}>{renderIcon(mode)}</Tooltip>
|
||||
</div>
|
||||
<div className="w-12 flex-shrink-0 px-4 py-3">
|
||||
<FontAwesomeIcon icon={faKey} />
|
||||
</div>
|
||||
<div className="flex-grow px-4 py-3 flex items-center space-x-4">
|
||||
{mode === "modified" ? (
|
||||
<>
|
||||
<div>{preSecret?.key}</div>
|
||||
<div className="bg-primary text-black rounded-lg font-bold px-1 py-0.5 text-xs">
|
||||
v{preSecret?.version}
|
||||
</div>
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faChevronRight} size="sm" className="text-orange-700" />
|
||||
</div>
|
||||
<div className="bg-primary text-black rounded-lg font-bold px-1 py-0.5 text-xs">
|
||||
v{postSecret?.version}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
postSecret.key
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="flex bg-bunker-600 cursor-pointer p-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Th className="min-table-row min-w-[11rem] border-r border-mineshaft-600">Type</Th>
|
||||
{isModified ? (
|
||||
<>
|
||||
<Th className="border-r border-mineshaft-600">Before</Th>
|
||||
<Th>After</Th>
|
||||
</>
|
||||
) : (
|
||||
<Th>Value</Th>
|
||||
)}
|
||||
</THead>
|
||||
<TBody>
|
||||
<Tr>
|
||||
<Td className="border-r border-mineshaft-600">Key</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">{preSecret?.key}</Td>
|
||||
)}
|
||||
<Td>{postSecret.key}</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td className="border-r border-mineshaft-600">Value</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">
|
||||
<SecretInput value={preSecret?.value} />
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
<SecretInput value={postSecret?.value} />
|
||||
</Td>
|
||||
</Tr>
|
||||
{Boolean(preSecret?.idOverride || postSecret?.idOverride) && (
|
||||
<Tr>
|
||||
<Td className="border-r border-mineshaft-600">Override</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">
|
||||
<SecretInput value={preSecret?.valueOverride} />
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
<SecretInput value={postSecret?.valueOverride} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
<Tr>
|
||||
<Td className="border-r border-mineshaft-600">Comment</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">{preSecret?.comment}</Td>
|
||||
)}
|
||||
<Td>{postSecret?.comment}</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td className="border-r border-mineshaft-600">Tags</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">
|
||||
{preSecret?.tags?.map(({ name, _id: tagId, tagColor }) => (
|
||||
<Tag
|
||||
className="flex items-center space-x-2 w-min"
|
||||
key={`${preSecret._id}-${tagId}`}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
<div className="text-sm">{name}</div>
|
||||
</Tag>
|
||||
))}
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
{postSecret?.tags?.map(({ name, _id: tagId, tagColor }) => (
|
||||
<Tag
|
||||
className="flex items-center space-x-2 w-min"
|
||||
key={`${postSecret._id}-${tagId}`}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
<div className="text-sm">{name}</div>
|
||||
</Tag>
|
||||
))}
|
||||
</Td>
|
||||
</Tr>
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faChevronRight,
|
||||
faCodeCommit,
|
||||
faFolder,
|
||||
faMagnifyingGlass,
|
||||
faUndo
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, ContentLoader, Input, Tag, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { useGetSnapshotSecrets, usePerformSecretRollback } from "@app/hooks/api";
|
||||
import { DecryptedSecret, TSecretFolder, UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
import { renderIcon, SecretItem, TDiffModes, TDiffView } from "./SecretItem";
|
||||
|
||||
type Props = {
|
||||
snapshotId: string;
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
secrets?: DecryptedSecret[];
|
||||
folders?: TSecretFolder[];
|
||||
snapshotCount?: number;
|
||||
onGoBack: () => void;
|
||||
onClickListSnapshot: () => void;
|
||||
};
|
||||
|
||||
const LOADER_TEXT = ["Fetching your snapshot", "Creating the difference view"];
|
||||
|
||||
const deepCompareSecrets = (lhs: DecryptedSecret, rhs: DecryptedSecret) =>
|
||||
lhs.key === rhs.key &&
|
||||
lhs.value === rhs.value &&
|
||||
lhs.comment === rhs.comment &&
|
||||
lhs?.valueOverride === rhs?.valueOverride &&
|
||||
JSON.stringify(lhs.tags) === JSON.stringify(rhs.tags);
|
||||
|
||||
export const SnapshotView = ({
|
||||
snapshotId,
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
decryptFileKey,
|
||||
secrets = [],
|
||||
folders = [],
|
||||
onGoBack,
|
||||
snapshotCount,
|
||||
onClickListSnapshot
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const [search, setSearch] = useState("");
|
||||
const { mutateAsync: performRollback, isLoading: isRollingBack } = usePerformSecretRollback();
|
||||
|
||||
const { data: snapshotData, isLoading: isSnapshotLoading } = useGetSnapshotSecrets({
|
||||
snapshotId,
|
||||
env: environment,
|
||||
decryptFileKey
|
||||
});
|
||||
|
||||
const rollingFolder = snapshotData?.folders || [];
|
||||
const rollingSecrets = snapshotData?.secrets || [];
|
||||
|
||||
const folderDiffView = useMemo(() => {
|
||||
const folderGroupById = folders.reduce<Record<string, TSecretFolder>>(
|
||||
(prev, curr) => ({ ...prev, [curr.id]: curr }),
|
||||
{}
|
||||
);
|
||||
const diffView: Array<TDiffView<TSecretFolder>> = [];
|
||||
rollingFolder.forEach((rollFolder) => {
|
||||
const { id, name } = rollFolder;
|
||||
const doesExist = Boolean(folderGroupById?.[id]);
|
||||
if (doesExist) {
|
||||
diffView.push({
|
||||
mode: name === folderGroupById[id].name ? TDiffModes.NoChange : TDiffModes.Modified,
|
||||
pre: folderGroupById[id],
|
||||
post: rollFolder
|
||||
});
|
||||
delete folderGroupById[id];
|
||||
} else {
|
||||
diffView.push({ mode: TDiffModes.Created, post: rollFolder });
|
||||
}
|
||||
});
|
||||
Object.values(folderGroupById).forEach((folder) => {
|
||||
diffView.push({ mode: TDiffModes.Deleted, post: folder });
|
||||
});
|
||||
return diffView;
|
||||
}, [folders, rollingFolder]);
|
||||
|
||||
const secretDiffView = useMemo(() => {
|
||||
const secretGroupById = secrets.reduce<Record<string, DecryptedSecret>>(
|
||||
(prev, curr) => ({ ...prev, [curr._id]: curr }),
|
||||
{}
|
||||
);
|
||||
const diffView: Array<TDiffView<DecryptedSecret>> = [];
|
||||
rollingSecrets.forEach((rollSecret) => {
|
||||
const { _id: id } = rollSecret;
|
||||
const doesExist = Boolean(secretGroupById?.[id]);
|
||||
if (doesExist) {
|
||||
diffView.push({
|
||||
mode: deepCompareSecrets(rollSecret, secretGroupById[id])
|
||||
? TDiffModes.NoChange
|
||||
: TDiffModes.Modified,
|
||||
pre: secretGroupById[id],
|
||||
post: rollSecret
|
||||
});
|
||||
delete secretGroupById[id];
|
||||
} else {
|
||||
diffView.push({ mode: TDiffModes.Created, post: rollSecret });
|
||||
}
|
||||
});
|
||||
Object.values(secretGroupById).forEach((folder) => {
|
||||
diffView.push({ mode: TDiffModes.Deleted, post: folder });
|
||||
});
|
||||
return diffView;
|
||||
}, [secrets, rollingSecrets]);
|
||||
|
||||
const handleClickRollback = async () => {
|
||||
if (!snapshotData?.version) {
|
||||
createNotification({
|
||||
text: "Failed to find secret version",
|
||||
type: "success"
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await performRollback({
|
||||
workspaceId,
|
||||
version: snapshotData.version,
|
||||
environment,
|
||||
directory: secretPath
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully rollback secrets",
|
||||
type: "success"
|
||||
});
|
||||
onGoBack();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
text: "Failed to rollback secrets",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isSnapshotLoading) {
|
||||
return <ContentLoader text={LOADER_TEXT} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex space-x-4 items-center">
|
||||
<h6 className="text-2xl">Snapshot</h6>
|
||||
<Tag colorSchema="green">{new Date(snapshotData?.createdAt || "").toLocaleString()}</Tag>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<div className="w-2/5">
|
||||
<Input
|
||||
className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
placeholder="Search by folder name, key name ..."
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
value={search}
|
||||
onChange={(evt) => setSearch(evt.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
<div>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={onClickListSnapshot}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
className="h-10"
|
||||
isDisabled={isRollingBack}
|
||||
>
|
||||
{snapshotCount} Commits
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={onGoBack}
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
|
||||
className="h-10"
|
||||
isDisabled={isRollingBack}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.SecretRollback}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={handleClickRollback}
|
||||
isDisabled={isRollingBack || !isAllowed}
|
||||
isLoading={isRollingBack}
|
||||
leftIcon={<FontAwesomeIcon icon={faUndo} />}
|
||||
className="h-10"
|
||||
>
|
||||
Rollback
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 bg-mineshaft-800 text-left text-bunker-300 rounded-md text-sm">
|
||||
<div className="flex flex-col ">
|
||||
<div className="flex font-medium border-b border-mineshaft-600">
|
||||
<div className="w-12 flex-shrink-0" />
|
||||
<div className="w-12 flex-shrink-0" />
|
||||
<div className="flex-grow px-4 py-2 flex items-center">Changes</div>
|
||||
</div>
|
||||
{folderDiffView
|
||||
.sort((a, b) => a.post.name.toLowerCase().localeCompare(b.post.name.toLowerCase()))
|
||||
.filter((a) => a.post.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.map(({ mode, pre, post }, index) => (
|
||||
<div
|
||||
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
|
||||
key={`folder-${mode}-${index + 1}`}
|
||||
>
|
||||
<div className="w-12 flex-shrink-0 px-4 py-3">
|
||||
<Tooltip content={mode}>{renderIcon(mode)}</Tooltip>
|
||||
</div>
|
||||
<div className="w-12 flex-shrink-0 px-4 py-3">
|
||||
<FontAwesomeIcon icon={faFolder} />
|
||||
</div>
|
||||
<div className="flex-grow px-4 py-3 flex items-center space-x-4">
|
||||
{mode === "modified" ? (
|
||||
<>
|
||||
<div>{pre?.name}</div>
|
||||
<div>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
size="sm"
|
||||
className="text-orange-700"
|
||||
/>
|
||||
</div>
|
||||
<div>{post?.name}</div>
|
||||
</>
|
||||
) : (
|
||||
post.name
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{secretDiffView
|
||||
.sort((a, b) => a.post.key.toLowerCase().localeCompare(b.post.key.toLowerCase()))
|
||||
.filter((a) => a.post.key.toLowerCase().includes(search.toLowerCase()))
|
||||
.map(({ mode, pre, post }, index) => (
|
||||
<SecretItem
|
||||
key={`folder-${mode}-${index + 1}`}
|
||||
mode={mode}
|
||||
preSecret={pre}
|
||||
postSecret={post}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SnapshotView } from "./SnapshotView";
|
||||
1
frontend/src/views/SecretMainPage/index.tsx
Normal file
1
frontend/src/views/SecretMainPage/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { SecretMainPage } from "./SecretMainPage";
|
||||
@@ -98,7 +98,7 @@ export const SecretOverviewPage = () => {
|
||||
const { folders, folderNames, isFolderPresentInEnv } = useGetFoldersByEnv({
|
||||
workspaceId,
|
||||
environments: userAvailableEnvs.map(({ slug }) => slug),
|
||||
parentFolderPath: secretPath
|
||||
directory: secretPath
|
||||
});
|
||||
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||
@@ -190,17 +190,8 @@ export const SecretOverviewPage = () => {
|
||||
|
||||
const handleExploreEnvClick = (slug: string) => {
|
||||
const query: Record<string, string> = { ...router.query, env: slug };
|
||||
delete query.secretPath;
|
||||
// the dir return will have the present directory folder id
|
||||
// use that when clicking on explore to redirect user to there
|
||||
const envIndex = userAvailableEnvs.findIndex((el) => slug === el.slug);
|
||||
if (envIndex !== -1) {
|
||||
const envFolder = folders?.[envIndex];
|
||||
const dir = envFolder?.data?.dir?.pop();
|
||||
if (dir) {
|
||||
query.folderId = dir.id;
|
||||
}
|
||||
|
||||
router.push({
|
||||
pathname: "/project/[id]/secrets/[env]",
|
||||
query
|
||||
|
||||
@@ -90,7 +90,9 @@ export const SecretEditRow = ({
|
||||
<Controller
|
||||
control={control}
|
||||
name="value"
|
||||
render={({ field }) => <SecretInput {...field} isVisible={isVisible} />}
|
||||
render={({ field }) => (
|
||||
<SecretInput {...field} value={field.value as string} isVisible={isVisible} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-16 justify-center space-x-3 pl-2 transition-all">
|
||||
|
||||
Reference in New Issue
Block a user