import type { Color } from '@yaakapp/api'; import classNames from 'classnames'; import type { EditorView } from 'codemirror'; import type { ReactNode } from 'react'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled'; import { useStateWithDeps } from '../../hooks/useStateWithDeps'; import { analyzeTemplate, convertTemplateToInsecure, convertTemplateToSecure, } from '../../lib/encryption'; import { generateId } from '../../lib/generateId'; import { 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 { Label } from './Label'; import { HStack } from './Stacks'; export type InputProps = Pick< EditorProps, | 'language' | 'autocomplete' | 'forceUpdateKey' | 'disabled' | 'autoFocus' | 'autoSelect' | 'autocompleteVariables' | 'autocompleteFunctions' | 'onKeyDown' | 'readOnly' > & { className?: string; containerClassName?: string; defaultValue?: string | null; disableObscureToggle?: boolean; fullHeight?: boolean; hideLabel?: boolean; inputWrapperClassName?: string; 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?: '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, inputWrapperClassName, defaultValue, forceUpdateKey, fullHeight, hideLabel, label, labelClassName, labelPosition = 'top', leftSlot, onBlur, onChange, onFocus, onPaste, onPasteOverwrite, placeholder, required, rightSlot, wrapLines, size = 'md', type = 'text', disableObscureToggle, tint, validate, readOnly, stateKey, multiLine, disabled, ...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 handleFocus = useCallback(() => { if (readOnly) return; setFocused(true); // Select all text on focus editorRef.current?.dispatch({ selection: { anchor: 0, head: editorRef.current.state.doc.length }, }); 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; }>({ fieldType: 'encrypted', value: null, security: null, obscured: true }, [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 convertTemplateToInsecure(defaultValue ?? '').then((value) => { setState({ fieldType: 'encrypted', security, value, obscured: true }); }); } else if (isEncryptionEnabled && !defaultValue) { // Default to encrypted field for new encrypted inputs setState({ fieldType: 'encrypted', security, value: '', obscured: true }); } else if (isEncryptionEnabled) { // Don't obscure plain text when encryption is enabled setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: false }); } else { // Don't obscure plain text when encryption is disabled setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: true }); } }, [defaultValue, isEncryptionEnabled, setState, state.value]); const handleChange = useCallback( (value: string, fieldType: PasswordFieldType) => { if (fieldType === 'encrypted') { convertTemplateToSecure(value).then((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 }; }); }, [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 value' : 'Conceal value', disabled: isEncryptionEnabled && state.fieldType === 'text', leftSlot: , onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })), }, { type: 'separator' }, { label: state.fieldType === 'text' ? 'Encrypt Value' : 'Decrypt Value', leftSlot: , onSelect: () => handleFieldTypeChange(state.fieldType === 'text' ? 'encrypted' : 'text'), }, ], [handleFieldTypeChange, isEncryptionEnabled, setState, state.fieldType, state.obscured], ); 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'; return ( ); }