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