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 (
}
onClick={() => {
setupOrConfigureEncryption();
}}
>
{state.error.replace(/^Render Error: /i, '')}
);
}
return (
);
}
const templateToSecure = createFastMutation({
mutationKey: ['template-to-secure'],
mutationFn: convertTemplateToSecure,
});
const templateToInsecure = createFastMutation({
mutationKey: ['template-to-insecure'],
mutationFn: convertTemplateToInsecure,
disableToastError: true,
});