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

This commit is contained in:
Akhil Mohan
2023-09-28 21:28:43 +05:30
parent fcf3f2837e
commit b54c29fc48
55 changed files with 4091 additions and 3599 deletions
@@ -0,0 +1,454 @@
import { subject } from "@casl/ability";
import {
faAnglesDown,
faCheckCircle,
faChevronRight,
faClose,
faCodeCommit,
faDownload,
faEye,
faEyeSlash,
faFileImport,
faFilter,
faFolderPlus,
faMagnifyingGlass,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import FileSaver from "file-saver";
import { twMerge } from "tailwind-merge";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
IconButton,
Input,
Modal,
ModalContent,
Tooltip,
UpgradePlanModal
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useCreateFolder, useDeleteSecretBatch } from "@app/hooks/api";
import { DecryptedSecret, TImportedSecrets, UserWsKeyPair, WsTag } from "@app/hooks/api/types";
import { Filter, GroupBy } from "../../SecretMainPage.types";
import { CreateSecretForm } from "./CreateSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm";
import { FolderForm } from "./FolderForm";
type Props = {
secrets?: DecryptedSecret[];
// swtich the secrets type as it gets decrypted after api call
importedSecrets?: Array<Omit<TImportedSecrets, "secrets"> & { secrets: DecryptedSecret[] }>;
environment: string;
workspaceId: string;
secretPath?: string;
decryptFileKey: UserWsKeyPair;
filter: Filter;
tags?: WsTag[];
isVisible?: boolean;
selectedSecrets: Record<string, boolean>;
snapshotCount: number;
isSnapshotCountLoading?: boolean;
onGroupByChange: (opt?: GroupBy) => void;
onSearchChange: (term: string) => void;
onToggleTagFilter: (tagId: string) => void;
onVisiblilityToggle: () => void;
onResetSelectedSecret: () => void;
onClickRollbackMode: () => void;
};
export const ActionBar = ({
secrets = [],
importedSecrets = [],
environment,
workspaceId,
decryptFileKey,
secretPath = "/",
filter,
tags = [],
isVisible,
selectedSecrets,
snapshotCount,
isSnapshotCountLoading,
onSearchChange,
onToggleTagFilter,
onGroupByChange,
onVisiblilityToggle,
onResetSelectedSecret,
onClickRollbackMode
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addSecret",
"addFolder",
"addSecretImport",
"bulkDeleteSecrets",
"misc",
"upgradePlan"
] as const);
const { subscription } = useSubscription();
const { createNotification } = useNotificationContext();
const { mutateAsync: createFolder } = useCreateFolder();
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
const handleFolderCreate = async (folderName: string) => {
try {
await createFolder({
folderName,
directory: secretPath,
environment,
workspaceId
});
handlePopUpClose("addFolder");
createNotification({
type: "success",
text: "Successfully created folder"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to create folder"
});
}
};
const handleSecretDownload = async () => {
const secPriority: Record<string, boolean> = {};
const downloadedSecrets: Array<{ key: string; value: string; comment?: string }> = [];
// load up secrets in dashboard
secrets?.forEach(({ key, value, comment }) => {
secPriority[key] = true;
downloadedSecrets.push({ key, value, comment });
});
// now load imported secrets with secPriority
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
importedSecrets[i].secrets.forEach(({ key, value, comment }) => {
if (secPriority?.[key]) return;
downloadedSecrets.unshift({ key, value, comment });
secPriority[key] = true;
});
}
const file = downloadedSecrets
.sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase()))
.reduce(
(prev, { key, value, comment }, index) =>
prev +
(comment
? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n`
: `${key}=${value}\n`),
""
);
const blob = new Blob([file], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, `${environment}.env`);
};
const handleSecretBulkDelete = async () => {
const bulkDeletedSecrets = secrets.filter(({ _id }) => Boolean(selectedSecrets?.[_id]));
try {
await deleteBatchSecretV3({
secretPath,
workspaceId,
environment,
secrets: bulkDeletedSecrets.map(({ key }) => ({ secretName: key, type: "shared" }))
});
handlePopUpClose("bulkDeleteSecrets");
createNotification({
type: "success",
text: "Successfully deleted secrets"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to delete secrets"
});
}
};
return (
<>
<div className="flex items-center space-x-2 mt-4">
<div className="w-2/5">
<Input
className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by folder name, key name, comment..."
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
value={filter.searchFilter}
onChange={(evt) => onSearchChange(evt.target.value)}
/>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
variant="outline_bg"
ariaLabel="Download"
className={twMerge(
"transition-all",
Object.keys(filter.tags).length && "text-primary border-primary/50"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0">
<DropdownMenuGroup>Group By</DropdownMenuGroup>
<DropdownMenuItem
iconPos="right"
icon={
filter?.groupBy === GroupBy.PREFIX && <FontAwesomeIcon icon={faCheckCircle} />
}
onClick={() => onGroupByChange(!filter.groupBy ? GroupBy.PREFIX : undefined)}
>
Prefix
</DropdownMenuItem>
<DropdownMenuGroup>Filter By</DropdownMenuGroup>
<DropdownSubMenu>
<DropdownSubMenuTrigger
iconPos="right"
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
>
Tags
</DropdownSubMenuTrigger>
<DropdownSubMenuContent className="rounded-l-none">
<DropdownMenuLabel>Apply tags to filter secrets</DropdownMenuLabel>
{tags.map(({ _id, name, tagColor }) => (
<DropdownMenuItem
onClick={() => onToggleTagFilter(_id)}
key={_id}
icon={filter?.tags[_id] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="w-2 h-2 rounded-full mr-2"
style={{ background: tagColor || "#bec2c8" }}
/>
{name}
</div>
</DropdownMenuItem>
))}
</DropdownSubMenuContent>
</DropdownSubMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex-grow" />
<div>
<IconButton variant="outline_bg" ariaLabel="Download" onClick={handleSecretDownload}>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</div>
<div>
<IconButton variant="outline_bg" ariaLabel="Reveal" onClick={onVisiblilityToggle}>
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
</IconButton>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SecretRollback}
>
{(isAllowed) => (
<Button
variant="outline_bg"
onClick={() => {
if (subscription && subscription.pitRecovery) {
onClickRollbackMode();
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
isLoading={isSnapshotCountLoading}
className="h-10"
isDisabled={!isAllowed}
>
{snapshotCount} Commits
</Button>
)}
</ProjectPermissionCan>
</div>
<div className="flex items-center">
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addSecret")}
className="rounded-r-none h-10"
isDisabled={!isAllowed}
>
Add Secret
</Button>
)}
</ProjectPermissionCan>
<DropdownMenu
open={popUp.misc.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="add-folder-or-import"
variant="outline_bg"
className="rounded-l-none bg-mineshaft-600 p-3"
>
<FontAwesomeIcon icon={faAnglesDown} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="p-2 flex flex-col space-y-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
onClick={() => {
handlePopUpOpen("addFolder");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10"
isFullWidth
>
Add Folder
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFileImport} />}
onClick={() => {
handlePopUpOpen("addSecretImport");
handlePopUpClose("misc");
}}
variant="outline_bg"
className="h-10"
isFullWidth
isDisabled={!isAllowed}
>
Add Import
</Button>
)}
</ProjectPermissionCan>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
className={twMerge(
"overflow-hidden transition-all h-0 flex-shrink-0",
isMultiSelectActive && "h-16"
)}
>
<div className="text-bunker-300 flex items-center bg-mineshaft-800 mt-4 p-2 pl-4 rounded-md">
<Tooltip content="Clear">
<IconButton variant="plain" ariaLabel="clear-selection" onClick={onResetSelectedSecret}>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
</Tooltip>
<div className="text-sm ml-4 px-2 border-x border-mineshaft-600">
{Object.keys(selectedSecrets).length} Secrets
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
variant="plain"
ariaLabel="delete"
className="ml-4 text-red-700"
onClick={() => handlePopUpOpen("bulkDeleteSecrets")}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
{/* all the side triggers from actions like modals etc */}
<CreateSecretForm
secrets={secrets}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
decryptFileKey={decryptFileKey!}
isOpen={popUp.addSecret.isOpen}
onClose={() => handlePopUpClose("addSecret")}
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecret", isOpen)}
/>
<CreateSecretImportForm
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isOpen={popUp.addSecretImport.isOpen}
onClose={() => handlePopUpClose("addSecretImport")}
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
/>
<Modal
isOpen={popUp.addFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
>
<ModalContent title="Create Folder">
<FolderForm onCreateFolder={handleFolderCreate} />
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.bulkDeleteSecrets.isOpen}
deleteKey="delete"
title="Do you want to delete these secrets?"
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
onDeleteApproved={handleSecretBulkDelete}
/>
{subscription && (
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={
subscription.slug === null
? "You can perform point-in-time recovery under an Enterprise license"
: "You can perform point-in-time recovery if you switch to Infisical's Team plan"
}
/>
)}
</>
);
};
ActionBar.displayName = "ActionBar";
@@ -0,0 +1,126 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, FormControl, Input, Modal, ModalContent, SecretInput } from "@app/components/v2";
import { useCreateSecretV3 } from "@app/hooks/api";
import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types";
const typeSchema = z.object({
key: z.string(),
value: z.string()
});
type TFormSchema = z.infer<typeof typeSchema>;
type Props = {
secrets?: DecryptedSecret[];
environment: string;
workspaceId: string;
decryptFileKey: UserWsKeyPair;
secretPath?: string;
// modal props
isOpen?: boolean;
onClose: () => void;
onTogglePopUp: (isOpen: boolean) => void;
};
export const CreateSecretForm = ({
environment,
workspaceId,
decryptFileKey,
secretPath = "/",
isOpen,
onClose,
onTogglePopUp
}: Props) => {
const {
register,
handleSubmit,
control,
reset,
formState: { errors, isSubmitting }
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
const { createNotification } = useNotificationContext();
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const handleFormSubmit = async ({ key, value }: TFormSchema) => {
try {
await createSecretV3({
environment,
workspaceId,
secretPath,
secretName: key,
secretValue: value,
secretComment: "",
type: "shared",
latestFileKey: decryptFileKey
});
onClose();
reset();
createNotification({
type: "success",
text: "Successfully created secret"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to create secret"
});
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
<ModalContent
title="Create secret"
subTitle="Add a secret to the particular environment and folder"
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<FormControl label="Key" isError={Boolean(errors?.key)} errorText={errors?.key?.message}>
<Input {...register("key")} placeholder="Type your secret name" />
</FormControl>
<Controller
control={control}
name="value"
render={({ field }) => (
<FormControl
label="Value"
isError={Boolean(errors?.value)}
errorText={errors?.value?.message}
>
<SecretInput
{...field}
className="bg-mineshaft-900 text-gray-400 hover:border-primary-400/50 focus:border px-2 py-1.5"
/>
</FormControl>
)}
/>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="layout-create-project-submit"
className="mr-4"
type="submit"
>
Create Secret
</Button>
<Button
key="layout-cancel-create-project"
onClick={onClose}
variant="plain"
colorSchema="secondary"
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,152 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useCreateSecretImport } from "@app/hooks/api";
const typeSchema = z.object({
environment: z.string().trim(),
secretPath: z
.string()
.trim()
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
)
});
type TFormSchema = z.infer<typeof typeSchema>;
type Props = {
environment: string;
workspaceId: string;
secretPath?: string;
// modal props
isOpen?: boolean;
onClose: () => void;
onTogglePopUp: (isOpen: boolean) => void;
};
export const CreateSecretImportForm = ({
environment,
workspaceId,
secretPath = "/",
isOpen,
onClose,
onTogglePopUp
}: Props) => {
const {
handleSubmit,
control,
reset,
formState: { isSubmitting }
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const { createNotification } = useNotificationContext();
const { mutateAsync: createSecretImport } = useCreateSecretImport();
const handleFormSubmit = async ({
environment: importedEnv,
secretPath: importedSecPath
}: TFormSchema) => {
try {
await createSecretImport({
environment,
workspaceId,
directory: secretPath,
secretImport: {
environment: importedEnv,
secretPath: importedSecPath
}
});
onClose();
reset();
createNotification({
type: "success",
text: "Successfully linked"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to link secrets"
});
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
<ModalContent
title="Add Secret Link"
subTitle="To inherit secrets from another environment or folder"
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="layout-create-project-submit"
className="mr-4"
type="submit"
>
Create Link
</Button>
<Button
key="layout-cancel-create-project"
onClick={onClose}
variant="plain"
colorSchema="secondary"
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,73 @@
import { Controller, useForm } from "react-hook-form";
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>;
isEdit?: boolean;
defaultFolderName?: string;
};
const formSchema = z.object({
name: z
.string()
.trim()
.regex(/^[a-zA-Z0-9-_]+$/, "Folder name cannot contain spaces. Only underscore and dashes")
});
type TFormData = z.infer<typeof formSchema>;
export const FolderForm = ({
isEdit,
defaultFolderName,
onCreateFolder,
onUpdateFolder
}: Props): JSX.Element => {
const {
control,
reset,
formState: { isSubmitting },
handleSubmit
} = useForm<TFormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: defaultFolderName
}
});
const onSubmit = async ({ name }: TFormData) => {
if (isEdit) {
await onUpdateFolder?.(name);
} else {
await onCreateFolder?.(name);
}
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl label="Folder Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="Type your folder name" />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
{isEdit ? "Save" : "Create"}
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
@@ -0,0 +1 @@
export { ActionBar } from "./ActionBar";
@@ -0,0 +1,188 @@
import { useRouter } from "next/router";
import { subject } from "@casl/ability";
import { faClose, faFolder, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { SortDir } from "../../SecretMainPage.types";
import { FolderForm } from "../ActionBar/FolderForm";
type Props = {
folders?: TSecretFolder[];
environment: string;
workspaceId: string;
secretPath?: string;
sortDir: SortDir;
};
export const FolderListView = ({
folders = [],
environment,
workspaceId,
secretPath = "/",
sortDir = SortDir.ASC
}: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"updateFolder",
"deleteFolder"
] as const);
const router = useRouter();
const { createNotification } = useNotificationContext();
const { mutateAsync: updateFolder } = useUpdateFolder();
const { mutateAsync: deleteFolder } = useDeleteFolder();
const handleFolderUpdate = async (newFolderName: string) => {
try {
await updateFolder({
folderName: popUp.updateFolder.data as string,
name: newFolderName,
directory: secretPath,
environment,
workspaceId
});
handlePopUpClose("updateFolder");
createNotification({
type: "success",
text: "Successfully saved folder"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to save folder"
});
}
};
const handleFolderDelete = async () => {
try {
await deleteFolder({
folderName: popUp.deleteFolder.data as string,
directory: secretPath,
environment,
workspaceId
});
handlePopUpClose("deleteFolder");
createNotification({
type: "success",
text: "Successfully deleted folder"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to delete folder"
});
}
};
const handleFolderClick = (name: string) => {
router.push({
pathname: router.pathname,
query: {
...router.query,
secretPath: `${router.query?.secretPath || ""}/${name}`
}
});
};
return (
<>
{folders
.sort((a, b) =>
sortDir === SortDir.ASC
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
)
.map(({ name, id }) => (
<div
key={id}
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
>
<div className="w-12 px-4 py-2 text-yellow-700 flex items-center">
<FontAwesomeIcon icon={faFolder} />
</div>
<div
className="flex-grow px-4 py-2 flex items-center"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") handleFolderClick(name);
}}
onClick={() => handleFolderClick(name)}
>
{name}
</div>
<div className="px-3 py-2 flex items-center space-x-4 border-l border-mineshaft-600">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="group-hover:opacity-100 opacity-0 p-0"
onClick={() => handlePopUpOpen("updateFolder", name)}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-folder"
variant="plain"
size="md"
className="group-hover:opacity-100 opacity-0 p-0"
onClick={() => handlePopUpOpen("deleteFolder", name)}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
))}
<Modal
isOpen={popUp.updateFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("updateFolder", isOpen)}
>
<ModalContent title="Create Folder">
<FolderForm
isEdit
defaultFolderName={popUp.updateFolder.data as string}
onUpdateFolder={handleFolderUpdate}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deleteFolder.isOpen}
deleteKey={popUp.deleteFolder?.data as string}
title="Do you want to delete this folder?"
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
onDeleteApproved={handleFolderDelete}
/>
</>
);
};
@@ -0,0 +1 @@
export { FolderListView } from "./FolderListView";
@@ -0,0 +1,83 @@
import { Fragment, useCallback } from "react";
import { InfiniteData } from "@tanstack/react-query";
import { Button, Drawer, DrawerContent } from "@app/components/v2";
import timeSince from "@app/ee/utilities/timeSince";
import { TSecretSnapshot } from "@app/hooks/api/secretSnapshots/types";
type Props = {
isDrawerOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
secretSnaphots?: InfiniteData<TSecretSnapshot[]>;
onSelectSnapshot: (id: string) => void;
snapshotId: string | null;
isFetchingNextPage?: boolean;
hasNextPage?: boolean;
fetchNextPage: () => void;
};
export const PitDrawer = ({
isDrawerOpen,
onOpenChange,
secretSnaphots,
onSelectSnapshot,
snapshotId,
isFetchingNextPage,
hasNextPage,
fetchNextPage
}: Props): JSX.Element => {
const getButtonLabel = useCallback((isFirstChild: boolean, isSelectedSnapshot: boolean) => {
if (isFirstChild) return "Current Version";
if (isSelectedSnapshot) return "Currently Viewing";
return "Explore";
}, []);
return (
<Drawer isOpen={isDrawerOpen} onOpenChange={onOpenChange}>
<DrawerContent
title="Point-in-time Recovery"
subTitle="Note: This will recover secrets for all environments in this project"
>
<div className="flex flex-col space-y-2">
{secretSnaphots?.pages?.map((group, i) => (
<Fragment key={`snapshot-item-${i + 1}`}>
{group.map(({ _id, createdAt }, index) => (
<Button
key={_id}
className="py-3 px-4 text-sm"
isFullWidth
colorSchema={
(i === 0 && index === 0 && snapshotId === null) || snapshotId === _id
? "primary"
: "secondary"
}
variant={
(i === 0 && index === 0 && snapshotId === null) || snapshotId === _id
? "selected"
: "star"
}
onClick={() => onSelectSnapshot(_id)}
>
<div className="flex w-full justify-between">
<div>{timeSince(new Date(createdAt))}</div>
<div>{getButtonLabel(i === 0 && index === 0, snapshotId === _id)}</div>
</div>
</Button>
))}
</Fragment>
))}
</div>
<Button
className="mt-8 py-3 px-4 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={fetchNextPage}
>
{hasNextPage ? "Load More" : "End of history"}
</Button>
</DrawerContent>
</Drawer>
);
};
@@ -0,0 +1 @@
export { PitDrawer } from "./PitDrawer";
@@ -0,0 +1,284 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import {
faClone,
faKey,
faSearch,
faSquareCheck,
faSquareXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
Checkbox,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
ModalTrigger,
Select,
SelectItem,
Skeleton,
Tooltip
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useDebounce } from "@app/hooks";
import { useGetProjectSecrets } from "@app/hooks/api";
import { UserWsKeyPair } from "@app/hooks/api/types";
const formSchema = z.object({
environment: z.string().trim(),
secretPath: z
.string()
.trim()
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
),
secrets: z.record(z.string())
});
type TFormSchema = z.infer<typeof formSchema>;
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
onParsedEnv: (env: Record<string, { value: string; comments: string[] }>) => void;
environments?: { name: string; slug: string }[];
workspaceId: string;
decryptFileKey: UserWsKeyPair;
environment: string;
secretPath: string;
};
export const CopySecretsFromBoard = ({
environments = [],
workspaceId,
decryptFileKey,
environment,
secretPath,
isOpen,
onToggle,
onParsedEnv
}: Props) => {
const [searchFilter, setSearchFilter] = useState("");
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
const {
handleSubmit,
control,
watch,
register,
reset,
setValue,
formState: { isDirty }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
});
const envCopySecPath = watch("secretPath");
const selectedEnvSlug = watch("environment");
const debouncedEnvCopySecretPath = useDebounce(envCopySecPath);
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
workspaceId,
environment: selectedEnvSlug,
secretPath: debouncedEnvCopySecretPath,
decryptFileKey,
options: {
enabled:
Boolean(workspaceId) &&
Boolean(selectedEnvSlug) &&
Boolean(debouncedEnvCopySecretPath) &&
isOpen
}
});
useEffect(() => {
setValue("secrets", {});
setSearchFilter("");
}, [debouncedEnvCopySecretPath]);
const handleSecSelectAll = () => {
if (secrets) {
setValue(
"secrets",
secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}),
{ shouldDirty: true }
);
}
};
const handleFormSubmit = async (data: TFormSchema) => {
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
Object.keys(data.secrets || {}).forEach((key) => {
if (data.secrets[key]) {
secretsToBePulled[key] = {
value: (shouldIncludeValues && data.secrets[key]) || "",
comments: [""]
};
}
});
onParsedEnv(secretsToBePulled);
onToggle(false);
reset();
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(state) => {
onToggle(state);
reset();
setSearchFilter("");
}}
>
<ModalTrigger asChild>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button
onClick={() => onToggle(true)}
isDisabled={!isAllowed}
variant="star"
size="xs"
>
Copy Secrets From An Environment
</Button>
)}
</ProjectPermissionCan>
</div>
</ModalTrigger>
<ModalContent
className="max-w-2xl"
title="Copy Secret From An Environment"
subTitle="Copy/paste secrets from other environments into this context"
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="environment"
render={({ field: { value, onChange } }) => (
<FormControl label="Environment" isRequired className="w-1/3">
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
defaultValue={environments?.[0]?.slug}
position="popper"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<FormControl label="Secret Path" className="flex-grow" isRequired>
<Input {...register("secretPath")} placeholder="Provide a path, default is /" />
</FormControl>
</div>
<div className="border-t border-mineshaft-600 pt-4">
<div className="mb-4 flex items-center justify-between">
<div>Secrets</div>
<div className="w-1/2 flex items-center space-x-2">
<Input
placeholder="Search for secret"
value={searchFilter}
size="xs"
leftIcon={<FontAwesomeIcon icon={faSearch} />}
onChange={(evt) => setSearchFilter(evt.target.value)}
/>
<Tooltip content="Select All">
<IconButton
ariaLabel="Select all"
variant="outline_bg"
size="xs"
onClick={handleSecSelectAll}
>
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
</IconButton>
</Tooltip>
<Tooltip content="Unselect All">
<IconButton
ariaLabel="UnSelect all"
variant="outline_bg"
size="xs"
onClick={() => reset()}
>
<FontAwesomeIcon icon={faSquareXmark} size="lg" />
</IconButton>
</Tooltip>
</div>
</div>
{!isSecretsLoading && !secrets?.length && (
<EmptyState title="No secrets found" icon={faKey} />
)}
<div className="grid grid-cols-2 gap-4 max-h-64 overflow-auto thin-scrollbar ">
{isSecretsLoading &&
Array.apply(0, Array(2)).map((_x, i) => (
<Skeleton key={`secret-pull-loading-${i + 1}`} className="bg-mineshaft-700" />
))}
{secrets
?.filter(({ key }) => key.toLowerCase().includes(searchFilter.toLowerCase()))
?.map(({ _id, key, value: secVal }) => (
<Controller
key={`pull-secret--${_id}`}
control={control}
name={`secrets.${key}`}
render={({ field: { value, onChange } }) => (
<Checkbox
id={`pull-secret-${_id}`}
isChecked={Boolean(value)}
onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
>
{key}
</Checkbox>
)}
/>
))}
</div>
<div className="mt-6 mb-4">
<Checkbox
id="populate-include-value"
isChecked={shouldIncludeValues}
onCheckedChange={(isChecked) => setShouldIncludeValues(isChecked as boolean)}
>
Include secret values
</Checkbox>
</div>
<div className="flex items-center space-x-2">
<Button
leftIcon={<FontAwesomeIcon icon={faClone} />}
type="submit"
isDisabled={!isDirty}
>
Paste Secrets
</Button>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</div>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,338 @@
import { ChangeEvent, DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability";
import { faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
import { parseDotEnv } from "@app/components/utilities/parseDotEnv";
import { Button, Modal, ModalContent } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
import { secretKeys } from "@app/hooks/api/secrets/queries";
import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types";
import { CopySecretsFromBoard } from "./CopySecretsFromBoard";
const parseJson = (src: ArrayBuffer) => {
const file = src.toString();
const formatedData: Record<string, string> = JSON.parse(file);
const env: Record<string, { value: string; comments: string[] }> = {};
Object.keys(formatedData).forEach((key) => {
if (typeof formatedData[key] === "string") {
env[key] = { value: formatedData[key], comments: [] };
}
});
return env;
};
type TParsedEnv = Record<string, { value: string; comments: string[] }>;
type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv };
type Props = {
isSmaller: boolean;
environments?: { name: string; slug: string }[];
workspaceId: string;
decryptFileKey: UserWsKeyPair;
environment: string;
secretPath: string;
secrets?: DecryptedSecret[];
};
export const SecretDropzone = ({
isSmaller,
environments = [],
workspaceId,
decryptFileKey,
environment,
secretPath,
secrets = []
}: Props): JSX.Element => {
const { t } = useTranslation();
const [isDragActive, setDragActive] = useToggle();
const [isLoading, setIsLoading] = useToggle();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"importSecEnv",
"overlapKeyWarning"
] as const);
const queryClient = useQueryClient();
const { mutateAsync: updateSecretBatch, isLoading: isUpdatingSecrets } = useUpdateSecretBatch({
options: { onSuccess: undefined }
});
const { mutateAsync: createSecretBatch, isLoading: isCreatingSecrets } = useCreateSecretBatch({
options: { onSuccess: undefined }
});
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
const handleDrag = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive.on();
} else if (e.type === "dragleave") {
setDragActive.off();
}
};
const handleParsedEnv = (env: TParsedEnv) => {
const secretsGroupedByKey = secrets?.reduce<Record<string, boolean>>(
(prev, curr) => ({ ...prev, [curr.key]: true }),
{}
);
const overlappedSecrets = Object.keys(env)
.filter((secKey) => secretsGroupedByKey?.[secKey])
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
const nonOverlappedSecrets = Object.keys(env)
.filter((secKey) => !secretsGroupedByKey?.[secKey])
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
if (!Object.keys(overlappedSecrets).length && !Object.keys(nonOverlappedSecrets).length) {
createNotification({
type: "error",
text: "Failed to find secrets"
});
return;
}
handlePopUpOpen("overlapKeyWarning", {
update: overlappedSecrets,
create: nonOverlappedSecrets
});
};
const parseFile = (file?: File, isJson?: boolean) => {
const reader = new FileReader();
if (!file) {
createNotification({
text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.",
type: "error",
timeoutMs: 10000
});
return;
}
// const fileType = file.name.split('.')[1];
setIsLoading.on();
reader.onload = (event) => {
if (!event?.target?.result) return;
// parse function's argument looks like to be ArrayBuffer
const env = isJson
? parseJson(event.target.result as ArrayBuffer)
: parseDotEnv(event.target.result as ArrayBuffer);
setIsLoading.off();
handleParsedEnv(env);
};
// If something is wrong show an error
try {
reader.readAsText(file);
} catch (error) {
console.log(error);
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!e.dataTransfer) {
return;
}
e.dataTransfer.dropEffect = "copy";
setDragActive.off();
parseFile(e.dataTransfer.files[0]);
};
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json");
};
const handleSaveSecrets = async () => {
const { update, create } = popUp?.overlapKeyWarning?.data as TSecOverwriteOpt;
try {
if (Object.keys(create || {}).length) {
await createSecretBatch({
latestFileKey: decryptFileKey!,
secretPath,
workspaceId,
environment,
secrets: Object.entries(create).map(([secretName, secData]) => ({
type: "shared",
secretComment: secData.comments.join("\n"),
secretValue: secData.value,
secretName
}))
});
}
if (Object.keys(update || {}).length) {
await updateSecretBatch({
latestFileKey: decryptFileKey!,
secretPath,
workspaceId,
environment,
secrets: Object.entries(update).map(([secretName, secData]) => ({
type: "shared",
secretComment: secData.comments.join("\n"),
secretValue: secData.value,
secretName
}))
});
}
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
handlePopUpClose("overlapKeyWarning");
createNotification({
type: "success",
text: "Successfully uploaded secrets"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to upload secrets"
});
}
};
const isUploadedDuplicateSecretsEmpty = !Object.keys(
(popUp.overlapKeyWarning?.data as TSecOverwriteOpt)?.update || {}
).length;
return (
<div>
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={twMerge(
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 py-4 text-sm px-2 text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100",
isDragActive && "opacity-100",
!isSmaller && "w-full max-w-3xl flex-col space-y-4 py-20 mx-auto",
isLoading && "bg-bunker-800"
)}
>
{isLoading ? (
<div className="mb-16 flex items-center justify-center pt-16">
<img
src="/images/loading/loading.gif"
height={70}
width={120}
alt="loading animation"
/>
</div>
) : (
<div className="flex items-center justify-cente flex-col space-y-2">
<div>
<FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} />
</div>
<div>
<p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<input
id="fileSelect"
disabled={!isAllowed}
type="file"
className="absolute h-full w-full cursor-pointer opacity-0"
accept=".txt,.env,.yml,.yaml,.json"
onChange={handleFileUpload}
/>
)}
</ProjectPermissionCan>
<div
className={twMerge(
"flex w-full flex-row items-center justify-center py-4",
isSmaller && "py-1"
)}
>
<div className="w-1/5 border-t border-mineshaft-700" />
<p className="mx-4 text-xs text-mineshaft-400">OR</p>
<div className="w-1/5 border-t border-mineshaft-700" />
</div>
<div className="flex items-center justify-center space-x-8">
<CopySecretsFromBoard
isOpen={popUp.importSecEnv.isOpen}
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
onParsedEnv={handleParsedEnv}
environment={environment}
environments={environments}
workspaceId={workspaceId}
decryptFileKey={decryptFileKey}
secretPath={secretPath}
/>
{!isSmaller && (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button variant="star" isDisabled={!isAllowed}>
Add a new secret
</Button>
)}
</ProjectPermissionCan>
)}
</div>
</div>
)}
</div>
<Modal
isOpen={popUp?.overlapKeyWarning?.isOpen}
onOpenChange={(open) => handlePopUpToggle("overlapKeyWarning", open)}
>
<ModalContent
title={isUploadedDuplicateSecretsEmpty ? "Confirmation" : "Duplicate Secrets!!"}
footerContent={[
<Button
key="keep-old-btn"
className="mr-4"
onClick={() => handlePopUpClose("overlapKeyWarning")}
variant="outline_bg"
isDisabled={isSubmitting}
>
Keep old
</Button>,
<Button
isLoading={isSubmitting}
isDisabled={isSubmitting}
colorSchema={isUploadedDuplicateSecretsEmpty ? "primary" : "danger"}
key="overwrite-btn"
onClick={handleSaveSecrets}
>
{isUploadedDuplicateSecretsEmpty ? "Create" : "Overwrite"}
</Button>
]}
>
{isUploadedDuplicateSecretsEmpty ? (
<div>Uploaded secrets will be created.</div>
) : (
<div className="flex flex-col space-y-2 text-gray-300">
<div>Your file contains following duplicate secrets</div>
<div className="text-sm text-gray-400">
{Object.keys((popUp?.overlapKeyWarning?.data as TSecOverwriteOpt)?.update || {})
?.map((key) => key)
.join(", ")}
</div>
<div>Are you sure you want to overwrite these secrets and create other ones?</div>
</div>
)}
</ModalContent>
</Modal>
</div>
);
};
@@ -0,0 +1 @@
export { SecretDropzone } from "./SecretDropzone";
@@ -0,0 +1,190 @@
import { useEffect } from "react";
import { subject } from "@casl/ability";
import { useSortable } from "@dnd-kit/sortable";
import {
faClose,
faFileImport,
faFolder,
faKey,
faUpDown
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, IconButton, SecretInput, TableContainer } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
type Props = {
onDelete: (environment: string, secretPath: string) => void;
environment: string;
secretPath?: string;
importedEnv: string;
importedSecPath: string;
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
searchTerm: string;
};
// to show the environment and folder icon
export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => (
<div className="inline-flex items-center space-x-2">
<div style={{ minWidth: "96px" }}>{env || "-"}</div>
{secretPath && (
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
<FontAwesomeIcon icon={faFolder} className="text-green-700 text-md" />
<span>{secretPath}</span>
</div>
)}
</div>
);
export const SecretImportItem = ({
importedEnv,
importedSecPath,
onDelete,
importedSecrets = [],
searchTerm = "",
secretPath,
environment
}: Props) => {
const [isExpanded, setIsExpanded] = useToggle();
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
id: `${importedEnv}-${importedSecPath}`
});
const { currentWorkspace } = useWorkspace();
const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv);
useEffect(() => {
const filteredSecrets = importedSecrets.filter((secret) =>
secret.key.toUpperCase().includes(searchTerm.toUpperCase())
);
if (filteredSecrets.length > 0 && searchTerm) {
setIsExpanded.on();
} else {
setIsExpanded.off();
}
}, [searchTerm]);
useEffect(() => {
if (isDragging) {
setIsExpanded.off();
}
}, [isDragging]);
const style = {
transform: transform ? `translateY(${transform.y ? Math.round(transform.y) : 0}px)` : "",
transition
};
return (
<>
<div
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
role="button"
ref={setNodeRef}
tabIndex={0}
style={style}
onClick={() => setIsExpanded.toggle()}
onKeyDown={() => setIsExpanded.toggle()}
>
<div className="w-12 px-4 py-2 flex items-center text-green-700">
<FontAwesomeIcon icon={faFileImport} />
</div>
<div className="flex-grow px-4 py-2 flex items-center">
<EnvFolderIcon env={rowEnv?.name || ""} secretPath={importedSecPath} />
</div>
<div className="px-4 py-2 flex items-center space-x-4 border-l border-mineshaft-600">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Change order"
>
{(isAllowed) => (
<IconButton
size="md"
colorSchema="primary"
variant="plain"
ariaLabel="expand"
className="group-hover:opacity-100 opacity-0 p-0"
{...attributes}
{...listeners}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faUpDown} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
size="md"
variant="plain"
colorSchema="danger"
ariaLabel="delete"
className="group-hover:opacity-100 opacity-0 p-0"
onClick={(evt) => {
evt.stopPropagation();
onDelete(importedEnv, importedSecPath);
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
{isExpanded && !isDragging && (
<td
colSpan={3}
className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
>
<div className="rounded-md bg-bunker-700 p-1">
<TableContainer>
<table className="secret-table">
<thead>
<tr>
<td style={{ padding: "0.25rem 1rem" }}>Key</td>
<td style={{ padding: "0.25rem 1rem" }}>Value</td>
<td style={{ padding: "0.25rem 1rem" }}>Override</td>
</tr>
</thead>
<tbody>
{importedSecrets?.length === 0 && (
<tr>
<td colSpan={3}>
<EmptyState title="No secrets found" icon={faKey} />
</td>
</tr>
)}
{importedSecrets
.filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase()))
.map(({ key, value, overriden }, index) => (
<tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
{key}
</td>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<SecretInput value={value} isReadOnly />
</td>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
</td>
</tr>
))}
</tbody>
</table>
</TableContainer>
</div>
</td>
)}
</>
);
};
@@ -0,0 +1,218 @@
import { useEffect, useState } from "react";
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors
} from "@dnd-kit/core";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { DeleteActionModal } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api";
import { TSecretImports } from "@app/hooks/api/secretImports/types";
import { DecryptedSecret } from "@app/hooks/api/types";
import { SecretImportItem } from "./SecretImportItem";
const SECRET_IN_DASHBOARD = "Present In Dashboard";
type TImportedSecrets = Array<{
environment: string;
secretPath: string;
folderId: string;
secrets: DecryptedSecret[];
}>;
export const computeImportedSecretRows = (
importedSecEnv: string,
importedSecPath: string,
importSecrets: TImportedSecrets = [],
secrets: DecryptedSecret[] = [],
environments: { name: string; slug: string }[] = []
) => {
const importedSecIndex = importSecrets.findIndex(
({ secretPath, environment }) =>
secretPath === importedSecPath && importedSecEnv === environment
);
if (importedSecIndex === -1) return [];
const importedSec = importSecrets[importedSecIndex];
const overridenSec: Record<string, { env: string; secretPath: string }> = {};
const envSlug2Name: Record<string, string> = {};
environments.forEach((el) => {
envSlug2Name[el.slug] = el.name;
});
for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) {
importSecrets[i].secrets.forEach((el) => {
overridenSec[el.key] = {
env: envSlug2Name?.[importSecrets[i].environment] || "unknown",
secretPath: importSecrets[i].secretPath
};
});
}
secrets.forEach((el) => {
overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" };
});
return importedSec.secrets.map(({ key, value }) => ({
key,
value,
overriden: overridenSec?.[key]
}));
};
type Props = {
environment: string;
workspaceId: string;
secretPath?: string;
secretImports?: TSecretImports;
isFetching?: boolean;
secrets?: DecryptedSecret[];
importedSecrets?: TImportedSecrets;
searchTerm: string;
};
type TDeleteSecretImport = { environment: string; secretPath: string };
export const SecretImportListView = ({
secretImports,
environment,
workspaceId,
secretPath,
importedSecrets,
secrets = [],
isFetching,
searchTerm
}: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteSecretImport"
] as const);
const { currentWorkspace } = useWorkspace();
const { createNotification } = useNotificationContext();
const environments = currentWorkspace?.environments || [];
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
);
const [items, setItems] = useState(
(secretImports?.imports || [])?.map((dto) => ({
id: `${dto.environment}-${dto.secretPath}`,
...dto
}))
);
useEffect(() => {
if (!isFetching) {
setItems(
(secretImports?.imports || [])?.map((dto) => ({
id: `${dto.environment}-${dto.secretPath}`,
...dto
}))
);
}
}, [isFetching]);
const { mutateAsync: deleteSecretImport } = useDeleteSecretImport();
const { mutate: updateSecretImport } = useUpdateSecretImport();
const handleSecretImportDelete = async () => {
const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport
?.data as TDeleteSecretImport;
try {
if (secretImports?._id) {
await deleteSecretImport({
workspaceId,
environment,
directory: secretPath,
id: secretImports?._id,
secretImportEnv: importEnv,
secretImportPath: impSecPath
});
handlePopUpClose("deleteSecretImport");
createNotification({
type: "success",
text: "Successfully removed secret link"
});
}
} catch (err) {
console.error(err);
createNotification({
text: "Failed to remove secret link",
type: "error"
});
}
};
const handleSecretImportReorder = ({ over, active }: DragEndEvent) => {
if (active.id !== over?.id) {
const oldIndex = items.findIndex(({ id }) => id === active.id);
const newIndex = items.findIndex(({ id }) => id === over?.id);
const newImportOrder = arrayMove(items, oldIndex, newIndex);
setItems(newImportOrder);
updateSecretImport({
workspaceId,
environment,
directory: secretPath,
id: secretImports?._id || "",
secretImports: newImportOrder
});
}
};
return (
<>
<DndContext
onDragEnd={handleSecretImportReorder}
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items?.map(({ secretPath: importedSecPath, environment: importedEnv }) => (
<SecretImportItem
searchTerm={searchTerm}
key={`${importedEnv}-${importedSecPath}`}
importedEnv={importedEnv}
importedSecPath={importedSecPath}
importedSecrets={computeImportedSecretRows(
importedEnv,
importedSecPath,
importedSecrets,
secrets,
environments
)}
secretPath={secretPath}
environment={environment}
onDelete={(env, secPath) =>
handlePopUpOpen("deleteSecretImport", { environment: env, secretPath: secPath })
}
/>
))}
</SortableContext>
</DndContext>
<DeleteActionModal
isOpen={popUp.deleteSecretImport.isOpen}
deleteKey="unlink"
title="Do you want to remove this secret import?"
subTitle={`This will unlink secrets from environment ${
(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.environment
} of path ${(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.secretPath}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
onDeleteApproved={handleSecretImportDelete}
/>
</>
);
};
@@ -0,0 +1 @@
export { SecretImportListView } from "./SecretImportListView";
@@ -0,0 +1,52 @@
import crypto from "crypto";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { faMinus, faPlus, faShuffle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Input } from "@app/components/v2";
type Props = {
onGenerate: (val: string) => void;
};
export const GenRandomNumber = ({ onGenerate }: Props) => {
const { t } = useTranslation();
const [value, setValue] = useState(32);
const onGenerateRandomHex = () => {
const rand = crypto.randomBytes(value).toString("hex");
onGenerate(rand);
};
return (
<div className="flex">
<Button
className="rounded-r-none"
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faShuffle} />}
onClick={onGenerateRandomHex}
>
{t("dashboard.sidebar.generate-random-hex")}
</Button>
<div className="rounded-r-md bg-mineshaft p-2">
<div className="flex rounded-md border-mineshaft-400 bg-bunker-800 p-1 px-2">
<button type="button" onClick={() => setValue((val) => val - 1)}>
<FontAwesomeIcon icon={faMinus} size="xs" />
</button>
<Input
size="sm"
className="w-8 p-0 text-center"
value={value}
variant="plain"
onChange={(e) => setValue(parseInt(e.target.value, 10))}
/>
<button type="button" onClick={() => setValue((val) => val + 1)}>
<FontAwesomeIcon icon={faPlus} size="xs" />
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,389 @@
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import {
faCheckCircle,
faCircle,
faCircleDot,
faPlus,
faTag
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
Drawer,
DrawerContent,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
FormControl,
IconButton,
Input,
SecretInput,
Switch,
Tag,
TextArea
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { useGetSecretVersion } from "@app/hooks/api";
import { DecryptedSecret, UserWsKeyPair, WsTag } from "@app/hooks/api/types";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
type Props = {
isOpen?: boolean;
environment: string;
secretPath: string;
onToggle: (isOpen: boolean) => void;
onClose: () => void;
secret: DecryptedSecret;
decryptFileKey: UserWsKeyPair;
onDeleteSecret: () => void;
onSaveSecret: (
orgSec: DecryptedSecret,
modSec: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] }
) => Promise<void>;
tags: WsTag[];
onCreateTag: () => void;
};
export const SecretDetailSidebar = ({
isOpen,
onToggle,
decryptFileKey,
secret,
onDeleteSecret,
onSaveSecret,
tags,
onCreateTag,
environment,
secretPath
}: Props) => {
const { createNotification } = useNotificationContext();
const {
register,
control,
watch,
handleSubmit,
setValue,
reset,
formState: { isDirty, isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
values: secret
});
const permission = useProjectPermission();
const cannotEditSecret = permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
const isReadOnly =
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
) && cannotEditSecret;
const { fields, append, remove } = useFieldArray({
control,
name: "tags"
});
const selectedTags = watch("tags", []);
const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>(
(prev, curr) => ({ ...prev, [curr._id]: true }),
{}
);
const overrideAction = watch("overrideAction");
const isOverridden =
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
const { data: secretVersion } = useGetSecretVersion({
limit: 10,
offset: 0,
secretId: secret?._id,
decryptFileKey
});
const handleOverrideClick = () => {
if (isOverridden) {
// override need not be flagged delete if it was never saved in server
// meaning a new unsaved personal secret but user toggled back later
const isUnsavedOverride = !secret.idOverride;
setValue(
"overrideAction",
isUnsavedOverride ? secret?.overrideAction : SecretActionType.Deleted,
{
shouldDirty: !isUnsavedOverride
}
);
setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride });
} else {
setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true });
setValue("valueOverride", "", { shouldDirty: true });
}
};
const handleTagSelect = (tag: WsTag) => {
if (selectedTagsGroupById?.[tag._id]) {
const tagPos = selectedTags.findIndex(({ _id }) => _id === tag._id);
if (tagPos !== -1) {
remove(tagPos);
}
} else {
append(tag);
}
};
const handleFormSubmit = async (data: TFormSchema) => {
await onSaveSecret(secret, { ...secret, ...data });
};
return (
<Drawer
onOpenChange={(state) => {
if (isOpen && isDirty) {
if (
// eslint-disable-next-line no-alert
window.confirm("You have edited the secret. Are you sure you want to reset the change?")
) {
onToggle(false);
reset();
} else return;
}
onToggle(state);
}}
isOpen={isOpen}
>
<DrawerContent title="Secret">
<form onSubmit={handleSubmit(handleFormSubmit)} className="h-full">
<div className="flex flex-col h-full">
<FormControl label="Key">
<Input isDisabled {...register("key")} />
</FormControl>
<Controller
name="value"
key="secret-value"
control={control}
render={({ field }) => (
<FormControl label="Value">
<SecretInput
isReadOnly={isReadOnly}
key="secret-value"
isDisabled={isOverridden}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
{...field}
autoFocus={false}
/>
</FormControl>
)}
/>
<div className="mb-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Switch
isDisabled={!isAllowed}
id="personal-override"
onCheckedChange={handleOverrideClick}
isChecked={isOverridden}
>
Override with a personal value
</Switch>
)}
</ProjectPermissionCan>
</div>
{isOverridden && (
<Controller
name="valueOverride"
control={control}
render={({ field }) => (
<FormControl label="Value Override">
<SecretInput
isReadOnly={isReadOnly}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
{...field}
/>
</FormControl>
)}
/>
)}
<div className="my-2 mb-6 border-t border-mineshaft-600 pt-4">
<Controller
control={control}
name="skipMultilineEncoding"
render={({ field: { value, onChange, onBlur } }) => (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Switch
id="skipmultiencoding-option"
onCheckedChange={onChange}
isChecked={value}
onBlur={onBlur}
isDisabled={!isAllowed}
>
Disable multi line encoding
</Switch>
)}
</ProjectPermissionCan>
)}
/>
</div>
<FormControl label="Tags" className="">
<div className="overflow-hidden grid gap-2 grid-flow-col auto-cols-min pt-2">
{fields.map(({ tagColor, id: formId, name, _id }) => (
<Tag
className="flex items-center space-x-2 w-min"
key={formId}
onClose={() => {
if (cannotEditSecret) {
createNotification({ type: "error", text: "Access denied" });
return;
}
const tag = tags?.find(({ _id: id }) => id === _id);
if (tag) handleTagSelect(tag);
}}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tagColor || "#bec2c8" }}
/>
<div className="text-sm">{name}</div>
</Tag>
))}
<DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="add"
variant="outline_bg"
size="xs"
className="rounded-md"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</DropdownMenuTrigger>
)}
</ProjectPermissionCan>
<DropdownMenuContent align="end" className="z-[100]">
<DropdownMenuLabel>Apply tags to this secrets</DropdownMenuLabel>
{tags.map((tag) => {
const { _id: tagId, name, tagColor } = tag;
const isSelected = selectedTagsGroupById?.[tagId];
return (
<DropdownMenuItem
onClick={() => handleTagSelect(tag)}
key={tagId}
icon={isSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="w-2 h-2 rounded-full mr-2"
style={{ background: tagColor || "#bec2c8" }}
/>
{name}
</div>
</DropdownMenuItem>
);
})}
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Tags}
>
{(isAllowed) => (
<DropdownMenuItem>
<Button
size="xs"
className="w-full"
leftIcon={<FontAwesomeIcon icon={faTag} />}
onClick={onCreateTag}
isDisabled={!isAllowed}
>
Create a tag
</Button>
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
</FormControl>
<FormControl label="Comments & Notes">
<TextArea
className="border border-mineshaft-600 text-sm"
{...register("comment")}
readOnly={isReadOnly}
rows={5}
/>
</FormControl>
<div className="dark mb-4 text-sm text-bunker-300 flex-grow">
<div className="mb-2">Version History</div>
<div className="flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-bunker-800 p-2 dark:[color-scheme:dark]">
{secretVersion?.map(({ createdAt, value, id }, i) => (
<div key={id} className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<div>
<FontAwesomeIcon icon={i === 0 ? faCircleDot : faCircle} size="sm" />
</div>
<div>{format(new Date(createdAt), "Pp")}</div>
</div>
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
<div className="self-start rounded-sm bg-primary-500/30 px-1">Value:</div>
<div className="break-all font-mono">{value}</div>
</div>
</div>
))}
</div>
</div>
<div className="flex flex-col space-y-4">
<div className="flex space-x-4 items-center">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button
isFullWidth
type="submit"
isDisabled={isSubmitting || !isDirty || !isAllowed}
isLoading={isSubmitting}
>
Save Changes
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button colorSchema="danger" isDisabled={!isAllowed} onClick={onDeleteSecret}>
Delete
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
</div>
</form>
</DrawerContent>
</Drawer>
);
};
@@ -0,0 +1,481 @@
import { memo, useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faCheck,
faClose,
faCodeBranch,
faComment,
faCopy,
faEllipsis,
faKey,
faSave,
faTag,
faTags
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
FormControl,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
SecretInput,
Spinner,
TextArea,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { useToggle } from "@app/hooks";
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
import { WsTag } from "@app/hooks/api/types";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
type Props = {
secret: DecryptedSecret;
onSaveSecret: (
orgSec: DecryptedSecret,
modSec: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] }
) => Promise<void>;
onDeleteSecret: (sec: DecryptedSecret) => void;
onDetailViewSecret: (sec: DecryptedSecret) => void;
isVisible?: boolean;
isSelected?: boolean;
onToggleSecretSelect: (id: string) => void;
tags: WsTag[];
onCreateTag: () => void;
environment: string;
secretPath: string;
};
export const SecretItem = memo(
({
secret,
onSaveSecret,
onDeleteSecret,
onDetailViewSecret,
isVisible,
isSelected,
tags = [],
onCreateTag,
onToggleSecretSelect,
environment,
secretPath
}: Props) => {
const { currentWorkspace } = useWorkspace();
const permission = useProjectPermission();
const isReadOnly =
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
) &&
permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
const {
handleSubmit,
control,
register,
watch,
setValue,
reset,
getValues,
formState: { isDirty, isSubmitting }
} = useForm<TFormSchema>({
defaultValues: secret,
values: secret,
resolver: zodResolver(formSchema)
});
const overrideAction = watch("overrideAction");
const hasComment = Boolean(watch("comment"));
const selectedTags = watch("tags", []);
const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>(
(prev, curr) => ({ ...prev, [curr._id]: true }),
{}
);
const { fields, append, remove } = useFieldArray({
control,
name: "tags"
});
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isSecValueCopied) {
timer = setTimeout(() => setIsSecValueCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isSecValueCopied]);
const isOverriden =
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
const hasTagsApplied = Boolean(fields.length);
const handleOverrideClick = () => {
if (isOverriden) {
// override need not be flagged delete if it was never saved in server
// meaning a new unsaved personal secret but user toggled back later
const isUnsavedOverride = !secret.idOverride;
setValue(
"overrideAction",
isUnsavedOverride ? secret?.overrideAction : SecretActionType.Deleted,
{
shouldDirty: !isUnsavedOverride
}
);
setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride });
} else {
setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true });
setValue("valueOverride", "", { shouldDirty: true });
}
};
const handleFormSubmit = async (data: TFormSchema) => {
await onSaveSecret(secret, { ...secret, ...data });
};
const handleTagSelect = (tag: WsTag) => {
if (selectedTagsGroupById?.[tag._id]) {
const tagPos = selectedTags.findIndex(({ _id }) => _id === tag._id);
if (tagPos !== -1) {
remove(tagPos);
}
} else {
append(tag);
}
};
const copyTokenToClipboard = () => {
const [overrideValue, value] = getValues(["value", "valueOverride"]);
navigator.clipboard.writeText((overrideValue || value) as string);
setIsSecValueCopied.on();
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div
className={twMerge(
"shadow-none border-b border-mineshaft-600 bg-mineshaft-800 hover:bg-mineshaft-700",
isDirty && "border-primary-400/50"
)}
>
<div className="flex group">
<div
className={twMerge(
"flex items-center justify-center w-11 px-4 py-3 h-11",
isDirty && "text-primary"
)}
>
<Checkbox
id={`checkbox-${secret._id}`}
isChecked={isSelected}
onCheckedChange={() => onToggleSecretSelect(secret._id)}
className={twMerge("group-hover:flex hidden", isSelected && "flex")}
/>
<FontAwesomeIcon
icon={faKey}
className={twMerge("group-hover:hidden block", isSelected && "hidden")}
/>
</div>
<div className="w-80 h-11 flex items-center px-4 py-2 flex-shrink-0">
<Controller
name="key"
control={control}
render={({ field }) => (
<Input
autoComplete="off"
isReadOnly={isReadOnly}
autoCapitalization={currentWorkspace?.autoCapitalization}
variant="plain"
{...field}
className="w-full focus:text-bunker-100 hover:ring-primary-400/50 hover:ring-1"
/>
)}
/>
</div>
<div
className="flex-grow flex items-center border-x border-mineshaft-600 pl-4 pr-2 py-1"
tabIndex={0}
role="button"
>
{isOverriden ? (
<Controller
name="valueOverride"
key="value-overriden"
control={control}
render={({ field }) => (
<SecretInput
key="value-overriden"
isVisible={isVisible}
isReadOnly={isReadOnly}
{...field}
containerClassName="hover:border-primary-400/50 border border-transparent px-2 py-1.5 rounded-md transition-all group-hover:mr-2"
/>
)}
/>
) : (
<Controller
name="value"
key="secret-value"
control={control}
render={({ field }) => (
<SecretInput
isReadOnly={isReadOnly}
key="secret-value"
isVisible={isVisible}
{...field}
containerClassName="hover:border-primary-400/50 border border-transparent px-2 py-1.5 rounded-md transition-all group-hover:mr-2"
/>
)}
/>
)}
<div key="actions" className="h-8 flex self-start flex-shrink-0 transition-all">
<Tooltip content="Copy secret">
<IconButton
ariaLabel="copy-value"
variant="plain"
size="sm"
className="w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<DropdownMenuTrigger asChild disabled={!isAllowed}>
<IconButton
ariaLabel="tags"
variant="plain"
size="sm"
className={twMerge(
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0 data-[state=open]:w-5",
hasTagsApplied && "w-5 text-primary"
)}
isDisabled={!isAllowed}
>
<Tooltip content="Tags">
<FontAwesomeIcon icon={faTags} />
</Tooltip>
</IconButton>
</DropdownMenuTrigger>
)}
</ProjectPermissionCan>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Apply tags to this secrets</DropdownMenuLabel>
{tags.map((tag) => {
const { _id: tagId, name, tagColor } = tag;
const isTagSelected = selectedTagsGroupById?.[tagId];
return (
<DropdownMenuItem
onClick={() => handleTagSelect(tag)}
key={tagId}
icon={isTagSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="w-2 h-2 rounded-full mr-2"
style={{ background: tagColor || "#bec2c8" }}
/>
{name}
</div>
</DropdownMenuItem>
);
})}
<DropdownMenuItem>
<Button
size="xs"
className="w-full"
leftIcon={<FontAwesomeIcon icon={faTag} />}
onClick={onCreateTag}
>
Create a tag
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Override"
>
{(isAllowed) => (
<IconButton
ariaLabel="override-value"
isDisabled={!isAllowed}
variant="plain"
size="sm"
onClick={handleOverrideClick}
className={twMerge(
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0",
isOverriden && "w-5 text-primary"
)}
>
<FontAwesomeIcon icon={faCodeBranch} />
</IconButton>
)}
</ProjectPermissionCan>
<Popover>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<PopoverTrigger asChild disabled={!isAllowed}>
<IconButton
className={twMerge(
"overflow-hidden w-0 p-0 group-hover:w-5 group-hover:mr-2 data-[state=open]:w-6",
hasComment && "w-5 text-primary"
)}
variant="plain"
size="md"
ariaLabel="add-comment"
isDisabled={!isAllowed}
>
<Tooltip content="Comment">
<FontAwesomeIcon icon={faComment} />
</Tooltip>
</IconButton>
</PopoverTrigger>
)}
</ProjectPermissionCan>
<PopoverContent
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
sticky="always"
>
<FormControl label="Comment" className="mb-0">
<TextArea
className="border border-mineshaft-600 text-sm"
rows={8}
cols={30}
{...register("comment")}
/>
</FormControl>
</PopoverContent>
</Popover>
</div>
</div>
<AnimatePresence exitBeforeEnter>
{!isDirty ? (
<motion.div
key="options"
className="h-10 flex items-center space-x-4 flex-shrink-0 px-3"
initial={{ x: 0, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 10, opacity: 0 }}
>
<Tooltip content="More">
<IconButton
ariaLabel="more"
variant="plain"
size="md"
className="group-hover:opacity-100 opacity-0 p-0"
onClick={() => onDetailViewSecret(secret)}
>
<FontAwesomeIcon icon={faEllipsis} size="lg" />
</IconButton>
</Tooltip>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-value"
variant="plain"
size="md"
className="group-hover:opacity-100 opacity-0 p-0"
onClick={() => onDeleteSecret(secret)}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</motion.div>
) : (
<motion.div
key="options-save"
className="h-10 flex items-center space-x-4 flex-shrink-0 px-3"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }}
>
<Tooltip content="Save">
<IconButton
ariaLabel="more"
variant="plain"
type="submit"
size="md"
className={twMerge(
"group-hover:opacity-100 opacity-0 p-0 text-primary",
isDirty && "opacity-100"
)}
isDisabled={isSubmitting}
>
{isSubmitting ? (
<Spinner className="w-4 h-4 p-0 m-0" />
) : (
<FontAwesomeIcon icon={faSave} size="lg" className="text-primary" />
)}
</IconButton>
</Tooltip>
<Tooltip content="Cancel">
<IconButton
ariaLabel="more"
variant="plain"
size="md"
className={twMerge(
"group-hover:opacity-100 opacity-0 p-0",
isDirty && "opacity-100"
)}
onClick={() => reset()}
isDisabled={isSubmitting}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
</Tooltip>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</form>
);
}
);
SecretItem.displayName = "SecretItem";
@@ -0,0 +1,334 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { CreateTagModal } from "@app/components/tags/CreateTagModal";
import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
import { secretKeys } from "@app/hooks/api/secrets/queries";
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
import { UserWsKeyPair, WsTag } from "@app/hooks/api/types";
import { Filter, GroupBy, SortDir } from "../../SecretMainPage.types";
import { SecretDetailSidebar } from "./SecretDetaiSidebar";
import { SecretItem } from "./SecretItem";
type Props = {
secrets?: DecryptedSecret[];
environment: string;
workspaceId: string;
decryptFileKey: UserWsKeyPair;
secretPath?: string;
filter: Filter;
sortDir?: SortDir;
tags?: WsTag[];
isVisible?: boolean;
selectedSecrets: Record<string, boolean>;
onToggleSecretSelect: (id: string) => void;
};
const reorderSecretGroupByUnderscore = (secrets: DecryptedSecret[], sortDir: SortDir) => {
const groupedSecrets: Record<string, DecryptedSecret[]> = {};
secrets.forEach((secret) => {
const lastSeperatorIndex = secret.key.lastIndexOf("_");
const namespace =
lastSeperatorIndex !== -1 ? secret.key.substring(0, lastSeperatorIndex) : "misc";
if (!groupedSecrets?.[namespace]) groupedSecrets[namespace] = [];
groupedSecrets[namespace].push(secret);
});
return Object.keys(groupedSecrets)
.sort((a, b) =>
sortDir === SortDir.ASC
? a.toLowerCase().localeCompare(b.toLowerCase())
: b.toLowerCase().localeCompare(a.toLowerCase())
)
.map((namespace) => ({ namespace, secrets: groupedSecrets[namespace] }));
};
const reorderSecret = (secrets: DecryptedSecret[], sortDir: SortDir, filter?: GroupBy | null) => {
console.log(secrets);
if (filter === GroupBy.PREFIX) {
return reorderSecretGroupByUnderscore(secrets, sortDir);
}
return [
{
namespace: "",
secrets: secrets?.sort((a, b) =>
sortDir === SortDir.ASC
? a.key.toLowerCase().localeCompare(b.key.toLowerCase())
: b.key.toLowerCase().localeCompare(a.key.toLowerCase())
)
}
];
};
export const filterSecrets = (secrets: DecryptedSecret[], filter: Filter) =>
secrets.filter(({ key, value, tags }) => {
const isTagFilterActive = Boolean(Object.keys(filter.tags).length);
const searchTerm = filter.searchFilter.toLowerCase();
return (
(!isTagFilterActive || tags.some(({ _id }) => filter.tags?.[_id])) &&
(key.toLowerCase().includes(searchTerm) || value.toLowerCase().includes(searchTerm))
);
});
export const SecretListView = ({
secrets = [],
environment,
workspaceId,
decryptFileKey,
secretPath = "/",
filter,
sortDir = SortDir.ASC,
tags: wsTags = [],
isVisible,
selectedSecrets,
onToggleSecretSelect
}: Props) => {
const { createNotification } = useNotificationContext();
const queryClient = useQueryClient();
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"deleteSecret",
"secretDetail",
"createTag"
] as const);
// strip of side effect queries
const { mutateAsync: createSecretV3 } = useCreateSecretV3({
options: {
onSuccess: undefined
}
});
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3({
options: {
onSuccess: undefined
}
});
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3({
options: {
onSuccess: undefined
}
});
const handleSecretOperation = async (
operation: "create" | "update" | "delete",
type: "shared" | "personal",
key: string,
{
value,
comment,
tags,
skipMultilineEncoding,
newKey
}: Partial<{
value: string;
comment: string;
tags: string[];
skipMultilineEncoding: boolean;
newKey: string;
}> = {}
) => {
if (operation === "delete") {
await deleteSecretV3({
environment,
workspaceId,
secretPath,
secretName: key,
type
});
return;
}
if (operation === "update") {
await updateSecretV3({
environment,
workspaceId,
secretPath,
secretName: key,
secretValue: value || "",
type,
latestFileKey: decryptFileKey,
tags,
secretComment: comment,
skipMultilineEncoding,
newSecretName: newKey
});
return;
}
await createSecretV3(
{
environment,
workspaceId,
secretPath,
secretName: key,
secretValue: value || "",
secretComment: "",
skipMultilineEncoding,
type,
latestFileKey: decryptFileKey
},
{}
);
};
const handleSaveSecret = useCallback(
async (
orgSecret: DecryptedSecret,
modSecret: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] }
) => {
const { key: oldKey } = orgSecret;
const { key, value, overrideAction, idOverride, valueOverride, tags, comment } = modSecret;
const hasKeyChanged = oldKey !== key;
const tagIds = tags.map(({ _id }) => _id);
const oldTagIds = orgSecret.tags.map(({ _id }) => _id);
const isSameTags = JSON.stringify(tagIds) === JSON.stringify(oldTagIds);
const isSharedSecUnchanged =
(["key", "value", "comment", "skipMultilineEncoding"] as const).every(
(el) => orgSecret[el] === modSecret[el]
) && isSameTags;
try {
// personal secret change
if (overrideAction === "deleted") await handleSecretOperation("delete", "personal", key);
else if (overrideAction && idOverride)
await handleSecretOperation("update", "personal", oldKey, {
value: valueOverride,
newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding
});
else if (overrideAction)
await handleSecretOperation("create", "personal", key, { value: valueOverride });
// shared secret change
if (!isSharedSecUnchanged)
await handleSecretOperation("update", "shared", oldKey, {
value,
tags: tagIds,
comment,
newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding
});
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
handlePopUpClose("secretDetail");
createNotification({
type: "success",
text: "Successfully saved secrets"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to save secret"
});
}
},
[]
);
const handleSecretDelete = useCallback(async () => {
const { key } = popUp.deleteSecret?.data as DecryptedSecret;
try {
await handleSecretOperation("delete", "shared", key);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
handlePopUpClose("deleteSecret");
handlePopUpClose("secretDetail");
createNotification({
type: "success",
text: "Successfully deleted secret"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to delete secret"
});
}
}, [(popUp.deleteSecret?.data as DecryptedSecret)?.key]);
// for optimization on minimise re-rendering of secret items
const onCreateTag = useCallback(() => handlePopUpOpen("createTag"), []);
const onDeleteSecret = useCallback(
(sec: DecryptedSecret) => handlePopUpOpen("deleteSecret", sec),
[]
);
const onDetailViewSecret = useCallback(
(sec: DecryptedSecret) => handlePopUpOpen("secretDetail", sec),
[]
);
return (
<>
{reorderSecret(secrets, sortDir, filter.groupBy).map(
({ namespace, secrets: groupedSecrets }) => {
const filteredSecrets = filterSecrets(groupedSecrets, filter);
return (
<div className="flex flex-col" key={`${namespace}-${groupedSecrets.length}`}>
<div
className={twMerge(
"bg-bunker-600 capitalize text-md h-0 transition-all",
Boolean(namespace) && Boolean(filteredSecrets.length) && "h-11 py-3 pl-4 "
)}
key={namespace}
>
{namespace}
</div>
{filteredSecrets.map((secret) => (
<SecretItem
environment={environment}
secretPath={secretPath}
tags={wsTags}
isSelected={selectedSecrets?.[secret._id]}
onToggleSecretSelect={onToggleSecretSelect}
isVisible={isVisible}
secret={secret}
key={secret._id}
onSaveSecret={handleSaveSecret}
onDeleteSecret={onDeleteSecret}
onDetailViewSecret={onDetailViewSecret}
onCreateTag={onCreateTag}
/>
))}
</div>
);
}
)}
<DeleteActionModal
isOpen={popUp.deleteSecret.isOpen}
deleteKey={(popUp.deleteSecret?.data as DecryptedSecret)?.key}
title="Do you want to delete this secret?"
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
onDeleteApproved={handleSecretDelete}
/>
<SecretDetailSidebar
environment={environment}
secretPath={secretPath}
isOpen={popUp.secretDetail.isOpen}
onToggle={(isOpen) => handlePopUpToggle("secretDetail", isOpen)}
decryptFileKey={decryptFileKey}
secret={popUp.secretDetail.data as DecryptedSecret}
onDeleteSecret={() => handlePopUpOpen("deleteSecret", popUp.secretDetail.data)}
onClose={() => handlePopUpClose("secretDetail")}
onSaveSecret={handleSaveSecret}
tags={wsTags}
onCreateTag={() => handlePopUpOpen("createTag")}
/>
<CreateTagModal
isOpen={popUp.createTag.isOpen}
onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)}
/>
</>
);
};
@@ -0,0 +1,28 @@
import { z } from "zod";
export enum SecretActionType {
Created = "created",
Modified = "modified",
Deleted = "deleted"
}
export const formSchema = z.object({
key: z.string(),
value: z.string(),
idOverride: z.string().optional(),
valueOverride: z.string().optional(),
overrideAction: z.string().optional(),
comment: z.string().trim().optional(),
skipMultilineEncoding: z.boolean().optional(),
tags: z
.object({
_id: z.string(),
name: z.string(),
slug: z.string(),
tagColor: z.string().optional()
})
.array()
.default([])
});
export type TFormSchema = z.infer<typeof formSchema>;
@@ -0,0 +1 @@
export { SecretListView } from "./SecretListView";
@@ -0,0 +1,190 @@
import {
faChevronRight,
faKey,
faMinusSquare,
faPencilSquare,
faPlusSquare,
faSquare
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
SecretInput,
Table,
TableContainer,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { DecryptedSecret } from "@app/hooks/api/types";
export enum TDiffModes {
NoChange = "no change",
Deleted = "deleted",
Modified = "modified",
Created = "created"
}
type Props = {
mode: TDiffModes;
preSecret?: DecryptedSecret;
postSecret: DecryptedSecret;
};
export type TDiffView<T> = {
mode: TDiffModes;
pre?: T;
post: T;
};
export const renderIcon = (mode: TDiffModes) => {
if (mode === TDiffModes.NoChange)
return <FontAwesomeIcon icon={faSquare} className="text-gray-700" size="lg" />;
if (mode === TDiffModes.Deleted)
return <FontAwesomeIcon icon={faMinusSquare} className="text-red-700" size="lg" />;
if (mode === TDiffModes.Modified)
return <FontAwesomeIcon icon={faPencilSquare} className="text-orange-700" size="lg" />;
return <FontAwesomeIcon icon={faPlusSquare} className="text-green-700" size="lg" />;
};
export const SecretItem = ({ mode, preSecret, postSecret }: Props) => {
const [isExpanded, setIsExpanded] = useToggle();
const isModified = mode === "modified";
return (
<>
<div
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
role="button"
tabIndex={0}
onClick={setIsExpanded.toggle}
onKeyDown={(evt) => {
if (evt.key === "Enter") setIsExpanded.toggle();
}}
>
<div className="w-12 flex-shrink-0 px-4 py-3">
<Tooltip content={mode}>{renderIcon(mode)}</Tooltip>
</div>
<div className="w-12 flex-shrink-0 px-4 py-3">
<FontAwesomeIcon icon={faKey} />
</div>
<div className="flex-grow px-4 py-3 flex items-center space-x-4">
{mode === "modified" ? (
<>
<div>{preSecret?.key}</div>
<div className="bg-primary text-black rounded-lg font-bold px-1 py-0.5 text-xs">
v{preSecret?.version}
</div>
<div>
<FontAwesomeIcon icon={faChevronRight} size="sm" className="text-orange-700" />
</div>
<div className="bg-primary text-black rounded-lg font-bold px-1 py-0.5 text-xs">
v{postSecret?.version}
</div>
</>
) : (
postSecret.key
)}
</div>
</div>
{isExpanded && (
<div className="flex bg-bunker-600 cursor-pointer p-4">
<TableContainer>
<Table>
<THead>
<Th className="min-table-row min-w-[11rem] border-r border-mineshaft-600">Type</Th>
{isModified ? (
<>
<Th className="border-r border-mineshaft-600">Before</Th>
<Th>After</Th>
</>
) : (
<Th>Value</Th>
)}
</THead>
<TBody>
<Tr>
<Td className="border-r border-mineshaft-600">Key</Td>
{isModified && (
<Td className="border-r border-mineshaft-600">{preSecret?.key}</Td>
)}
<Td>{postSecret.key}</Td>
</Tr>
<Tr>
<Td className="border-r border-mineshaft-600">Value</Td>
{isModified && (
<Td className="border-r border-mineshaft-600">
<SecretInput value={preSecret?.value} />
</Td>
)}
<Td>
<SecretInput value={postSecret?.value} />
</Td>
</Tr>
{Boolean(preSecret?.idOverride || postSecret?.idOverride) && (
<Tr>
<Td className="border-r border-mineshaft-600">Override</Td>
{isModified && (
<Td className="border-r border-mineshaft-600">
<SecretInput value={preSecret?.valueOverride} />
</Td>
)}
<Td>
<SecretInput value={postSecret?.valueOverride} />
</Td>
</Tr>
)}
<Tr>
<Td className="border-r border-mineshaft-600">Comment</Td>
{isModified && (
<Td className="border-r border-mineshaft-600">{preSecret?.comment}</Td>
)}
<Td>{postSecret?.comment}</Td>
</Tr>
<Tr>
<Td className="border-r border-mineshaft-600">Tags</Td>
{isModified && (
<Td className="border-r border-mineshaft-600">
{preSecret?.tags?.map(({ name, _id: tagId, tagColor }) => (
<Tag
className="flex items-center space-x-2 w-min"
key={`${preSecret._id}-${tagId}`}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tagColor || "#bec2c8" }}
/>
<div className="text-sm">{name}</div>
</Tag>
))}
</Td>
)}
<Td>
{postSecret?.tags?.map(({ name, _id: tagId, tagColor }) => (
<Tag
className="flex items-center space-x-2 w-min"
key={`${postSecret._id}-${tagId}`}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tagColor || "#bec2c8" }}
/>
<div className="text-sm">{name}</div>
</Tag>
))}
</Td>
</Tr>
</TBody>
</Table>
</TableContainer>
</div>
)}
</>
);
};
@@ -0,0 +1,268 @@
import { useMemo, useState } from "react";
import {
faArrowLeft,
faChevronRight,
faCodeCommit,
faFolder,
faMagnifyingGlass,
faUndo
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, ContentLoader, Input, Tag, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useGetSnapshotSecrets, usePerformSecretRollback } from "@app/hooks/api";
import { DecryptedSecret, TSecretFolder, UserWsKeyPair } from "@app/hooks/api/types";
import { renderIcon, SecretItem, TDiffModes, TDiffView } from "./SecretItem";
type Props = {
snapshotId: string;
environment: string;
workspaceId: string;
secretPath?: string;
decryptFileKey: UserWsKeyPair;
secrets?: DecryptedSecret[];
folders?: TSecretFolder[];
snapshotCount?: number;
onGoBack: () => void;
onClickListSnapshot: () => void;
};
const LOADER_TEXT = ["Fetching your snapshot", "Creating the difference view"];
const deepCompareSecrets = (lhs: DecryptedSecret, rhs: DecryptedSecret) =>
lhs.key === rhs.key &&
lhs.value === rhs.value &&
lhs.comment === rhs.comment &&
lhs?.valueOverride === rhs?.valueOverride &&
JSON.stringify(lhs.tags) === JSON.stringify(rhs.tags);
export const SnapshotView = ({
snapshotId,
environment,
workspaceId,
secretPath,
decryptFileKey,
secrets = [],
folders = [],
onGoBack,
snapshotCount,
onClickListSnapshot
}: Props) => {
const { createNotification } = useNotificationContext();
const [search, setSearch] = useState("");
const { mutateAsync: performRollback, isLoading: isRollingBack } = usePerformSecretRollback();
const { data: snapshotData, isLoading: isSnapshotLoading } = useGetSnapshotSecrets({
snapshotId,
env: environment,
decryptFileKey
});
const rollingFolder = snapshotData?.folders || [];
const rollingSecrets = snapshotData?.secrets || [];
const folderDiffView = useMemo(() => {
const folderGroupById = folders.reduce<Record<string, TSecretFolder>>(
(prev, curr) => ({ ...prev, [curr.id]: curr }),
{}
);
const diffView: Array<TDiffView<TSecretFolder>> = [];
rollingFolder.forEach((rollFolder) => {
const { id, name } = rollFolder;
const doesExist = Boolean(folderGroupById?.[id]);
if (doesExist) {
diffView.push({
mode: name === folderGroupById[id].name ? TDiffModes.NoChange : TDiffModes.Modified,
pre: folderGroupById[id],
post: rollFolder
});
delete folderGroupById[id];
} else {
diffView.push({ mode: TDiffModes.Created, post: rollFolder });
}
});
Object.values(folderGroupById).forEach((folder) => {
diffView.push({ mode: TDiffModes.Deleted, post: folder });
});
return diffView;
}, [folders, rollingFolder]);
const secretDiffView = useMemo(() => {
const secretGroupById = secrets.reduce<Record<string, DecryptedSecret>>(
(prev, curr) => ({ ...prev, [curr._id]: curr }),
{}
);
const diffView: Array<TDiffView<DecryptedSecret>> = [];
rollingSecrets.forEach((rollSecret) => {
const { _id: id } = rollSecret;
const doesExist = Boolean(secretGroupById?.[id]);
if (doesExist) {
diffView.push({
mode: deepCompareSecrets(rollSecret, secretGroupById[id])
? TDiffModes.NoChange
: TDiffModes.Modified,
pre: secretGroupById[id],
post: rollSecret
});
delete secretGroupById[id];
} else {
diffView.push({ mode: TDiffModes.Created, post: rollSecret });
}
});
Object.values(secretGroupById).forEach((folder) => {
diffView.push({ mode: TDiffModes.Deleted, post: folder });
});
return diffView;
}, [secrets, rollingSecrets]);
const handleClickRollback = async () => {
if (!snapshotData?.version) {
createNotification({
text: "Failed to find secret version",
type: "success"
});
return;
}
try {
await performRollback({
workspaceId,
version: snapshotData.version,
environment,
directory: secretPath
});
createNotification({
text: "Successfully rollback secrets",
type: "success"
});
onGoBack();
} catch (error) {
console.log(error);
createNotification({
text: "Failed to rollback secrets",
type: "error"
});
}
};
if (isSnapshotLoading) {
return <ContentLoader text={LOADER_TEXT} />;
}
return (
<>
<div className="flex space-x-4 items-center">
<h6 className="text-2xl">Snapshot</h6>
<Tag colorSchema="green">{new Date(snapshotData?.createdAt || "").toLocaleString()}</Tag>
</div>
<div className="flex items-center space-x-2 mt-4">
<div className="w-2/5">
<Input
className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by folder name, key name ..."
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
value={search}
onChange={(evt) => setSearch(evt.target.value)}
/>
</div>
<div className="flex-grow" />
<div>
<Button
variant="outline_bg"
onClick={onClickListSnapshot}
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
className="h-10"
isDisabled={isRollingBack}
>
{snapshotCount} Commits
</Button>
</div>
<div>
<Button
onClick={onGoBack}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
className="h-10"
isDisabled={isRollingBack}
>
Go Back
</Button>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretRollback}
>
{(isAllowed) => (
<Button
onClick={handleClickRollback}
isDisabled={isRollingBack || !isAllowed}
isLoading={isRollingBack}
leftIcon={<FontAwesomeIcon icon={faUndo} />}
className="h-10"
>
Rollback
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="mt-4 bg-mineshaft-800 text-left text-bunker-300 rounded-md text-sm">
<div className="flex flex-col ">
<div className="flex font-medium border-b border-mineshaft-600">
<div className="w-12 flex-shrink-0" />
<div className="w-12 flex-shrink-0" />
<div className="flex-grow px-4 py-2 flex items-center">Changes</div>
</div>
{folderDiffView
.sort((a, b) => a.post.name.toLowerCase().localeCompare(b.post.name.toLowerCase()))
.filter((a) => a.post.name.toLowerCase().includes(search.toLowerCase()))
.map(({ mode, pre, post }, index) => (
<div
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
key={`folder-${mode}-${index + 1}`}
>
<div className="w-12 flex-shrink-0 px-4 py-3">
<Tooltip content={mode}>{renderIcon(mode)}</Tooltip>
</div>
<div className="w-12 flex-shrink-0 px-4 py-3">
<FontAwesomeIcon icon={faFolder} />
</div>
<div className="flex-grow px-4 py-3 flex items-center space-x-4">
{mode === "modified" ? (
<>
<div>{pre?.name}</div>
<div>
<FontAwesomeIcon
icon={faChevronRight}
size="sm"
className="text-orange-700"
/>
</div>
<div>{post?.name}</div>
</>
) : (
post.name
)}
</div>
</div>
))}
{secretDiffView
.sort((a, b) => a.post.key.toLowerCase().localeCompare(b.post.key.toLowerCase()))
.filter((a) => a.post.key.toLowerCase().includes(search.toLowerCase()))
.map(({ mode, pre, post }, index) => (
<SecretItem
key={`folder-${mode}-${index + 1}`}
mode={mode}
preSecret={pre}
postSecret={post}
/>
))}
</div>
</div>
</>
);
};
@@ -0,0 +1 @@
export { SnapshotView } from "./SnapshotView";