import type { EditorView } from "@codemirror/view"; import type { Color } from "@yaakapp-internal/plugins"; import classNames from "classnames"; import type { ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createFastMutation } from "../../hooks/useFastMutation"; import { useIsEncryptionEnabled } from "../../hooks/useIsEncryptionEnabled"; import { useStateWithDeps } from "../../hooks/useStateWithDeps"; import { copyToClipboard } from "../../lib/copy"; import { analyzeTemplate, convertTemplateToInsecure, convertTemplateToSecure, } from "../../lib/encryption"; import { generateId } from "../../lib/generateId"; import { setupOrConfigureEncryption, withEncryptionEnabled, } from "../../lib/setupOrConfigureEncryption"; import { Button } from "./Button"; import type { DropdownItem } from "./Dropdown"; import { Dropdown } from "./Dropdown"; import type { EditorProps } from "./Editor/Editor"; import { Editor } from "./Editor/LazyEditor"; import type { IconProps } from "./Icon"; import { Icon } from "./Icon"; import { IconButton } from "./IconButton"; import { IconTooltip } from "./IconTooltip"; import { Label } from "./Label"; import { HStack } from "./Stacks"; export type InputProps = Pick< EditorProps, | "language" | "autocomplete" | "forcedEnvironmentId" | "forceUpdateKey" | "disabled" | "autoFocus" | "autoSelect" | "autocompleteVariables" | "autocompleteFunctions" | "onKeyDown" | "readOnly" > & { className?: string; containerClassName?: string; inputWrapperClassName?: string; defaultValue?: string | null; disableObscureToggle?: boolean; fullHeight?: boolean; hideLabel?: boolean; help?: ReactNode; label: ReactNode; labelClassName?: string; labelPosition?: "top" | "left"; leftSlot?: ReactNode; multiLine?: boolean; name?: string; onBlur?: () => void; onChange?: (value: string) => void; onFocus?: () => void; onPaste?: (value: string) => void; onPasteOverwrite?: EditorProps["onPasteOverwrite"]; placeholder?: string; required?: boolean; rightSlot?: ReactNode; size?: "2xs" | "xs" | "sm" | "md" | "auto"; stateKey: EditorProps["stateKey"]; extraExtensions?: EditorProps["extraExtensions"]; tint?: Color; type?: "text" | "password"; validate?: boolean | ((v: string) => boolean); wrapLines?: boolean; setRef?: (h: InputHandle | null) => void; }; export interface InputHandle { focus: () => void; isFocused: () => boolean; value: () => string; selectAll: () => void; dispatch: EditorView["dispatch"]; } export function Input({ type, ...props }: InputProps) { // If it's a password and template functions are supported (ie. secure(...)) then // use the encrypted input component. if (type === "password" && props.autocompleteFunctions) { return ; } return ; } function BaseInput({ className, containerClassName, defaultValue, disableObscureToggle, disabled, forceUpdateKey, fullHeight, help, hideLabel, inputWrapperClassName, label, labelClassName, labelPosition = "top", leftSlot, multiLine, onBlur, onChange, onFocus, onPaste, onPasteOverwrite, placeholder, readOnly, required, rightSlot, size = "md", stateKey, tint, type = "text", validate, wrapLines, setRef, ...props }: InputProps) { const [focused, setFocused] = useState(false); const [obscured, setObscured] = useStateWithDeps(type === "password", [type]); const [hasChanged, setHasChanged] = useStateWithDeps(false, [forceUpdateKey]); const editorRef = useRef(null); const skipNextFocus = useRef(false); const handle = useMemo( () => ({ focus: () => { if (editorRef.current == null) return; const anchor = editorRef.current.state.doc.length; skipNextFocus.current = true; editorRef.current.focus(); editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true }); }, isFocused: () => editorRef.current?.hasFocus ?? false, value: () => editorRef.current?.state.doc.toString() ?? "", dispatch: (...args) => { // oxlint-disable-next-line no-explicit-any editorRef.current?.dispatch(...(args as any)); }, selectAll() { if (editorRef.current == null) return; editorRef.current.focus(); editorRef.current.dispatch({ selection: { anchor: 0, head: editorRef.current.state.doc.length }, }); }, }), [], ); const setEditorRef = useCallback( (h: EditorView | null) => { editorRef.current = h; setRef?.(handle); }, [handle, setRef], ); useEffect(() => { const fn = () => { skipNextFocus.current = true; }; window.addEventListener("focus", fn); return () => { window.removeEventListener("focus", fn); }; }, []); const handleFocus = useCallback(() => { if (readOnly) return; if (!skipNextFocus.current) { editorRef.current?.dispatch({ selection: { anchor: 0, head: editorRef.current.state.doc.length }, }); } setFocused(true); onFocus?.(); skipNextFocus.current = false; }, [onFocus, readOnly]); const handleBlur = useCallback(async () => { setFocused(false); // Move selection to the end on blur const anchor = editorRef.current?.state.doc.length ?? 0; editorRef.current?.dispatch({ selection: { anchor, head: anchor }, }); onBlur?.(); }, [onBlur]); const id = useRef(`input-${generateId()}`); const editorClassName = classNames( className, "!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder", ); const isValid = useMemo(() => { if (required && !validateRequire(defaultValue ?? "")) return false; if (typeof validate === "boolean") return validate; if (typeof validate === "function" && !validate(defaultValue ?? "")) return false; return true; }, [required, defaultValue, validate]); const handleChange = useCallback( (value: string) => { onChange?.(value); setHasChanged(true); }, [onChange, setHasChanged], ); const wrapperRef = useRef(null); // Submit the nearest form on Enter key press const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key !== "Enter") return; const form = wrapperRef.current?.closest("form"); if (!isValid || form == null) return; form?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true })); }, [isValid], ); return (
{tint != null && (
)} {leftSlot} {type === "password" && !disableObscureToggle && ( setObscured((o) => !o)} /> )} {rightSlot}
); } function validateRequire(v: string) { return v.length > 0; } type PasswordFieldType = "text" | "encrypted"; function EncryptionInput({ defaultValue, onChange, autocompleteFunctions, autocompleteVariables, forceUpdateKey: ogForceUpdateKey, setRef, ...props }: InputProps) { const isEncryptionEnabled = useIsEncryptionEnabled(); const [state, setState] = useStateWithDeps<{ fieldType: PasswordFieldType; value: string | null; security: ReturnType | null; obscured: boolean; error: string | null; }>( { fieldType: isEncryptionEnabled ? "encrypted" : "text", value: null, security: null, obscured: true, error: null, }, [ogForceUpdateKey], ); const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`; const inputRef = useRef(null); useEffect(() => { if (state.value != null) { // We already configured it return; } const security = analyzeTemplate(defaultValue ?? ""); if (analyzeTemplate(defaultValue ?? "") === "global_secured") { // Lazily update value to decrypted representation templateToInsecure.mutate(defaultValue ?? "", { onSuccess: (value) => { setState({ fieldType: "encrypted", security, value, obscured: true, error: null }); // We're calling this here because we want the input to be fully initialized so the caller // can do stuff like change the selection. requestAnimationFrame(() => setRef?.(inputRef.current)); }, onError: (value) => { setState({ fieldType: "encrypted", security, value: null, error: String(value), obscured: true, }); }, }); } else if (isEncryptionEnabled && !defaultValue) { // Default to encrypted field for new encrypted inputs setState({ fieldType: "encrypted", security, value: "", obscured: true, error: null }); requestAnimationFrame(() => setRef?.(inputRef.current)); } else if (isEncryptionEnabled) { // Don't obscure plain text when encryption is enabled setState({ fieldType: "text", security, value: defaultValue ?? "", obscured: false, error: null, }); requestAnimationFrame(() => setRef?.(inputRef.current)); } else { // Don't obscure plain text when encryption is disabled setState({ fieldType: "text", security, value: defaultValue ?? "", obscured: true, error: null, }); requestAnimationFrame(() => setRef?.(inputRef.current)); } }, [defaultValue, isEncryptionEnabled, setRef, setState, state.value]); const handleChange = useCallback( (value: string, fieldType: PasswordFieldType) => { if (fieldType === "encrypted") { templateToSecure.mutate(value, { onSuccess: (value) => onChange?.(value) }); } else { onChange?.(value); } setState((s) => { // We can't analyze when encrypted because we don't have the raw value, so assume it's secured const security = fieldType === "encrypted" ? "global_secured" : analyzeTemplate(value); // Reset obscured value when the field type is being changed const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== "text"; return { fieldType, value, security, obscured, error: s.error }; }); }, [onChange, setState], ); const handleInputChange = useCallback( (value: string) => { if (state.fieldType != null) { handleChange(value, state.fieldType); } }, [handleChange, state], ); const setInputRef = useCallback((h: InputHandle | null) => { inputRef.current = h; }, []); const handleFieldTypeChange = useCallback( (newFieldType: PasswordFieldType) => { const { value, fieldType } = state; if (value == null || fieldType === newFieldType) { return; } withEncryptionEnabled(async () => { const newValue = await convertTemplateToInsecure(value); handleChange(newValue, newFieldType); }); }, [handleChange, state], ); const dropdownItems = useMemo( () => [ { label: state.obscured ? "Show" : "Hide", disabled: isEncryptionEnabled && state.fieldType === "text", leftSlot: , onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })), }, { label: "Copy", leftSlot: , hidden: !state.value, onSelect: () => copyToClipboard(state.value ?? ""), }, { type: "separator" }, { label: state.fieldType === "text" ? "Encrypt Field" : "Decrypt Field", leftSlot: , onSelect: () => handleFieldTypeChange(state.fieldType === "text" ? "encrypted" : "text"), }, ], [ handleFieldTypeChange, isEncryptionEnabled, setState, state.fieldType, state.obscured, state.value, ], ); let tint: InputProps["tint"]; if (!isEncryptionEnabled) { tint = undefined; } else if (state.fieldType === "encrypted") { tint = "info"; } else if (state.security === "local_secured") { tint = "secondary"; } else if (state.security === "insecure") { tint = "notice"; } const rightSlot = useMemo(() => { let icon: IconProps["icon"]; if (isEncryptionEnabled) { icon = state.security === "insecure" ? "shield_off" : "shield_check"; } else { icon = state.obscured ? "eye_closed" : "eye"; } return ( ); }, [dropdownItems, isEncryptionEnabled, props.disabled, state.obscured, state.security, tint]); const type = state.obscured ? "password" : "text"; if (state.error) { return ( ); } return ( ); } const templateToSecure = createFastMutation({ mutationKey: ["template-to-secure"], mutationFn: convertTemplateToSecure, }); const templateToInsecure = createFastMutation({ mutationKey: ["template-to-insecure"], mutationFn: convertTemplateToInsecure, disableToastError: true, });