[WIP] Encryption for secure values (#183)

This commit is contained in:
Gregory Schier
2025-04-15 07:18:26 -07:00
committed by GitHub
parent e114a85c39
commit 2e55a1bd6d
208 changed files with 4063 additions and 28698 deletions

View File

@@ -0,0 +1,6 @@
import type { ButtonProps } from './Button';
import { Button } from './Button';
export function BadgeButton(props: ButtonProps) {
return <Button size="2xs" variant="border" className="!rounded-full mx-1" {...props} />;
}

View File

@@ -1,13 +1,13 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
export interface BannerProps {
children: ReactNode;
className?: string;
color?: 'primary' | 'secondary' | 'success' | 'notice' | 'warning' | 'danger' | 'info';
}
export function Banner({ children, className, color }: Props) {
export function Banner({ children, className, color }: BannerProps) {
return (
<div className="w-full mb-auto grid grid-rows-1 max-h-full">
<div

View File

@@ -1,6 +1,7 @@
import classNames from 'classnames';
import { type ReactNode } from 'react';
import { Icon } from './Icon';
import { IconTooltip } from './IconTooltip';
import { HStack } from './Stacks';
export interface CheckboxProps {
@@ -12,6 +13,7 @@ export interface CheckboxProps {
inputWrapperClassName?: string;
hideLabel?: boolean;
fullWidth?: boolean;
help?: ReactNode;
}
export function Checkbox({
@@ -23,9 +25,15 @@ export function Checkbox({
title,
hideLabel,
fullWidth,
help,
}: CheckboxProps) {
return (
<HStack as="label" space={2} className={classNames(className, 'text-text mr-auto')}>
<HStack
as="label"
alignItems="center"
space={2}
className={classNames(className, 'text-text mr-auto')}
>
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex')}>
<input
aria-hidden
@@ -51,6 +59,7 @@ export function Checkbox({
<div className={classNames(fullWidth && 'w-full', disabled && 'opacity-disabled')}>
{!hideLabel && title}
</div>
{help && <IconTooltip content={help} />}
</HStack>
);
}

View File

@@ -75,6 +75,7 @@ export function Dialog({
'relative bg-surface pointer-events-auto',
'rounded-lg',
'border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]',
'min-h-[10rem]',
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]',
size === 'sm' && 'w-[28rem]',
size === 'md' && 'w-[45rem]',
@@ -88,15 +89,15 @@ export function Dialog({
{title}
</Heading>
) : (
<span />
<span aria-hidden />
)}
{description ? (
<div className="px-6 text-text-subtle" id={descriptionId}>
<div className="px-6 text-text-subtle mb-3" id={descriptionId}>
{description}
</div>
) : (
<span />
<span aria-hidden />
)}
<div

View File

@@ -0,0 +1,32 @@
import classNames from 'classnames';
import { useKeyValue } from '../../hooks/useKeyValue';
import type { BannerProps } from './Banner';
import { Banner } from './Banner';
import { IconButton } from './IconButton';
export function DismissibleBanner({
children,
className,
id,
...props
}: BannerProps & { id: string }) {
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
namespace: 'global',
key: ['dismiss-banner', id],
fallback: false,
});
if (dismissed) return null;
return (
<Banner className={classNames(className, 'relative pr-8')} {...props}>
<IconButton
className="!absolute right-0 top-0"
icon="x"
onClick={() => setDismissed((d) => !d)}
title="Dismiss message"
/>
{children}
</Banner>
);
}

View File

@@ -134,30 +134,31 @@
.cm-searchMatch {
@apply bg-transparent !important;
@apply rounded-[2px] outline outline-1;
&.cm-searchMatch-selected {
@apply outline-text;
@apply bg-text !important;
&, * {
@apply text-surface font-semibold !important;
}
}
}
/*.cm-searchMatch {*/
/* @apply bg-transparent !important;*/
/* @apply outline outline-[1.5px] outline-text-subtlest rounded-sm;*/
/* &.cm-searchMatch-selected {*/
/* @apply outline-text;*/
/* & * {*/
/* @apply text-text font-semibold;*/
/* }*/
/* }*/
/*}*/
/* Obscure text for password fields */
.cm-wrapper.cm-obscure-text .cm-line {
-webkit-text-security: disc;
}
/* Obscure text for password fields */
.cm-wrapper.cm-obscure-text .cm-line {
-webkit-text-security: disc;
.cm-placeholder {
-webkit-text-security: none;
}
}
.cm-editor .cm-gutterElement {
@apply flex items-center;
transition: color var(--transition-duration);

View File

@@ -6,12 +6,13 @@ import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/vie
import { emacs } from '@replit/codemirror-emacs';
import { vim } from '@replit/codemirror-vim';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import type {EditorKeymap, EnvironmentVariable} from '@yaakapp-internal/models';
import { settingsAtom} from '@yaakapp-internal/models';
import type { EditorKeymap, EnvironmentVariable } from '@yaakapp-internal/models';
import { settingsAtom } from '@yaakapp-internal/models';
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
import { parseTemplate } from '@yaakapp-internal/templates';
import classNames from 'classnames';
import { EditorView } from 'codemirror';
import {useAtomValue} from "jotai";
import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import type { MutableRefObject, ReactNode } from 'react';
import {
@@ -26,14 +27,15 @@ import {
useRef,
} from 'react';
import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironmentVariables';
import { parseTemplate } from '../../../hooks/useParseTemplate';
import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
import { showDialog } from '../../../lib/dialog';
import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton';
import { InlineCode } from '../InlineCode';
import { HStack } from '../Stacks';
import './Editor.css';
import {
@@ -203,7 +205,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
useEffect(
function configurePlaceholder() {
if (cm.current === null) return;
const ext = placeholderExt(placeholderElFromText(placeholder, type));
const ext = placeholderExt(placeholderElFromText(placeholder));
const effects = placeholderCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
@@ -265,32 +267,39 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
showDialog({
id: 'template-function-'+Math.random(), // Allow multiple at once
size: 'sm',
title: 'Configure Function',
description: fn.description,
render: ({ hide }) => (
<TemplateFunctionDialog
templateFunction={fn}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
const initialTokens = parseTemplate(tagValue);
const show = () =>
showDialog({
id: 'template-function-' + Math.random(), // Allow multiple at once
size: 'sm',
title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description,
render: ({ hide }) => (
<TemplateFunctionDialog
templateFunction={fn}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
if (fn.name === 'secure') {
withEncryptionEnabled(show);
} else {
show();
}
},
[],
);
const onClickVariable = useCallback(
async (_v: EnvironmentVariable, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
const initialTokens = parseTemplate(tagValue);
showDialog({
size: 'dynamic',
id: 'template-variable',
@@ -313,7 +322,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const onClickMissingVariable = useCallback(
async (_name: string, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
const initialTokens = parseTemplate(tagValue);
showDialog({
size: 'dynamic',
id: 'template-variable',
@@ -398,9 +407,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const extensions = [
languageCompartment.of(langExt),
placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder, type)),
),
placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
tabIndentCompartment.current.of(
!disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
@@ -639,17 +646,11 @@ function getExtensions({
];
}
const placeholderElFromText = (text: string | undefined, type: EditorProps['type']) => {
const placeholderElFromText = (text: string | undefined) => {
const el = document.createElement('div');
if (type === 'password') {
// Will be obscured (dots) so just needs to be something to take up space
el.innerHTML = 'something-cool';
el.setAttribute('aria-hidden', 'true');
} else {
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
// to do with how Yaak "hacks" it with CSS for single line input.
el.innerHTML = text ? text.replaceAll('\n', '<br/>') : ' ';
}
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
// to do with how Yaak "hacks" it with CSS for single line input.
el.innerHTML = text ? text.replaceAll('\n', '<br/>') : ' ';
return el;
};

View File

@@ -11,7 +11,7 @@ export function Heading({ className, level = 1, ...props }: Props) {
<Component
className={classNames(
className,
'font-semibold text',
'font-semibold text-text',
level === 1 && 'text-2xl',
level === 2 && 'text-xl',
level === 3 && 'text-lg',

View File

@@ -56,8 +56,8 @@ const icons = {
git_branch_plus: lucide.GitBranchPlusIcon,
git_commit: lucide.GitCommitIcon,
git_commit_vertical: lucide.GitCommitVerticalIcon,
git_pull_request: lucide.GitPullRequestIcon,
git_fork: lucide.GitForkIcon,
git_pull_request: lucide.GitPullRequestIcon,
grip_vertical: lucide.GripVerticalIcon,
hand: lucide.HandIcon,
help: lucide.CircleHelpIcon,
@@ -65,6 +65,7 @@ const icons = {
house: lucide.HomeIcon,
import: lucide.ImportIcon,
info: lucide.InfoIcon,
key_round: lucide.KeyRoundIcon,
keyboard: lucide.KeyboardIcon,
left_panel_hidden: lucide.PanelLeftOpenIcon,
left_panel_visible: lucide.PanelLeftCloseIcon,
@@ -87,6 +88,9 @@ const icons = {
search: lucide.SearchIcon,
send_horizontal: lucide.SendHorizonalIcon,
settings: lucide.SettingsIcon,
shield: lucide.ShieldIcon,
shield_check: lucide.ShieldCheckIcon,
shield_off: lucide.ShieldOffIcon,
sparkles: lucide.SparklesIcon,
sun: lucide.SunIcon,
table: lucide.TableIcon,
@@ -126,7 +130,7 @@ export const Icon = memo(function Icon({
title={title}
className={classNames(
className,
'flex-shrink-0',
'flex-shrink-0 transform-cpu',
size === 'xl' && 'h-6 w-6',
size === 'lg' && 'h-5 w-5',
size === 'md' && 'h-4 w-4',
@@ -134,7 +138,7 @@ export const Icon = memo(function Icon({
size === 'xs' && 'h-3 w-3',
size === '2xs' && 'h-2.5 w-2.5',
color === 'default' && 'inherit',
color === 'danger' && 'text-danger',
color === 'danger' && 'text-danger!',
color === 'warning' && 'text-warning',
color === 'notice' && 'text-notice',
color === 'info' && 'text-info',

View File

@@ -74,10 +74,11 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
size={iconSize}
icon={confirmed ? 'check' : icon}
spin={spin}
color={confirmed ? 'success' : iconColor}
color={iconColor}
className={classNames(
iconClassName,
'group-hover/button:text-text',
confirmed && '!text-success', // Don't use Icon.color here because it won't override the hover color
props.disabled && 'opacity-70',
)}
/>

View File

@@ -0,0 +1,19 @@
import React from 'react';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import type { TooltipProps } from './Tooltip';
import { Tooltip } from './Tooltip';
type Props = Omit<TooltipProps, 'children'> & {
icon?: IconProps['icon'];
iconSize?: IconProps['size'];
className?: string;
};
export function IconTooltip({ content, icon = 'info', iconSize, ...tooltipProps }: Props) {
return (
<Tooltip content={content} {...tooltipProps}>
<Icon className="opacity-60 hover:opacity-100" icon={icon} size={iconSize} />
</Tooltip>
);
}

View File

@@ -1,11 +1,32 @@
import type { Color } from '@yaakapp/api';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { ReactNode } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } 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';
@@ -23,34 +44,46 @@ export type InputProps = Pick<
| 'onKeyDown'
| 'readOnly'
> & {
name?: string;
type?: 'text' | 'password';
label: ReactNode;
hideLabel?: boolean;
labelPosition?: 'top' | 'left';
labelClassName?: string;
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;
onBlur?: () => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: EditorProps['onPasteOverwrite'];
defaultValue?: string;
leftSlot?: ReactNode;
placeholder?: string;
required?: boolean;
rightSlot?: ReactNode;
size?: 'xs' | 'sm' | 'md' | 'auto';
className?: string;
placeholder?: string;
validate?: boolean | ((v: string) => boolean);
required?: boolean;
wrapLines?: boolean;
multiLine?: boolean;
fullHeight?: boolean;
stateKey: EditorProps['stateKey'];
tint?: Color;
type?: 'text' | 'password';
validate?: boolean | ((v: string) => boolean);
wrapLines?: boolean;
};
export const Input = forwardRef<EditorView, InputProps>(function Input(
export const Input = forwardRef<EditorView, InputProps>(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 <EncryptionInput {...props} />;
} else {
return <BaseInput ref={ref} type={type} {...props} />;
}
});
const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
{
className,
containerClassName,
@@ -74,6 +107,8 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
wrapLines,
size = 'md',
type = 'text',
disableObscureToggle,
tint,
validate,
readOnly,
stateKey,
@@ -83,11 +118,11 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
}: InputProps,
ref,
) {
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [stateKey, forceUpdateKey]);
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
const editorRef = useRef<EditorView | null>(null);
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
const handleFocus = useCallback(() => {
@@ -116,15 +151,14 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
);
const isValid = useMemo(() => {
if (required && !validateRequire(currentValue)) return false;
if (required && !validateRequire(defaultValue ?? '')) return false;
if (typeof validate === 'boolean') return validate;
if (typeof validate === 'function' && !validate(currentValue)) return false;
if (typeof validate === 'function' && !validate(defaultValue ?? '')) return false;
return true;
}, [required, currentValue, validate]);
}, [required, defaultValue, validate]);
const handleChange = useCallback(
(value: string) => {
setCurrentValue(value);
onChange?.(value);
setHasChanged(true);
},
@@ -171,7 +205,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
containerClassName,
fullHeight && 'h-full',
'x-theme-input',
'relative w-full rounded-md text',
'relative w-full rounded-md text overflow-hidden',
'border',
focused && !disabled ? 'border-border-focus' : 'border-border',
disabled && 'border-dotted',
@@ -181,6 +215,21 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
size === 'xs' && 'min-h-xs',
)}
>
{tint != null && (
<div
aria-hidden
className={classNames(
'absolute inset-0 opacity-5 pointer-events-none',
tint === 'primary' && 'bg-primary',
tint === 'secondary' && 'bg-secondary',
tint === 'info' && 'bg-info',
tint === 'success' && 'bg-success',
tint === 'notice' && 'bg-notice',
tint === 'warning' && 'bg-warning',
tint === 'danger' && 'bg-danger',
)}
/>
)}
{leftSlot}
<HStack
className={classNames(
@@ -219,15 +268,21 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
{...props}
/>
</HStack>
{type === 'password' && (
{type === 'password' && !disableObscureToggle && (
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"
className={classNames(
'mr-0.5 group/obscure !h-auto my-0.5',
disabled && 'opacity-disabled',
)}
iconClassName="group-hover/obscure:text"
className={classNames('mr-0.5 !h-auto my-0.5', disabled && 'opacity-disabled')}
color={tint}
// iconClassName={classNames(
// tint === 'primary' && 'text-primary',
// tint === 'secondary' && 'text-secondary',
// tint === 'info' && 'text-info',
// tint === 'success' && 'text-success',
// tint === 'notice' && 'text-notice',
// tint === 'warning' && 'text-warning',
// tint === 'danger' && 'text-danger',
// )}
iconSize="sm"
icon={obscured ? 'eye' : 'eye_closed'}
onClick={() => setObscured((o) => !o)}
@@ -242,3 +297,167 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
function validateRequire(v: string) {
return v.length > 0;
}
type PasswordFieldType = 'text' | 'encrypted';
function EncryptionInput({
defaultValue,
onChange,
autocompleteFunctions,
autocompleteVariables,
forceUpdateKey: ogForceUpdateKey,
...props
}: Omit<InputProps, 'type'>) {
const isEncryptionEnabled = useIsEncryptionEnabled();
const [state, setState] = useStateWithDeps<{
fieldType: PasswordFieldType;
value: string | null;
security: ReturnType<typeof analyzeTemplate> | 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<DropdownItem[]>(
() => [
{
label: state.obscured ? 'Reveal value' : 'Conceal value',
disabled: isEncryptionEnabled && state.fieldType === 'text',
leftSlot: <Icon icon={state.obscured ? 'eye' : 'eye_closed'} />,
onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })),
},
{ type: 'separator' },
{
label: state.fieldType === 'text' ? 'Encrypt Value' : 'Decrypt Value',
leftSlot: <Icon icon={state.fieldType === 'text' ? 'lock' : 'lock_open'} />,
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 (
<HStack className="h-auto m-0.5">
<Dropdown items={dropdownItems}>
<Button
size="sm"
variant="border"
color={tint}
aria-label="Configure encryption"
className={classNames(
'flex items-center justify-center !h-full !px-1',
'opacity-70', // Makes it a bit subtler
props.disabled && '!opacity-disabled',
)}
>
<HStack space={0.5}>
<Icon size="sm" title="Configure encryption" icon={icon} />
<Icon size="xs" title="Configure encryption" icon="chevron_down" />
</HStack>
</Button>
</Dropdown>
</HStack>
);
}, [dropdownItems, isEncryptionEnabled, props.disabled, state.obscured, state.security, tint]);
const type = state.obscured ? 'password' : 'text';
return (
<BaseInput
disableObscureToggle
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
defaultValue={state.value ?? ''}
forceUpdateKey={forceUpdateKey}
onChange={handleInputChange}
tint={tint}
type={type}
rightSlot={rightSlot}
{...props}
/>
);
}

View File

@@ -1,5 +1,6 @@
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import type { HTMLAttributes, ReactNode } from 'react';
import { IconTooltip } from './IconTooltip';
export function Label({
htmlFor,
@@ -8,21 +9,24 @@ export function Label({
visuallyHidden,
tags = [],
required,
help,
...props
}: HTMLAttributes<HTMLLabelElement> & {
htmlFor: string;
htmlFor: string | null;
required?: boolean;
tags?: string[];
visuallyHidden?: boolean;
children: ReactNode;
help?: ReactNode;
}) {
return (
<label
htmlFor={htmlFor}
htmlFor={htmlFor ?? undefined}
className={classNames(
className,
visuallyHidden && 'sr-only',
'flex-shrink-0 text-sm',
'text-text-subtle whitespace-nowrap flex items-center gap-1',
'text-text-subtle whitespace-nowrap flex items-center gap-1 mb-0.5',
)}
{...props}
>
@@ -35,6 +39,7 @@ export function Label({
({tag})
</span>
))}
{help && <IconTooltip content={help} />}
</label>
);
}

View File

@@ -55,7 +55,7 @@ export type PairEditorProps = {
valueAutocompleteFunctions?: boolean;
valueAutocompleteVariables?: boolean;
valuePlaceholder?: string;
valueType?: 'text' | 'password';
valueType?: InputProps['type'] | ((pair: Pair) => InputProps['type']);
valueValidate?: InputProps['validate'];
};
@@ -78,7 +78,6 @@ const MAX_INITIAL_PAIRS = 50;
export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function PairEditor(
{
stateKey,
allowFileValues,
allowMultilineValues,
className,
@@ -91,6 +90,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
noScroll,
onChange,
pairs: originalPairs,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
@@ -124,7 +124,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
const p = originalPairs[i];
if (!p) continue; // Make TS happy
if (isPairEmpty(p)) continue;
newPairs.push({ ...p, id: p.id ?? generateId() });
newPairs.push(ensurePairId(p));
}
// Add empty last pair if there is none
@@ -555,7 +555,7 @@ function PairEditorRow({
name={`value[${index}]`}
onChange={handleChangeValueText}
onFocus={handleFocus}
type={isLast ? 'text' : valueType}
type={isLast ? 'text' : typeof valueType === 'function' ? valueType(pair) : valueType}
placeholder={valuePlaceholder ?? 'value'}
autocomplete={valueAutocomplete?.(pair.name)}
autocompleteFunctions={valueAutocompleteFunctions}
@@ -615,7 +615,7 @@ function FileActionsDropdown({
[onChangeFile, onChangeText],
);
const extraItems = useMemo<DropdownItem[]>(
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
label: 'Edit Multi-Line',
@@ -664,7 +664,7 @@ function FileActionsDropdown({
value={pair.isFile ? 'file' : 'text'}
onChange={onChange}
items={fileItems}
extraItems={extraItems}
itemsAfter={itemsAfter}
>
<IconButton iconSize="sm" size="xs" icon="chevron_down" title="Select form data type" />
</RadioDropdown>
@@ -672,12 +672,7 @@ function FileActionsDropdown({
}
function emptyPair(): PairWithId {
return {
enabled: true,
name: '',
value: '',
id: generateId(),
};
return ensurePairId({ enabled: true, name: '', value: '' });
}
function isPairEmpty(pair: Pair): boolean {
@@ -723,3 +718,12 @@ function MultilineEditDialog({
</div>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function ensurePairId(p: Pair): PairWithId {
if (typeof p.id === 'string') {
return p as PairWithId;
} else {
return { ...p, id: p.id ?? generateId() };
}
}

View File

@@ -12,6 +12,7 @@ export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type
onFocusRaw?: HTMLAttributes<HTMLInputElement>['onFocus'];
type?: 'text' | 'password' | 'number';
step?: number;
hideObscureToggle?: boolean;
};
export function PlainInput({
@@ -31,8 +32,10 @@ export function PlainInput({
onPaste,
required,
rightSlot,
hideObscureToggle,
size = 'md',
type = 'text',
tint,
validate,
autoSelect,
placeholder,
@@ -115,6 +118,16 @@ export function PlainInput({
size === 'xs' && 'min-h-xs',
)}
>
{tint != null && (
<div
aria-hidden
className={classNames(
'absolute inset-0 opacity-5 pointer-events-none',
tint === 'info' && 'bg-info',
tint === 'warning' && 'bg-warning',
)}
/>
)}
{leftSlot}
<HStack
className={classNames(
@@ -128,7 +141,7 @@ export function PlainInput({
key={forceUpdateKey}
id={id}
type={type === 'password' && !obscured ? 'text' : type}
defaultValue={defaultValue}
defaultValue={defaultValue ?? undefined}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
@@ -143,7 +156,7 @@ export function PlainInput({
onKeyDownCapture={onKeyDownCapture}
/>
</HStack>
{type === 'password' && (
{type === 'password' && !hideObscureToggle && (
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"

View File

@@ -17,20 +17,23 @@ export type RadioDropdownItem<T = string | null> =
export interface RadioDropdownProps<T = string | null> {
value: T;
onChange: (value: T) => void;
itemsBefore?: DropdownItem[];
items: RadioDropdownItem<T>[];
extraItems?: DropdownItem[];
itemsAfter?: DropdownItem[];
children: DropdownProps['children'];
}
export function RadioDropdown<T = string | null>({
value,
items,
extraItems,
itemsAfter,
itemsBefore,
onChange,
children,
}: RadioDropdownProps<T>) {
const dropdownItems = useMemo(
() => [
...((itemsBefore ? [...itemsBefore, { type: 'separator' }] : []) as DropdownItem[]),
...items.map((item) => {
if (item.type === 'separator') {
return item;
@@ -44,9 +47,9 @@ export function RadioDropdown<T = string | null>({
} as DropdownItem;
}
}),
...((extraItems ? [{ type: 'separator' }, ...extraItems] : []) as DropdownItem[]),
...((itemsAfter ? [{ type: 'separator' }, ...itemsAfter] : []) as DropdownItem[]),
],
[items, extraItems, value, onChange],
[itemsBefore, items, itemsAfter, value, onChange],
);
return (

View File

@@ -10,9 +10,15 @@ interface Props<T extends string> {
onChange: (value: T) => void;
value: T;
name: string;
className?: string;
}
export function SegmentedControl<T extends string>({ value, onChange, options }: Props<T>) {
export function SegmentedControl<T extends string>({
value,
onChange,
options,
className,
}: Props<T>) {
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
const containerRef = useRef<HTMLDivElement>(null);
return (
@@ -21,7 +27,12 @@ export function SegmentedControl<T extends string>({ value, onChange, options }:
role="group"
dir="ltr"
space={0.5}
className="bg-surface-highlight rounded-md mb-auto opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100 transition-opacity transform-gpu"
className={classNames(
className,
'bg-surface-highlight rounded-md mb-auto opacity-0',
'transition-opacity transform-gpu',
'group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100',
)}
onKeyDown={(e) => {
const selectedIndex = options.findIndex((o) => o.value === selectedValue);
if (e.key === 'ArrowRight') {

View File

@@ -144,7 +144,7 @@ export function SplitLayout({
const containerQueryReady = size.width > 0 || size.height > 0;
return (
<div ref={containerRef} style={styles} className={classNames(className, 'grid w-full h-full')}>
<div ref={containerRef} style={styles} className={classNames(className, 'grid w-full h-full overflow-hidden')}>
{containerQueryReady && (
<>
{firstSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}

View File

@@ -0,0 +1,135 @@
import classNames from 'classnames';
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react';
import React, { useRef, useState } from 'react';
import { generateId } from '../../lib/generateId';
import { Portal } from '../Portal';
export interface TooltipProps {
children: ReactNode;
content: ReactNode;
size?: 'md' | 'lg';
}
const hiddenStyles: CSSProperties = {
left: -99999,
top: -99999,
visibility: 'hidden',
pointerEvents: 'none',
opacity: 0,
};
export function Tooltip({ children, content, size = 'md' }: TooltipProps) {
const [isOpen, setIsOpen] = useState<CSSProperties>();
const triggerRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const showTimeout = useRef<NodeJS.Timeout>();
const handleOpenImmediate = () => {
if (triggerRef.current == null || tooltipRef.current == null) return;
clearTimeout(showTimeout.current);
setIsOpen(undefined);
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const docRect = document.documentElement.getBoundingClientRect();
const styles: CSSProperties = {
bottom: docRect.height - triggerRect.top,
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
maxHeight: triggerRect.top,
};
setIsOpen(styles);
};
const handleOpen = () => {
clearTimeout(showTimeout.current);
showTimeout.current = setTimeout(handleOpenImmediate, 500);
};
const handleClose = () => {
clearTimeout(showTimeout.current);
setIsOpen(undefined);
};
const handleToggleImmediate = () => {
if (isOpen) handleClose();
else handleOpenImmediate();
};
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (isOpen && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
handleClose();
}
};
const id = useRef(`tooltip-${generateId()}`);
return (
<>
<Portal name="tooltip">
<div
ref={tooltipRef}
style={isOpen ?? hiddenStyles}
id={id.current}
role="tooltip"
aria-hidden={!isOpen}
onMouseEnter={handleOpenImmediate}
onMouseLeave={handleClose}
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
>
<div
className={classNames(
'bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto',
size === 'md' && 'max-w-sm',
size === 'lg' && 'max-w-md',
)}
>
{content}
</div>
<Triangle className="text-border mb-2" />
</div>
</Portal>
<button
ref={triggerRef}
type="button"
aria-describedby={isOpen ? id.current : undefined}
className="flex-grow-0 inline-flex items-center"
onClick={handleToggleImmediate}
onMouseEnter={handleOpen}
onMouseLeave={handleClose}
onFocus={handleOpenImmediate}
onBlur={handleClose}
onKeyDown={handleKeyDown}
>
{children}
</button>
</>
);
}
function Triangle({ className }: { className?: string }) {
return (
<svg
aria-hidden
viewBox="0 0 30 10"
preserveAspectRatio="none"
shapeRendering="crispEdges"
className={classNames(
className,
'absolute z-50 border-t-[2px] border-surface-highlight',
'-bottom-[calc(0.5rem-3px)] left-[calc(50%-0.4rem)]',
'h-[0.5rem] w-[0.8rem]',
)}
>
<polygon className="fill-surface-highlight" points="0,0 30,0 15,10" />
<path
d="M0 0 L15 9 L30 0"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinejoin="miter"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}