mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 11:29:42 +00:00
591 lines
22 KiB
TypeScript
591 lines
22 KiB
TypeScript
/* eslint-disable simple-import-sort/imports */
|
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
|
import {
|
|
Button,
|
|
Checkbox,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuTrigger,
|
|
FontAwesomeSymbol,
|
|
FormControl,
|
|
IconButton,
|
|
Input,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
SecretInput,
|
|
Spinner,
|
|
TextArea,
|
|
Tooltip
|
|
} from "@app/components/v2";
|
|
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
|
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 { subject } from "@casl/ability";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { memo, useEffect } from "react";
|
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
|
import { twMerge } from "tailwind-merge";
|
|
|
|
import { CreateReminderForm } from "./CreateReminderForm";
|
|
import {
|
|
FontAwesomeSpriteName,
|
|
formSchema,
|
|
SecretActionType,
|
|
TFormSchema
|
|
} from "./SecretListView.utils";
|
|
|
|
type Props = {
|
|
secret: DecryptedSecret;
|
|
onSaveSecret: (
|
|
orgSec: DecryptedSecret,
|
|
modSec: Omit<DecryptedSecret, "tags"> & { tags: { id: string }[] },
|
|
cb?: () => void
|
|
) => 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,
|
|
trigger,
|
|
formState: { isDirty, isSubmitting, errors }
|
|
} = useForm<TFormSchema>({
|
|
defaultValues: secret,
|
|
values: secret,
|
|
resolver: zodResolver(formSchema)
|
|
});
|
|
|
|
const secretReminderRepeatDays = watch("reminderRepeatDays");
|
|
const secretReminderNote = watch("reminderNote");
|
|
|
|
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);
|
|
const [createReminderFormOpen, setCreateReminderFormOpen] = 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 });
|
|
setValue("reminderRepeatDays", secret?.reminderRepeatDays, {
|
|
shouldDirty: !isUnsavedOverride
|
|
});
|
|
setValue("reminderNote", secret?.reminderNote, { shouldDirty: !isUnsavedOverride });
|
|
} else {
|
|
reset();
|
|
setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true });
|
|
setValue("valueOverride", "", { shouldDirty: true });
|
|
}
|
|
};
|
|
|
|
const handleFormSubmit = async (data: TFormSchema) => {
|
|
await onSaveSecret(secret, { ...secret, ...data }, () => reset());
|
|
};
|
|
|
|
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"]);
|
|
if (isOverriden) {
|
|
navigator.clipboard.writeText(value as string);
|
|
} else {
|
|
navigator.clipboard.writeText(overrideValue as string);
|
|
}
|
|
setIsSecValueCopied.on();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<CreateReminderForm
|
|
repeatDays={secretReminderRepeatDays}
|
|
note={secretReminderNote}
|
|
isOpen={createReminderFormOpen}
|
|
onOpenChange={(_, data) => {
|
|
setCreateReminderFormOpen.toggle();
|
|
|
|
if (data) {
|
|
setValue("reminderRepeatDays", data.days, { shouldDirty: true });
|
|
setValue("reminderNote", data.note, { shouldDirty: true });
|
|
}
|
|
}}
|
|
/>
|
|
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
|
<div
|
|
className={twMerge(
|
|
"border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700",
|
|
isDirty && "border-primary-400/50"
|
|
)}
|
|
>
|
|
<div className="group flex">
|
|
<div
|
|
className={twMerge(
|
|
"flex h-11 w-11 items-center justify-center px-4 py-3",
|
|
isDirty && "text-primary"
|
|
)}
|
|
>
|
|
<Checkbox
|
|
id={`checkbox-${secret.id}`}
|
|
isChecked={isSelected}
|
|
onCheckedChange={() => onToggleSecretSelect(secret.id)}
|
|
className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
|
|
/>
|
|
<FontAwesomeSymbol
|
|
className={twMerge(
|
|
"ml-3 block h-3.5 w-3.5 group-hover:hidden",
|
|
isSelected && "hidden"
|
|
)}
|
|
symbolName={FontAwesomeSpriteName.SecretKey}
|
|
/>
|
|
</div>
|
|
<div className="flex h-11 w-80 flex-shrink-0 items-center px-4 py-2">
|
|
<Controller
|
|
name="key"
|
|
control={control}
|
|
render={({ field, fieldState: { error } }) => (
|
|
<Input
|
|
autoComplete="off"
|
|
isReadOnly={isReadOnly}
|
|
autoCapitalization={currentWorkspace?.autoCapitalization}
|
|
variant="plain"
|
|
isDisabled={isOverriden}
|
|
placeholder={error?.message}
|
|
isError={Boolean(error)}
|
|
onKeyUp={() => trigger("key")}
|
|
{...field}
|
|
className="w-full px-0 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
<div
|
|
className="flex flex-grow items-center border-x border-mineshaft-600 py-1 pl-4 pr-2"
|
|
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="py-1.5 rounded-md transition-all group-hover:mr-2"
|
|
/>
|
|
)}
|
|
/>
|
|
) : (
|
|
<Controller
|
|
name="value"
|
|
key="secret-value"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<InfisicalSecretInput
|
|
isReadOnly={isReadOnly}
|
|
key="secret-value"
|
|
isVisible={isVisible}
|
|
environment={environment}
|
|
secretPath={secretPath}
|
|
{...field}
|
|
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2"
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
<div key="actions" className="flex h-8 flex-shrink-0 self-start transition-all">
|
|
<Tooltip content="Copy secret">
|
|
<IconButton
|
|
ariaLabel="copy-value"
|
|
variant="plain"
|
|
size="sm"
|
|
className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5"
|
|
onClick={copyTokenToClipboard}
|
|
>
|
|
<FontAwesomeSymbol
|
|
className="h-3.5 w-3"
|
|
symbolName={
|
|
isSecValueCopied
|
|
? FontAwesomeSpriteName.Check
|
|
: FontAwesomeSpriteName.ClipboardCopy
|
|
}
|
|
/>
|
|
</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 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-5",
|
|
hasTagsApplied && "w-5 text-primary"
|
|
)}
|
|
isDisabled={!isAllowed}
|
|
>
|
|
<Tooltip content="Tags">
|
|
<FontAwesomeSymbol
|
|
className="h-3.5 w-3.5"
|
|
symbolName={FontAwesomeSpriteName.Tags}
|
|
/>
|
|
</Tooltip>
|
|
</IconButton>
|
|
</DropdownMenuTrigger>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
|
|
{tags.map((tag) => {
|
|
const { id: tagId, name, color } = tag;
|
|
|
|
const isTagSelected = selectedTagsGroupById?.[tagId];
|
|
return (
|
|
<DropdownMenuItem
|
|
onClick={() => handleTagSelect(tag)}
|
|
key={`${secret.id}-${tagId}`}
|
|
icon={
|
|
isTagSelected && (
|
|
<FontAwesomeSymbol
|
|
symbolName={FontAwesomeSpriteName.CheckedCircle}
|
|
className="h-3 w-3"
|
|
/>
|
|
)
|
|
}
|
|
iconPos="right"
|
|
>
|
|
<div className="flex items-center">
|
|
<div
|
|
className="mr-2 h-2 w-2 rounded-full"
|
|
style={{ background: color || "#bec2c8" }}
|
|
/>
|
|
{name}
|
|
</div>
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
<DropdownMenuItem className="px-1.5" asChild>
|
|
<Button
|
|
size="xs"
|
|
className="w-full"
|
|
colorSchema="primary"
|
|
variant="outline_bg"
|
|
leftIcon={
|
|
<FontAwesomeSymbol
|
|
symbolName={FontAwesomeSpriteName.Tags}
|
|
className="h-3 w-3"
|
|
/>
|
|
}
|
|
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 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5",
|
|
isOverriden && "w-5 text-primary"
|
|
)}
|
|
>
|
|
<FontAwesomeSymbol
|
|
symbolName={FontAwesomeSpriteName.Override}
|
|
className="h-3.5 w-3.5"
|
|
/>
|
|
</IconButton>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
|
|
{!isOverriden && (
|
|
<IconButton
|
|
className={twMerge(
|
|
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6",
|
|
Boolean(secretReminderRepeatDays) && "w-5 text-primary"
|
|
)}
|
|
variant="plain"
|
|
size="md"
|
|
ariaLabel="add-reminder"
|
|
onClick={() => setCreateReminderFormOpen.on()}
|
|
>
|
|
<Tooltip
|
|
content={
|
|
secretReminderRepeatDays && secretReminderRepeatDays > 0
|
|
? `Every ${secretReminderRepeatDays} day${
|
|
Number(secretReminderRepeatDays) > 1 ? "s" : ""
|
|
}
|
|
`
|
|
: "Reminder"
|
|
}
|
|
>
|
|
<FontAwesomeSymbol
|
|
className="h-3.5 w-3.5"
|
|
symbolName={FontAwesomeSpriteName.Clock}
|
|
/>
|
|
</Tooltip>
|
|
</IconButton>
|
|
)}
|
|
|
|
<Popover>
|
|
<ProjectPermissionCan
|
|
I={ProjectPermissionActions.Edit}
|
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
|
>
|
|
{(isAllowed) => (
|
|
<PopoverTrigger asChild disabled={!isAllowed}>
|
|
<IconButton
|
|
className={twMerge(
|
|
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6",
|
|
hasComment && "w-5 text-primary"
|
|
)}
|
|
variant="plain"
|
|
size="md"
|
|
ariaLabel="add-comment"
|
|
isDisabled={!isAllowed}
|
|
>
|
|
<Tooltip content="Comment">
|
|
<FontAwesomeSymbol
|
|
className="h-3.5 w-3.5"
|
|
symbolName={FontAwesomeSpriteName.Comment}
|
|
/>
|
|
</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="flex h-10 flex-shrink-0 items-center space-x-4 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="p-0 opacity-0 group-hover:opacity-100 h-5 w-4"
|
|
onClick={() => onDetailViewSecret(secret)}
|
|
>
|
|
<FontAwesomeSymbol
|
|
symbolName={FontAwesomeSpriteName.More}
|
|
className="h-5 w-4"
|
|
/>
|
|
</IconButton>
|
|
</Tooltip>
|
|
<ProjectPermissionCan
|
|
I={ProjectPermissionActions.Delete}
|
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
|
renderTooltip
|
|
allowedLabel="Delete"
|
|
>
|
|
{(isAllowed) => (
|
|
<IconButton
|
|
ariaLabel="delete-value"
|
|
variant="plain"
|
|
colorSchema="danger"
|
|
size="md"
|
|
className="p-0 opacity-0 group-hover:opacity-100"
|
|
onClick={() => onDeleteSecret(secret)}
|
|
isDisabled={!isAllowed}
|
|
>
|
|
<FontAwesomeSymbol
|
|
symbolName={FontAwesomeSpriteName.Close}
|
|
className="h-5 w-4"
|
|
/>
|
|
</IconButton>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="options-save"
|
|
className="flex h-10 flex-shrink-0 items-center space-x-4 px-3"
|
|
initial={{ x: -10, opacity: 0 }}
|
|
animate={{ x: 0, opacity: 1 }}
|
|
exit={{ x: -10, opacity: 0 }}
|
|
>
|
|
<Tooltip content={errors.key ? errors.key?.message : "Save"}>
|
|
<IconButton
|
|
ariaLabel="more"
|
|
variant="plain"
|
|
type="submit"
|
|
size="md"
|
|
className={twMerge(
|
|
"p-0 text-primary opacity-0 group-hover:opacity-100",
|
|
isDirty && "opacity-100"
|
|
)}
|
|
isDisabled={isSubmitting || Boolean(errors.key)}
|
|
>
|
|
{isSubmitting ? (
|
|
<Spinner className="m-0 h-4 w-4 p-0" />
|
|
) : (
|
|
<FontAwesomeSymbol
|
|
symbolName={FontAwesomeSpriteName.Check}
|
|
className={twMerge(
|
|
"h-4 w-4 text-primary",
|
|
errors.key && "text-mineshaft-300"
|
|
)}
|
|
/>
|
|
)}
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip content="Cancel">
|
|
<IconButton
|
|
ariaLabel="more"
|
|
variant="plain"
|
|
size="md"
|
|
className={twMerge(
|
|
"p-0 opacity-0 group-hover:opacity-100",
|
|
isDirty && "opacity-100"
|
|
)}
|
|
onClick={() => reset()}
|
|
isDisabled={isSubmitting}
|
|
>
|
|
<FontAwesomeSymbol
|
|
symbolName={FontAwesomeSpriteName.Close}
|
|
className="h-4 w-4 text-primary"
|
|
/>
|
|
</IconButton>
|
|
</Tooltip>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</>
|
|
);
|
|
}
|
|
);
|
|
|
|
SecretItem.displayName = "SecretItem";
|