fix(multi-line): resolved breaking ui when secret value contains < or >

This commit is contained in:
Akhil Mohan
2023-08-24 23:07:58 +05:30
parent 8ee6710e9b
commit fccec083a9
3 changed files with 122 additions and 159 deletions
@@ -1,36 +1,35 @@
/* eslint-disable react/no-danger */ /* eslint-disable react/no-danger */
import { HTMLAttributes } from "react"; import { forwardRef, HTMLAttributes } from "react";
import ContentEditable from "react-contenteditable"; import ContentEditable from "react-contenteditable";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml, { DisallowedTagsModes } from "sanitize-html";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
const REGEX = /\${([^}]+)}/g; const REGEX = /\${([^}]+)}/g;
const stripSpanTags = (str: string) => str.replace(/<\/?span[^>]*>/g, "");
const replaceContentWithDot = (str: string) => { const replaceContentWithDot = (str: string) => {
let finalStr = ""; let finalStr = "";
let isHtml = false;
for (let i = 0; i < str.length; i += 1) { for (let i = 0; i < str.length; i += 1) {
const char = str.at(i); const char = str.at(i);
finalStr += char === "\n" ? "\n" : "&#8226;";
if (char === "<" || char === ">") {
isHtml = char === "<";
finalStr += char;
} else if (!isHtml && char !== "\n") {
finalStr += "&#8226;";
} else {
finalStr += char;
}
} }
return finalStr; return finalStr;
}; };
const syntaxHighlight = (orgContent?: string | null, isVisible?: boolean) => { const sanitizeConf = {
if (orgContent === "") return "EMPTY"; allowedTags: ["span"],
if (!orgContent) return "missing"; disallowedTagsMode: "escape" as DisallowedTagsModes
if (!isVisible) return replaceContentWithDot(orgContent); };
const content = stripSpanTags(orgContent);
const newContent = content.replace( const syntaxHighlight = (content?: string | null, isVisible?: boolean) => {
if (content === "") return "EMPTY";
if (!content) return "missing";
if (!isVisible) return replaceContentWithDot(content);
const sanitizedContent = sanitizeHtml(
content.replaceAll("<", "&lt;").replaceAll(">", "&gt;"),
sanitizeConf
);
const newContent = sanitizedContent.replace(
REGEX, REGEX,
(_a, b) => (_a, b) =>
`<span class="ph-no-capture text-yellow">&#36;&#123;<span class="ph-no-capture text-yello-200/80">${b}</span>&#125;</span>` `<span class="ph-no-capture text-yellow">&#36;&#123;<span class="ph-no-capture text-yello-200/80">${b}</span>&#125;</span>`
@@ -39,57 +38,58 @@ const syntaxHighlight = (orgContent?: string | null, isVisible?: boolean) => {
return newContent; return newContent;
}; };
const sanitizeConf = {
allowedTags: ["div", "span", "br", "p"]
};
type Props = Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "onBlur"> & { type Props = Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "onBlur"> & {
value?: string | null; value?: string | null;
isVisible?: boolean; isVisible?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
onChange?: (val: string, html: string) => void; onChange?: (val: string) => void;
onBlur?: (sanitizedHtml: string) => void; onBlur?: () => void;
}; };
export const SecretInput = ({ export const SecretInput = forwardRef<HTMLDivElement, Props>(
value, ({ value, isVisible, onChange, onBlur, isDisabled, ...props }, ref) => {
isVisible, const [isSecretFocused, setIsSecretFocused] = useToggle();
onChange,
onBlur,
isDisabled,
...props
}: Props) => {
const [isSecretFocused, setIsSecretFocused] = useToggle();
return ( return (
<div
className="thin-scrollbar relative overflow-y-auto overflow-x-hidden"
style={{ maxHeight: `${21 * 7}px` }}
>
<div <div
dangerouslySetInnerHTML={{ className="thin-scrollbar relative overflow-y-auto overflow-x-hidden"
__html: syntaxHighlight(value, isVisible || isSecretFocused) style={{ maxHeight: `${21 * 7}px` }}
}} >
className={`absolute top-0 left-0 z-0 h-full w-full text-ellipsis whitespace-pre-line break-all ${ <div
!value && value !== "" && "italic text-red-600/70" dangerouslySetInnerHTML={{
}`} __html: syntaxHighlight(value, isVisible || isSecretFocused)
/> }}
<ContentEditable className={`absolute top-0 left-0 z-0 h-full w-full inline-block text-ellipsis whitespace-pre-wrap break-all ${
className="relative z-10 h-full w-full text-ellipsis whitespace-pre-line break-all text-transparent caret-white outline-none" !value && value !== "" && "italic text-red-600/70"
role="textbox" }`}
onChange={(evt) => { ref={ref}
if (onChange) onChange(evt.currentTarget.innerText.trim(), evt.currentTarget.innerHTML); />
}} <ContentEditable
onFocus={() => setIsSecretFocused.on()} className="relative z-10 h-full w-full text-ellipsis inline-block whitespace-pre-wrap break-all text-transparent caret-white outline-none"
disabled={isDisabled} role="textbox"
spellCheck={false} onChange={(evt) => {
onBlur={(evt) => { if (onChange) onChange(evt.currentTarget.innerText.trim());
if (onBlur) onBlur(sanitizeHtml(evt.currentTarget.innerHTML || "", sanitizeConf)); }}
setIsSecretFocused.off(); onFocus={() => setIsSecretFocused.on()}
}} disabled={isDisabled}
html={isVisible || isSecretFocused ? value || "" : syntaxHighlight(value, false)} spellCheck={false}
{...props} onBlur={() => {
/> if (onBlur) onBlur();
</div> setIsSecretFocused.off();
); }}
}; html={
isVisible || isSecretFocused
? sanitizeHtml(
value?.replaceAll("<", "&lt;").replaceAll(">", "&gt;") || "",
sanitizeConf
)
: syntaxHighlight(value, false)
}
{...props}
/>
</div>
);
}
);
SecretInput.displayName = "SecretInput";
@@ -1,5 +1,5 @@
/* eslint-disable react/jsx-no-useless-fragment */ /* eslint-disable react/jsx-no-useless-fragment */
import { memo, useEffect,useRef, useState } from "react"; import { memo, useEffect, useRef, useState } from "react";
import { import {
Control, Control,
Controller, Controller,
@@ -32,7 +32,8 @@ import {
PopoverTrigger, PopoverTrigger,
SecretInput, SecretInput,
Tag, Tag,
Tooltip} from "@app/components/v2"; Tooltip
} from "@app/components/v2";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { WsTag } from "@app/hooks/api/types"; import { WsTag } from "@app/hooks/api/types";
@@ -83,7 +84,7 @@ export const SecretInputRow = memo(
isKeyError, isKeyError,
keyError, keyError,
secUniqId, secUniqId,
autoCapitalization, autoCapitalization
}: Props): JSX.Element => { }: Props): JSX.Element => {
const isKeySubDisabled = useRef<boolean>(false); const isKeySubDisabled = useRef<boolean>(false);
// comment management in a row // comment management in a row
@@ -94,7 +95,7 @@ export const SecretInputRow = memo(
} = useFieldArray({ control, name: `secrets.${index}.tags` }); } = useFieldArray({ control, name: `secrets.${index}.tags` });
// display the tags in alphabetical order // display the tags in alphabetical order
secretTags.sort((a, b) => a?.name?.localeCompare(b?.name)) secretTags.sort((a, b) => a?.name?.localeCompare(b?.name));
// to get details on a secret // to get details on a secret
const overrideAction = useWatch({ const overrideAction = useWatch({
@@ -127,47 +128,40 @@ export const SecretInputRow = memo(
const isOverridden = const isOverridden =
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified; overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
const [editorRef, setEditorRef] = useState(isOverridden ? secValueOverride : secValue);
const [hoveredTag, setHoveredTag] = useState<WsTag | null>(null); const [hoveredTag, setHoveredTag] = useState<WsTag | null>(null);
const handleTagOnMouseEnter = (wsTag: WsTag) => { const handleTagOnMouseEnter = (wsTag: WsTag) => {
setHoveredTag(wsTag); setHoveredTag(wsTag);
} };
const handleTagOnMouseLeave = () => { const handleTagOnMouseLeave = () => {
setHoveredTag(null); setHoveredTag(null);
} };
const checkIfTagIsVisible = (wsTag: WsTag) => wsTag._id === hoveredTag?._id; const checkIfTagIsVisible = (wsTag: WsTag) => wsTag._id === hoveredTag?._id;
const secId = useWatch({ control, name: `secrets.${index}._id`, exact: true }); const secId = useWatch({ control, name: `secrets.${index}._id`, exact: true });
const tags = useWatch({ control, name: `secrets.${index}.tags`, exact: true, defaultValue: [] }) || []; const tags =
useWatch({ control, name: `secrets.${index}.tags`, exact: true, defaultValue: [] }) || [];
const selectedTagIds = tags.reduce<Record<string, boolean>>( const selectedTagIds = tags.reduce<Record<string, boolean>>(
(prev, curr) => ({ ...prev, [curr.slug]: true }), (prev, curr) => ({ ...prev, [curr.slug]: true }),
{} {}
); );
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false); const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
useEffect(() => { useEffect(() => {
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
if (isInviteLinkCopied) { if (isSecValueCopied) {
timer = setTimeout(() => setInviteLinkCopied.off(), 2000); timer = setTimeout(() => setIsSecValueCopied.off(), 2000);
} }
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isInviteLinkCopied]); }, [isSecValueCopied]);
useEffect(() => {
setEditorRef(isOverridden ? secValueOverride : secValue);
}, [isOverridden]);
const copyTokenToClipboard = () => { const copyTokenToClipboard = () => {
navigator.clipboard.writeText((secValueOverride || secValue) as string); navigator.clipboard.writeText((secValueOverride || secValue) as string);
setInviteLinkCopied.on(); setIsSecValueCopied.on();
}; };
const onSecretOverride = () => { const onSecretOverride = () => {
@@ -191,8 +185,8 @@ export const SecretInputRow = memo(
const onSelectTag = (selectedTag: WsTag) => { const onSelectTag = (selectedTag: WsTag) => {
const shouldAppend = !selectedTagIds[selectedTag.slug]; const shouldAppend = !selectedTagIds[selectedTag.slug];
if (shouldAppend) { if (shouldAppend) {
const {_id: id, name, slug, tagColor} = selectedTag const { _id: id, name, slug, tagColor } = selectedTag;
append({_id: id, name, slug, tagColor}); append({ _id: id, name, slug, tagColor });
} else { } else {
const pos = tags.findIndex(({ slug }: { slug: string }) => selectedTag.slug === slug); const pos = tags.findIndex(({ slug }: { slug: string }) => selectedTag.slug === slug);
remove(pos); remove(pos);
@@ -272,7 +266,7 @@ export const SecretInputRow = memo(
<Controller <Controller
control={control} control={control}
name={`secrets.${index}.valueOverride`} name={`secrets.${index}.valueOverride`}
render={({ field: { onChange, onBlur } }) => ( render={({ field }) => (
<SecretInput <SecretInput
key={`secrets.${index}.valueOverride`} key={`secrets.${index}.valueOverride`}
isDisabled={ isDisabled={
@@ -280,16 +274,8 @@ export const SecretInputRow = memo(
isRollbackMode || isRollbackMode ||
(isOverridden ? isAddOnly : shouldBeBlockedInAddOnly) (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
} }
value={editorRef}
isVisible={!isSecretValueHidden} isVisible={!isSecretValueHidden}
onChange={(val, html) => { {...field}
onChange(val);
setEditorRef(html);
}}
onBlur={(html) => {
setEditorRef(html);
onBlur();
}}
/> />
)} )}
/> />
@@ -297,7 +283,7 @@ export const SecretInputRow = memo(
<Controller <Controller
control={control} control={control}
name={`secrets.${index}.value`} name={`secrets.${index}.value`}
render={({ field: { onBlur, onChange } }) => ( render={({ field }) => (
<SecretInput <SecretInput
key={`secrets.${index}.value`} key={`secrets.${index}.value`}
isVisible={!isSecretValueHidden} isVisible={!isSecretValueHidden}
@@ -306,15 +292,7 @@ export const SecretInputRow = memo(
isRollbackMode || isRollbackMode ||
(isOverridden ? isAddOnly : shouldBeBlockedInAddOnly) (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
} }
onChange={(val, html) => { {...field}
onChange(val);
setEditorRef(html);
}}
value={editorRef}
onBlur={(html) => {
setEditorRef(html);
onBlur();
}}
/> />
)} )}
/> />
@@ -323,38 +301,41 @@ export const SecretInputRow = memo(
</td> </td>
<td className="min-w-sm flex"> <td className="min-w-sm flex">
<div className="flex h-8 items-center pl-2"> <div className="flex h-8 items-center pl-2">
{secretTags.map(({ id, slug, tagColor}) => { {secretTags.map(({ id, slug, tagColor }) => {
return ( return (
<> <>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<div> <div>
<Tag <Tag
// isDisabled={isReadOnly || isAddOnly || isRollbackMode} // isDisabled={isReadOnly || isAddOnly || isRollbackMode}
// onClose={() => remove(i)} // onClose={() => remove(i)}
key={id} key={id}
className="cursor-pointer" className="cursor-pointer"
> >
<div className="rounded-full border-mineshaft-500 bg-transparent flex items-center gap-1.5 justify-around"> <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" }} /> <div
{slug} className="w-[10px] h-[10px] rounded-full"
</div> style={{ background: tagColor || "#bec2c8" }}
</Tag> />
</div> {slug}
</PopoverTrigger> </div>
<AddTagPopoverContent </Tag>
wsTags={wsTags} </div>
secKey={secKey || "this secret"} </PopoverTrigger>
selectedTagIds={selectedTagIds} <AddTagPopoverContent
handleSelectTag={(wsTag: WsTag) => onSelectTag(wsTag)} wsTags={wsTags}
handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)} secKey={secKey || "this secret"}
handleTagOnMouseLeave={() => handleTagOnMouseLeave()} selectedTagIds={selectedTagIds}
checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)} handleSelectTag={(wsTag: WsTag) => onSelectTag(wsTag)}
handleOnCreateTagOpen={() => onCreateTagOpen()} handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)}
/> handleTagOnMouseLeave={() => handleTagOnMouseLeave()}
</Popover> checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)}
</> handleOnCreateTagOpen={() => onCreateTagOpen()}
) />
</Popover>
</>
);
})} })}
<div className="w-0 overflow-hidden group-hover:w-6"> <div className="w-0 overflow-hidden group-hover:w-6">
<Tooltip content="Copy value"> <Tooltip content="Copy value">
@@ -365,7 +346,7 @@ export const SecretInputRow = memo(
className="py-[0.42rem]" className="py-[0.42rem]"
onClick={copyTokenToClipboard} onClick={copyTokenToClipboard}
> >
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} /> <FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</div> </div>
@@ -1,4 +1,3 @@
import { useRef } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -39,14 +38,11 @@ export const SecretEditRow = ({
value: defaultValue value: defaultValue
} }
}); });
const editorRef = useRef(defaultValue);
const [isDeleting, setIsDeleting] = useToggle(); const [isDeleting, setIsDeleting] = useToggle();
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const handleFormReset = () => { const handleFormReset = () => {
reset(); reset();
const val = getValues();
editorRef.current = val.value;
}; };
const handleCopySecretToClipboard = async () => { const handleCopySecretToClipboard = async () => {
@@ -78,7 +74,6 @@ export const SecretEditRow = ({
try { try {
await onSecretDelete(environment, secretName); await onSecretDelete(environment, secretName);
reset({ value: undefined }); reset({ value: undefined });
editorRef.current = undefined;
} finally { } finally {
setIsDeleting.off(); setIsDeleting.off();
} }
@@ -90,20 +85,7 @@ export const SecretEditRow = ({
<Controller <Controller
control={control} control={control}
name="value" name="value"
render={({ field: { onChange, onBlur } }) => ( render={({ field }) => <SecretInput {...field} isVisible={isVisible} />}
<SecretInput
value={editorRef.current}
onChange={(val, html) => {
onChange(val);
editorRef.current = html;
}}
onBlur={(html) => {
editorRef.current = html;
onBlur();
}}
isVisible={isVisible}
/>
)}
/> />
</div> </div>
<div className="flex w-16 justify-center space-x-3 pl-2 transition-all"> <div className="flex w-16 justify-center space-x-3 pl-2 transition-all">