feat(dashboard-v3): implemented new the dashboard with v3 support

This commit is contained in:
Akhil Mohan
2023-09-22 21:50:28 +05:30
parent fcf3f2837e
commit b54c29fc48
55 changed files with 4091 additions and 3599 deletions

View File

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

View File

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

View File

@@ -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>
</>
);

View 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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { CompareSecret } from "./CompareSecret";

View File

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

View File

@@ -1 +0,0 @@
export {CreateTagModal} from "./CreateTagModal"

View File

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

View File

@@ -1,3 +0,0 @@
export { FolderForm } from "./FolderForm";
export { FolderSection } from "./FolderSection";
export * from "./types";

View File

@@ -1,2 +0,0 @@
export type TEditFolderForm = { id: string; name: string };
export type TDeleteFolderForm = { id: string; name: string };

View File

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

View File

@@ -1 +0,0 @@
export {SecretDetailDrawer} from "./SecretDetailDrawer"

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { SecretImportForm } from "./SecretImportForm";

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { SecretImportSection } from "./SecretImportSection";

View File

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

View File

@@ -1 +0,0 @@
export { SecretInputRow } from "./SecretInputRow";

View File

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

View File

@@ -1 +0,0 @@
export { SecretTableHeader } from "./SecretTableHeader";

View File

@@ -1 +0,0 @@
export { DashboardPage } from "./DashboardPage";

View 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>
);
};

View 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"
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { ActionBar } from "./ActionBar";

View File

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

View File

@@ -0,0 +1 @@
export { FolderListView } from "./FolderListView";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { SecretImportListView } from "./SecretImportListView";

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { SecretListView } from "./SecretListView";

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { SnapshotView } from "./SnapshotView";

View File

@@ -0,0 +1 @@
export { SecretMainPage } from "./SecretMainPage";

View File

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

View File

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