mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
526 lines
19 KiB
TypeScript
526 lines
19 KiB
TypeScript
/* eslint-disable react/jsx-no-useless-fragment */
|
|
import { memo, useEffect, useRef, useState } from "react";
|
|
import {
|
|
Control,
|
|
Controller,
|
|
useFieldArray,
|
|
UseFormRegister,
|
|
UseFormSetValue,
|
|
useWatch
|
|
} from "react-hook-form";
|
|
import { subject } from "@casl/ability";
|
|
import {
|
|
faCheck,
|
|
faCodeBranch,
|
|
faComment,
|
|
faCopy,
|
|
faEllipsis,
|
|
faInfoCircle,
|
|
faTags,
|
|
faXmark
|
|
} from "@fortawesome/free-solid-svg-icons";
|
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
import { cx } from "cva";
|
|
import { twMerge } from "tailwind-merge";
|
|
|
|
// TODO:(akhilmhdh): Refactor this
|
|
import AddTagPopoverContent from "@app/components/AddTagPopoverContent/AddTagPopoverContent";
|
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
|
import {
|
|
FormControl,
|
|
HoverCard,
|
|
HoverCardContent,
|
|
HoverCardTrigger,
|
|
IconButton,
|
|
Input,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
SecretInput,
|
|
Tag,
|
|
TextArea,
|
|
Tooltip
|
|
} from "@app/components/v2";
|
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
|
import { useToggle } from "@app/hooks";
|
|
import { WsTag } from "@app/hooks/api/types";
|
|
|
|
import { FormData, SecretActionType } from "../../DashboardPage.utils";
|
|
|
|
type Props = {
|
|
index: number;
|
|
environment: string;
|
|
secretPath: string;
|
|
// backend generated unique id
|
|
secUniqId?: string;
|
|
// permission and external state's that decided to hide or show
|
|
isReadOnly?: boolean;
|
|
isAddOnly?: boolean;
|
|
isRollbackMode?: boolean;
|
|
isSecretValueHidden: boolean;
|
|
searchTerm: string;
|
|
// to record the ids of deleted ones
|
|
onSecretDelete: (index: number, secretName: string, id?: string, overrideId?: string) => void;
|
|
// sidebar control props
|
|
onRowExpand: (secId: string | undefined, index: number) => void;
|
|
// tag props
|
|
wsTags?: WsTag[];
|
|
onCreateTagOpen: () => void;
|
|
// rhf specific functions, dont put this using useFormContext. This is passed as props to avoid re-rendering
|
|
control: Control<FormData>;
|
|
register: UseFormRegister<FormData>;
|
|
setValue: UseFormSetValue<FormData>;
|
|
isKeyError?: boolean;
|
|
keyError?: string;
|
|
autoCapitalization?: boolean;
|
|
};
|
|
|
|
export const SecretInputRow = memo(
|
|
({
|
|
index,
|
|
secretPath,
|
|
environment,
|
|
isSecretValueHidden,
|
|
onRowExpand,
|
|
isReadOnly,
|
|
isRollbackMode,
|
|
isAddOnly,
|
|
wsTags,
|
|
onCreateTagOpen,
|
|
onSecretDelete,
|
|
searchTerm,
|
|
control,
|
|
register,
|
|
setValue,
|
|
isKeyError,
|
|
keyError,
|
|
secUniqId,
|
|
autoCapitalization
|
|
}: Props): JSX.Element => {
|
|
const isKeySubDisabled = useRef<boolean>(false);
|
|
// comment management in a row
|
|
const {
|
|
fields: secretTags,
|
|
remove,
|
|
append
|
|
} = useFieldArray({ control, name: `secrets.${index}.tags` });
|
|
|
|
// display the tags in alphabetical order
|
|
secretTags.sort((a, b) => a?.name?.localeCompare(b?.name));
|
|
|
|
// to get details on a secret
|
|
const overrideAction = useWatch({
|
|
control,
|
|
name: `secrets.${index}.overrideAction`,
|
|
exact: true
|
|
});
|
|
const idOverride = useWatch({ control, name: `secrets.${index}.idOverride`, exact: true });
|
|
const secComment = useWatch({ control, name: `secrets.${index}.comment`, exact: true });
|
|
const hasComment = Boolean(secComment);
|
|
const secKey = useWatch({
|
|
control,
|
|
name: `secrets.${index}.key`,
|
|
disabled: isKeySubDisabled.current,
|
|
exact: true
|
|
});
|
|
const secValue = useWatch({
|
|
control,
|
|
name: `secrets.${index}.value`,
|
|
disabled: isKeySubDisabled.current,
|
|
exact: true
|
|
});
|
|
const secValueOverride = useWatch({
|
|
control,
|
|
name: `secrets.${index}.valueOverride`,
|
|
disabled: isKeySubDisabled.current,
|
|
exact: true
|
|
});
|
|
// when secret is override by personal values
|
|
const isOverridden =
|
|
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
|
const [hoveredTag, setHoveredTag] = useState<WsTag | null>(null);
|
|
|
|
const handleTagOnMouseEnter = (wsTag: WsTag) => {
|
|
setHoveredTag(wsTag);
|
|
};
|
|
|
|
const handleTagOnMouseLeave = () => {
|
|
setHoveredTag(null);
|
|
};
|
|
|
|
const checkIfTagIsVisible = (wsTag: WsTag) => wsTag._id === hoveredTag?._id;
|
|
|
|
const secId = useWatch({ control, name: `secrets.${index}._id`, exact: true });
|
|
const tags =
|
|
useWatch({ control, name: `secrets.${index}.tags`, exact: true, defaultValue: [] }) || [];
|
|
|
|
const selectedTagIds = tags.reduce<Record<string, boolean>>(
|
|
(prev, curr) => ({ ...prev, [curr.slug]: true }),
|
|
{}
|
|
);
|
|
|
|
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
|
|
|
|
useEffect(() => {
|
|
let timer: NodeJS.Timeout;
|
|
if (isSecValueCopied) {
|
|
timer = setTimeout(() => setIsSecValueCopied.off(), 2000);
|
|
}
|
|
return () => clearTimeout(timer);
|
|
}, [isSecValueCopied]);
|
|
|
|
const copyTokenToClipboard = () => {
|
|
navigator.clipboard.writeText((secValueOverride || secValue) as string);
|
|
setIsSecValueCopied.on();
|
|
};
|
|
|
|
const onSecretOverride = () => {
|
|
if (isOverridden) {
|
|
// when user created a new override but then removes
|
|
if (overrideAction === SecretActionType.Created)
|
|
setValue(`secrets.${index}.valueOverride`, "");
|
|
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, {
|
|
shouldDirty: true
|
|
});
|
|
} else {
|
|
setValue(`secrets.${index}.valueOverride`, "");
|
|
setValue(
|
|
`secrets.${index}.overrideAction`,
|
|
idOverride ? SecretActionType.Modified : SecretActionType.Created,
|
|
{ shouldDirty: true }
|
|
);
|
|
}
|
|
};
|
|
|
|
const onSelectTag = (selectedTag: WsTag) => {
|
|
const shouldAppend = !selectedTagIds[selectedTag.slug];
|
|
if (shouldAppend) {
|
|
const { _id: id, name, slug, tagColor } = selectedTag;
|
|
append({ _id: id, name, slug, tagColor });
|
|
} else {
|
|
const pos = tags.findIndex(({ slug }: { slug: string }) => selectedTag.slug === slug);
|
|
remove(pos);
|
|
}
|
|
};
|
|
const isCreatedSecret = !secId;
|
|
const shouldBeBlockedInAddOnly = !isCreatedSecret && isAddOnly;
|
|
|
|
// Why this instead of filter in parent
|
|
// Because rhf field.map has default values so basically
|
|
// keys are not updated there and index needs to kept so that we can monitor
|
|
// values individually here
|
|
if (
|
|
!(
|
|
secKey?.toUpperCase().includes(searchTerm?.toUpperCase()) ||
|
|
tags
|
|
?.map((tag) => tag.name)
|
|
.join(" ")
|
|
?.toUpperCase()
|
|
.includes(searchTerm?.toUpperCase()) ||
|
|
secComment?.toUpperCase().includes(searchTerm?.toUpperCase())
|
|
)
|
|
) {
|
|
return <></>;
|
|
}
|
|
|
|
return (
|
|
<tr className="group flex flex-row hover:bg-mineshaft-700" key={index}>
|
|
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
|
|
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div>
|
|
</td>
|
|
<Controller
|
|
control={control}
|
|
defaultValue=""
|
|
name={`secrets.${index}.key`}
|
|
render={({ field }) => (
|
|
<HoverCard openDelay={0} open={isKeyError ? undefined : false}>
|
|
<HoverCardTrigger asChild>
|
|
<td className={cx(isKeyError ? "rounded ring ring-red/50" : null)}>
|
|
<div className="relative flex w-full min-w-[220px] items-center justify-end lg:min-w-[240px] xl:min-w-[280px]">
|
|
<Input
|
|
autoComplete="off"
|
|
onFocus={() => {
|
|
isKeySubDisabled.current = true;
|
|
}}
|
|
variant="plain"
|
|
isDisabled={isReadOnly || shouldBeBlockedInAddOnly || isRollbackMode}
|
|
className="w-full focus:text-bunker-100 focus:ring-transparent"
|
|
{...field}
|
|
onBlur={() => {
|
|
isKeySubDisabled.current = false;
|
|
field.onBlur();
|
|
}}
|
|
autoCapitalization={autoCapitalization}
|
|
/>
|
|
</div>
|
|
</td>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent className="w-auto py-2 pt-2">
|
|
<div className="flex items-center space-x-2">
|
|
<div>
|
|
<FontAwesomeIcon icon={faInfoCircle} className="text-red" />
|
|
</div>
|
|
<div className="text-sm">{keyError}</div>
|
|
</div>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
)}
|
|
/>
|
|
<td
|
|
className="flex w-full flex-grow flex-row border-r border-none border-red"
|
|
style={{ padding: "0.5rem 0 0.5rem 1rem" }}
|
|
>
|
|
<div className="w-full">
|
|
{isOverridden ? (
|
|
<Controller
|
|
control={control}
|
|
key={`secrets.${index}.valueOverride`}
|
|
name={`secrets.${index}.valueOverride`}
|
|
render={({ field }) => (
|
|
<SecretInput
|
|
key={`secrets.${index}.valueOverride`}
|
|
isDisabled={
|
|
isReadOnly ||
|
|
isRollbackMode ||
|
|
(isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
|
|
}
|
|
isVisible={!isSecretValueHidden}
|
|
{...field}
|
|
/>
|
|
)}
|
|
/>
|
|
) : (
|
|
<Controller
|
|
control={control}
|
|
key={`secrets.${index}.value`}
|
|
name={`secrets.${index}.value`}
|
|
render={({ field }) => (
|
|
<SecretInput
|
|
key={`secrets.${index}.value`}
|
|
isVisible={!isSecretValueHidden}
|
|
isDisabled={
|
|
isReadOnly ||
|
|
isRollbackMode ||
|
|
(isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
|
|
}
|
|
{...field}
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="min-w-sm flex">
|
|
<div className="flex h-8 items-center pl-2">
|
|
{secretTags.map(({ id, slug, tagColor }) => {
|
|
return (
|
|
<>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<div>
|
|
<Tag
|
|
// isDisabled={isReadOnly || isAddOnly || isRollbackMode}
|
|
// onClose={() => remove(i)}
|
|
key={id}
|
|
className="cursor-pointer"
|
|
>
|
|
<div className="rounded-full border-mineshaft-500 bg-transparent flex items-center gap-1.5 justify-around">
|
|
<div
|
|
className="w-[10px] h-[10px] rounded-full"
|
|
style={{ background: tagColor || "#bec2c8" }}
|
|
/>
|
|
{slug}
|
|
</div>
|
|
</Tag>
|
|
</div>
|
|
</PopoverTrigger>
|
|
<AddTagPopoverContent
|
|
wsTags={wsTags}
|
|
secKey={secKey || "this secret"}
|
|
selectedTagIds={selectedTagIds}
|
|
handleSelectTag={(wsTag: WsTag) => onSelectTag(wsTag)}
|
|
handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)}
|
|
handleTagOnMouseLeave={() => handleTagOnMouseLeave()}
|
|
checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)}
|
|
handleOnCreateTagOpen={() => onCreateTagOpen()}
|
|
/>
|
|
</Popover>
|
|
</>
|
|
);
|
|
})}
|
|
<div className="w-0 overflow-hidden group-hover:w-6">
|
|
<Tooltip content="Copy value">
|
|
<IconButton
|
|
variant="plain"
|
|
size="md"
|
|
ariaLabel="add-tag"
|
|
className="py-[0.42rem]"
|
|
onClick={copyTokenToClipboard}
|
|
>
|
|
<FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</div>
|
|
{!(isReadOnly || isAddOnly || isRollbackMode) && (
|
|
<div className="duration-0 ml-1 overflow-hidden">
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<div className="w-0 group-hover:w-6 data-[state=open]:w-6">
|
|
<ProjectPermissionCan
|
|
renderTooltip
|
|
allowedLabel="Add Tags"
|
|
I={ProjectPermissionActions.Edit}
|
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
|
>
|
|
{(isAllowed) => (
|
|
<IconButton
|
|
isDisabled={!isAllowed}
|
|
variant="plain"
|
|
size="md"
|
|
ariaLabel="add-tags"
|
|
className="py-[0.42rem]"
|
|
>
|
|
<FontAwesomeIcon icon={faTags} />
|
|
</IconButton>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
</div>
|
|
</PopoverTrigger>
|
|
<AddTagPopoverContent
|
|
wsTags={wsTags}
|
|
secKey={secKey || "this secret"}
|
|
selectedTagIds={selectedTagIds}
|
|
handleSelectTag={(wsTag: WsTag) => onSelectTag(wsTag)}
|
|
handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)}
|
|
handleTagOnMouseLeave={() => handleTagOnMouseLeave()}
|
|
checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)}
|
|
handleOnCreateTagOpen={() => onCreateTagOpen()}
|
|
/>
|
|
</Popover>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex h-8 flex-row items-center pr-2">
|
|
{!isAddOnly && (
|
|
<div>
|
|
<ProjectPermissionCan
|
|
renderTooltip
|
|
allowedLabel="Override with a personal value"
|
|
I={ProjectPermissionActions.Edit}
|
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
|
>
|
|
{(isAllowed) => (
|
|
<IconButton
|
|
variant="plain"
|
|
className={twMerge(
|
|
"mt-0.5 w-0 overflow-hidden p-0 group-hover:ml-1 group-hover:w-7",
|
|
isOverridden && "ml-1 w-7 text-primary"
|
|
)}
|
|
onClick={onSecretOverride}
|
|
size="md"
|
|
isDisabled={isRollbackMode || isReadOnly || !isAllowed}
|
|
ariaLabel="info"
|
|
>
|
|
<div className="flex items-center space-x-1">
|
|
<FontAwesomeIcon icon={faCodeBranch} className="text-base" />
|
|
</div>
|
|
</IconButton>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
</div>
|
|
)}
|
|
<div className="mt-0.5 overflow-hidden ">
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<div>
|
|
<ProjectPermissionCan
|
|
renderTooltip
|
|
allowedLabel="Comment"
|
|
I={ProjectPermissionActions.Edit}
|
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
|
>
|
|
{(isAllowed) => (
|
|
<IconButton
|
|
className={twMerge(
|
|
"w-7 overflow-hidden p-0",
|
|
"w-0 group-hover:w-7 data-[state=open]:w-7",
|
|
hasComment ? "w-7 text-primary" : "group-hover:w-7"
|
|
)}
|
|
isDisabled={!isAllowed}
|
|
variant="plain"
|
|
size="md"
|
|
ariaLabel="add-comment"
|
|
>
|
|
<FontAwesomeIcon icon={faComment} />
|
|
</IconButton>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
|
|
sticky="always"
|
|
>
|
|
<FormControl label="Comment" className="mb-0">
|
|
<TextArea
|
|
isDisabled={isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly}
|
|
className="border border-mineshaft-600 text-sm"
|
|
{...register(`secrets.${index}.comment`)}
|
|
rows={8}
|
|
cols={30}
|
|
/>
|
|
</FormControl>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
<div className="duration-0 flex w-16 justify-center overflow-hidden border-l border-mineshaft-600 pl-2 transition-all">
|
|
<div className="flex h-8 items-center space-x-2.5">
|
|
{!isAddOnly && (
|
|
<div className="opacity-0 group-hover:opacity-100">
|
|
<Tooltip content="Settings">
|
|
<IconButton
|
|
size="lg"
|
|
colorSchema="primary"
|
|
variant="plain"
|
|
onClick={() => onRowExpand(secUniqId, index)}
|
|
ariaLabel="expand"
|
|
>
|
|
<FontAwesomeIcon icon={faEllipsis} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
<div className="opacity-0 group-hover:opacity-100">
|
|
<ProjectPermissionCan
|
|
I={ProjectPermissionActions.Delete}
|
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
|
renderTooltip
|
|
allowedLabel="Delete"
|
|
>
|
|
{(isAllowed) => (
|
|
<IconButton
|
|
size="lg"
|
|
variant="plain"
|
|
colorSchema="danger"
|
|
ariaLabel="delete"
|
|
isDisabled={isReadOnly || isRollbackMode || !isAllowed}
|
|
onClick={() => {
|
|
onSecretDelete(index, secKey, secId, idOverride);
|
|
}}
|
|
>
|
|
<FontAwesomeIcon icon={faXmark} />
|
|
</IconButton>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
);
|
|
|
|
SecretInputRow.displayName = "SecretInputRow";
|