import type { EditorView } from '@codemirror/view'; import type { Color } from '@yaakapp/api'; import classNames from 'classnames'; import type { ReactNode } from 'react'; import { forwardRef, useCallback, useEffect, useImperativeHandle, 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/Editor'; 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; defaultValue?: string | null; disableObscureToggle?: boolean; fullHeight?: boolean; hideLabel?: boolean; inputWrapperClassName?: string; 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']; tint?: Color; type?: 'text' | 'password'; validate?: boolean | ((v: string) => boolean); wrapLines?: boolean; }; export const Input = forwardRef(function Input({ type, ...props }, ref) { // If it's a password and template functions are supported (ie. secure(...)) then // use the encrypted input component. if (type === 'password' && props.autocompleteFunctions) { return ; } else { return ; } }); const BaseInput = forwardRef(function InputBase( { 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, ...props }: InputProps, ref, ) { const [focused, setFocused] = useState(false); const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]); const [hasChanged, setHasChanged] = useStateWithDeps(false, [forceUpdateKey]); const editorRef = useRef(null); useImperativeHandle(ref, () => editorRef.current); const lastWindowFocus = useRef(0); useEffect(() => { const fn = () => (lastWindowFocus.current = Date.now()); window.addEventListener('focus', fn); return () => { window.removeEventListener('focus', fn); }; }, []); const handleFocus = useCallback(() => { if (readOnly) return; // Select all text of input when it's focused to match standard browser behavior. // This should not, however, select when the input is focused due to a window focus event, so // we handle that case as well. const windowJustFocused = Date.now() - lastWindowFocus.current < 200; if (!windowJustFocused) { editorRef.current?.dispatch({ selection: { anchor: 0, head: editorRef.current.state.doc.length }, }); } setFocused(true); onFocus?.(); }, [onFocus, readOnly]); const handleBlur = useCallback(() => { setFocused(false); // Move selection to the end on blur editorRef.current?.dispatch({ selection: { anchor: editorRef.current.state.doc.length }, }); 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, ...props }: Omit) { const isEncryptionEnabled = useIsEncryptionEnabled(); const [state, setState] = useStateWithDeps<{ fieldType: PasswordFieldType; value: string | null; security: ReturnType | null; obscured: boolean; error: string | null; }>({ fieldType: 'encrypted', value: null, security: null, obscured: true, error: null }, [ ogForceUpdateKey, ]); const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === 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 }); }, 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 }); } else if (isEncryptionEnabled) { // Don't obscure plain text when encryption is enabled setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: false, error: null, }); } else { // Don't obscure plain text when encryption is disabled setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: true, error: null, }); } }, [defaultValue, isEncryptionEnabled, 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 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 ? 'Reveal' : 'Conceal', 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, });