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) => { // biome-ignore lint/suspicious/noExplicitAny: none 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, });