🔐 feat: Use SecretInput for Sensitive Fields (#12955)

* feat: use SecretInput for sensitive fields

* fix: align auth SecretInput styles

* chore: remove unused password i18n keys

* fix: align SecretInput controls

* fix: use SecretInput for dynamic credentials

* fix: reveal SecretInput controls on hover

* fix: align SecretInput eye icon and modernize controls

The wrapper was a flex container, so passing 'mb-2' on the input made it
contribute its margin to the wrapper's cross-axis size — the controls overlay
spanned the inflated height and centered the toggle 4px below the input's
true center. Switching the wrapper to a plain relative block collapses height
back to the input.

Also tightens the toggle/copy buttons (size-7 rounded-md with hover:bg-surface-hover)
and adds a focus ring on the input. Auth pages still override className/buttonClassName
so login/register styling is unchanged.

* fix: remove focus ring from SecretInput

* fix: keep green focus border on auth secret inputs

SecretInput's modernized default uses focus-visible:border-border-heavy and
hover:border-border-medium, which Tailwind emits after the auth pages' focus:
rules and overrides them. Auth pages now also declare focus-visible:border-green-500
and hover:border-border-light so cn()/twMerge resolves them as the winners
when classes are concatenated.

* feat: add optional sensitive flag to MCP customUserVars

Dynamic MCP credential fields all rendered as masked SecretInputs, which
also hid non-secret setup values like usernames, project keys, and URLs.

Add an optional `sensitive` flag to customUserVars and the plugin auth
config. It defaults to masked when omitted, so existing configs keep the
safe-by-default behavior; set `sensitive: false` to render a field as
plain text. The flag is display-only — values remain encrypted at rest.
This commit is contained in:
Marco Beretta 2026-06-02 00:14:12 +02:00 committed by GitHub
parent 547b28455a
commit 730878bc5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 415 additions and 281 deletions

View file

@ -146,6 +146,7 @@ const getMCPTools = async (req, res) => {
authField: key,
label: value.title || key,
description: value.description || '',
sensitive: value.sensitive,
}));
server.authenticated = false;
}

View file

@ -16,6 +16,8 @@ export function isEphemeralAgent(agentId: string | null | undefined): boolean {
export interface ConfigFieldDetail {
title: string;
description: string;
/** Whether the field holds a secret and should be masked (defaults to masked when omitted). */
sensitive?: boolean;
}
export type CodeBarProps = {

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect, useContext } from 'react';
import { useForm } from 'react-hook-form';
import { Turnstile } from '@marsidev/react-turnstile';
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
import { ThemeContext, SecretInput, Spinner, Button, isDark } from '@librechat/client';
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
import type { TAuthContext } from '~/common';
import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider';
@ -31,6 +31,13 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
const useUsernameLogin = config?.ldap?.username;
const validTheme = isDark(theme) ? 'dark' : 'light';
const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey);
const authInputClassName =
'webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-green-500 focus:outline-none focus-visible:border-green-500';
const authSecretInputClassName = `${authInputClassName} h-auto pr-12`;
const authLabelClassName =
'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4';
const authSecretButtonClassName =
'size-9 rounded-xl text-text-secondary-alt hover:bg-transparent hover:text-text-primary';
useEffect(() => {
if (error && error.includes('422') && !showResendLink) {
@ -102,13 +109,10 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
: (value) => validateEmail(value, localize('com_auth_email_pattern')),
})}
aria-invalid={!!errors.email}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
className={authInputClassName}
placeholder=" "
/>
<label
htmlFor="email"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
<label htmlFor="email" className={authLabelClassName}>
{useUsernameLogin
? localize('com_auth_username').replace(/ \(.*$/, '')
: localize('com_auth_email_address')}
@ -118,8 +122,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
<SecretInput
id="password"
autoComplete="current-password"
aria-label={localize('com_auth_password')}
@ -132,15 +135,13 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
maxLength: { value: 128, message: localize('com_auth_password_max_length') },
})}
aria-invalid={!!errors.password}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
className={authSecretInputClassName}
placeholder=" "
label={localize('com_auth_password')}
labelClassName={authLabelClassName}
controlsClassName="right-2"
buttonClassName={authSecretButtonClassName}
/>
<label
htmlFor="password"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
</div>
{renderError('password')}
</div>

View file

@ -1,7 +1,7 @@
import { useForm } from 'react-hook-form';
import React, { useContext, useState } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
import { ThemeContext, SecretInput, Spinner, Button, isDark } from '@librechat/client';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
@ -36,6 +36,13 @@ const Registration: React.FC = () => {
// only require captcha if we have a siteKey
const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey);
const authInputClassName =
'webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-green-500 focus:outline-none focus-visible:border-green-500';
const authSecretInputClassName = `${authInputClassName} h-auto pr-12`;
const authLabelClassName =
'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4';
const authSecretButtonClassName =
'size-9 rounded-xl text-text-secondary-alt hover:bg-transparent hover:text-text-primary';
const registerUser = useRegisterUserMutation({
onMutate: () => {
@ -64,37 +71,58 @@ const Registration: React.FC = () => {
},
});
const renderInput = (id: string, label: TranslationKeys, type: string, validation: object) => (
<div className="mb-4">
<div className="relative">
<input
id={id}
type={type}
autoComplete={id}
aria-label={localize(label)}
{...register(
id as 'name' | 'email' | 'username' | 'password' | 'confirm_password',
validation,
const renderInput = (id: string, label: TranslationKeys, type: string, validation: object) => {
const fieldLabel = localize(label);
const field = register(
id as 'name' | 'email' | 'username' | 'password' | 'confirm_password',
validation,
);
return (
<div className="mb-4">
<div className="relative">
{type === 'password' ? (
<SecretInput
id={id}
autoComplete={id}
aria-label={fieldLabel}
{...field}
aria-invalid={!!errors[id]}
className={authSecretInputClassName}
placeholder=" "
data-testid={id}
label={fieldLabel}
labelClassName={authLabelClassName}
controlsClassName="right-2"
buttonClassName={authSecretButtonClassName}
/>
) : (
<>
<input
id={id}
type={type}
autoComplete={id}
aria-label={fieldLabel}
{...field}
aria-invalid={!!errors[id]}
className={authInputClassName}
placeholder=" "
data-testid={id}
/>
<label htmlFor={id} className={authLabelClassName}>
{fieldLabel}
</label>
</>
)}
aria-invalid={!!errors[id]}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
data-testid={id}
/>
<label
htmlFor={id}
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize(label)}
</label>
</div>
{errors[id] && (
<span role="alert" className="mt-1 text-sm text-red-500">
{String(errors[id]?.message) ?? ''}
</span>
)}
</div>
{errors[id] && (
<span role="alert" className="mt-1 text-sm text-red-500">
{String(errors[id]?.message) ?? ''}
</span>
)}
</div>
);
);
};
return (
<>

View file

@ -1,5 +1,5 @@
import { useForm } from 'react-hook-form';
import { Spinner, Button } from '@librechat/client';
import { Spinner, Button, SecretInput } from '@librechat/client';
import { useOutletContext } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useResetPasswordMutation } from 'librechat-data-provider/react-query';
@ -20,6 +20,12 @@ function ResetPassword() {
const password = watch('password');
const resetPassword = useResetPasswordMutation();
const { setError, setHeaderText, startupConfig } = useOutletContext<TLoginLayoutContext>();
const authInputClassName =
'webkit-dark-styles transition-color peer h-auto w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pr-12 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-green-500 focus:outline-none focus-visible:border-green-500';
const authLabelClassName =
'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4';
const authSecretButtonClassName =
'size-9 rounded-xl text-text-secondary-alt hover:bg-transparent hover:text-text-primary';
const onSubmit = (data: TResetPassword) => {
resetPassword.mutate(data, {
@ -75,8 +81,7 @@ function ResetPassword() {
value={params.get('userId') ?? ''}
{...register('userId', { required: 'Unable to process: No valid user id' })}
/>
<input
type="password"
<SecretInput
id="password"
autoComplete="current-password"
aria-label={localize('com_auth_password')}
@ -92,15 +97,13 @@ function ResetPassword() {
},
})}
aria-invalid={!!errors.password}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
className={authInputClassName}
placeholder=" "
label={localize('com_auth_password')}
labelClassName={authLabelClassName}
controlsClassName="right-2"
buttonClassName={authSecretButtonClassName}
/>
<label
htmlFor="password"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
</div>
{errors.password && (
@ -111,23 +114,20 @@ function ResetPassword() {
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
<SecretInput
id="confirm_password"
aria-label={localize('com_auth_password_confirm')}
{...register('confirm_password', {
validate: (value) => value === password || localize('com_auth_password_not_match'),
})}
aria-invalid={!!errors.confirm_password}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
className={authInputClassName}
placeholder=" "
label={localize('com_auth_password_confirm')}
labelClassName={authLabelClassName}
controlsClassName="right-2"
buttonClassName={authSecretButtonClassName}
/>
<label
htmlFor="confirm_password"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password_confirm')}
</label>
</div>
{errors.confirm_password && (
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">

View file

@ -1,6 +1,6 @@
import React, { useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Button, Input, Label, OGDialog, OGDialogTemplate } from '@librechat/client';
import { Button, Input, Label, SecretInput, OGDialog, OGDialogTemplate } from '@librechat/client';
import type { ConfigFieldDetail } from '~/common';
import {
CONFIG_HTML_BLOCK_TAGS,
@ -84,15 +84,34 @@ export default function MCPConfigDialog({
name={key}
control={control}
defaultValue={initialValues[key] || ''}
render={({ field }) => (
<Input
id={key}
type="text"
{...field}
placeholder={localize('com_ui_mcp_enter_var', { 0: details.title })}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
/>
)}
render={({ field }) => {
const placeholder = localize('com_ui_mcp_enter_var', { 0: details.title });
const className =
'w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm';
if (details.sensitive === false) {
return (
<Input
id={key}
{...field}
type="text"
placeholder={placeholder}
className={className}
/>
);
}
return (
<SecretInput
id={key}
{...field}
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
placeholder={placeholder}
className={className}
/>
);
}}
/>
{details.description && (
<p

View file

@ -22,6 +22,7 @@ const CustomEndpoint = ({
label={`${endpoint} API Key`}
labelClassName="mb-1"
inputClassName="mb-2"
secret
/>
)}
/>

View file

@ -53,6 +53,7 @@ const GoogleConfig = ({ userKey, setUserKey }: Pick<TConfigProps, 'userKey' | 's
}
label={localize('com_endpoint_config_google_api_key')}
subLabel={localize('com_endpoint_config_google_gemini_api')}
secret
/>
</>
);

View file

@ -1,7 +1,7 @@
import { forwardRef } from 'react';
import { Input, Label } from '@librechat/client';
import { Input, Label, SecretInput } from '@librechat/client';
import type { ChangeEvent, FC, Ref } from 'react';
import { cn, defaultTextPropsLabel, removeFocusOutlines, defaultTextProps } from '~/utils/';
import { cn } from '~/utils/';
import { useLocalize } from '~/hooks';
interface InputWithLabelProps {
@ -12,11 +12,21 @@ interface InputWithLabelProps {
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
labelClassName?: string;
inputClassName?: string;
secret?: boolean;
ref?: Ref<HTMLInputElement>;
}
const InputWithLabel: FC<InputWithLabelProps> = forwardRef((props, ref) => {
const { id, value, label, subLabel, onChange, labelClassName = '', inputClassName = '' } = props;
const {
id,
value,
label,
secret = false,
subLabel,
onChange,
labelClassName = '',
inputClassName = '',
} = props;
const localize = useLocalize();
return (
<>
@ -24,19 +34,37 @@ const InputWithLabel: FC<InputWithLabelProps> = forwardRef((props, ref) => {
<Label htmlFor={id} className="text-left text-sm font-medium">
{label}
</Label>
{Label && <Label className="mx-1 text-right text-sm text-text-secondary">{subLabel}</Label>}
{subLabel && (
<Label className="mx-1 text-right text-sm text-text-secondary">{subLabel}</Label>
)}
<br />
</div>
<div className="h-1" />
<Input
id={id}
data-testid={`input-${id}`}
value={value ?? ''}
onChange={onChange}
ref={ref}
placeholder={`${localize('com_endpoint_config_value')} ${label}`}
className={cn('flex h-10 max-h-10 w-full resize-none px-3 py-2')}
/>
{secret ? (
<SecretInput
id={id}
data-testid={`input-${id}`}
value={value ?? ''}
onChange={onChange}
ref={ref}
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
placeholder={`${localize('com_endpoint_config_value')} ${label}`}
className={cn('flex h-10 max-h-10 w-full resize-none px-3 py-2', inputClassName)}
/>
) : (
<Input
id={id}
data-testid={`input-${id}`}
value={value ?? ''}
onChange={onChange}
ref={ref}
placeholder={`${localize('com_endpoint_config_value')} ${label}`}
className={cn('flex h-10 max-h-10 w-full resize-none px-3 py-2', inputClassName)}
/>
)}
</>
);
});

View file

@ -24,6 +24,7 @@ const OpenAIConfig = ({
label={`${isAzure ? 'Azure q' : ''}OpenAI API Key`}
labelClassName="mb-1"
inputClassName="mb-2"
secret
/>
)}
/>
@ -39,6 +40,7 @@ const OpenAIConfig = ({
{...field}
label={'Azure OpenAI API Key'}
labelClassName="mb-1"
secret
/>
)}
/>

View file

@ -11,6 +11,7 @@ const OtherConfig = ({ userKey, setUserKey, endpoint }: TConfigProps) => {
value={userKey ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUserKey(e.target.value ?? '')}
label={localize('com_endpoint_config_key_name')}
secret
/>
);
};

View file

@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Input, Label, Button } from '@librechat/client';
import { Label, Input, Button, SecretInput } from '@librechat/client';
import type { Control, FieldErrors } from 'react-hook-form';
import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries';
import {
CONFIG_HTML_INLINE_TAGS,
@ -12,6 +13,8 @@ import { useLocalize } from '~/hooks';
export interface CustomUserVarConfig {
title: string;
description?: string;
/** Whether the field holds a secret and should be masked (defaults to masked when omitted). */
sensitive?: boolean;
}
interface CustomUserVarsSectionProps {
@ -25,8 +28,8 @@ interface AuthFieldProps {
name: string;
config: CustomUserVarConfig;
hasValue: boolean;
control: any;
errors: any;
control: Control<Record<string, string>>;
errors: FieldErrors<Record<string, string>>;
autoFocus?: boolean;
}
@ -70,29 +73,30 @@ function AuthField({ name, config, hasValue, control, errors, autoFocus }: AuthF
name={name}
control={control}
defaultValue=""
render={({ field }) => (
<Input
id={name}
// Prevent autofill: browser DOM mutations bypass React's synthetic
// onChange, silently emptying react-hook-form state on submit.
type="new-password"
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
/* autoFocus is generally disabled due to the fact that it can disorient users,
* but in this case, the required field would logically be immediately navigated to anyways, and the component's
* functionality emulates that of a new modal opening, where users would expect focus to be shifted to the new content */
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
{...field}
placeholder={
hasValue
? localize('com_ui_mcp_update_var', { 0: config.title })
: localize('com_ui_mcp_enter_var', { 0: config.title })
}
className="w-full rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary placeholder:text-text-secondary focus:outline-none sm:text-sm"
/>
)}
render={({ field }) => {
const placeholder = hasValue
? localize('com_ui_mcp_update_var', { 0: config.title })
: localize('com_ui_mcp_enter_var', { 0: config.title });
const className =
'w-full rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary placeholder:text-text-secondary focus:outline-none sm:text-sm';
// Prevent autofill: browser DOM mutations bypass React's synthetic
// onChange, silently emptying react-hook-form state on submit.
const sharedProps = {
id: name,
'data-lpignore': 'true',
'data-1p-ignore': 'true',
/* autoFocus is generally disorienting, but here the required field is navigated to
* anyway, and the section emulates a modal opening where users expect focus to shift. */
autoFocus,
...field,
placeholder,
className,
};
if (config.sensitive === false) {
return <Input {...sharedProps} type="text" autoComplete="off" />;
}
return <SecretInput {...sharedProps} autoComplete="new-password" controlsOnHover />;
}}
/>
{sanitizedDescription && (
<p

View file

@ -27,8 +27,25 @@ describe('CustomUserVarsSection', () => {
const input = screen.getByLabelText(/My API Key/);
expect(input).toHaveAttribute('autocomplete', 'new-password');
expect(input).toHaveAttribute('type', 'new-password');
expect(input).toHaveAttribute('type', 'password');
expect(input).toHaveAttribute('data-lpignore', 'true');
expect(input).toHaveAttribute('data-1p-ignore', 'true');
});
it('renders non-sensitive fields as unmasked text while keeping secrets masked', () => {
render(
<CustomUserVarsSection
serverName="test-server"
fields={{
api_key: { title: 'My API Key', description: 'Your API key' },
project_key: { title: 'Project Key', description: 'Your project key', sensitive: false },
}}
onSave={jest.fn()}
onRevoke={jest.fn()}
/>,
);
expect(screen.getByLabelText(/My API Key/)).toHaveAttribute('type', 'password');
expect(screen.getByLabelText(/Project Key/)).toHaveAttribute('type', 'text');
});
});

View file

@ -1,10 +1,8 @@
import React, { useState } from 'react';
import React from 'react';
import { motion } from 'framer-motion';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, Check } from 'lucide-react';
import { Input, Button, Label } from '@librechat/client';
import { Button, Label, SecretInput } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const fadeAnimation = {
initial: { opacity: 0, y: 20 },
@ -23,13 +21,6 @@ interface QRPhaseProps {
export const QRPhase: React.FC<QRPhaseProps> = ({ secret, otpauthUrl, onNext }) => {
const localize = useLocalize();
const [isCopying, setIsCopying] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(secret);
setIsCopying(true);
setTimeout(() => setIsCopying(false), 2000);
};
return (
<motion.div {...fadeAnimation} className="space-y-6">
@ -45,21 +36,14 @@ export const QRPhase: React.FC<QRPhaseProps> = ({ secret, otpauthUrl, onNext })
<Label className="text-sm font-medium text-text-secondary">
{localize('com_ui_secret_key')}
</Label>
<div className="flex gap-2">
<Input value={secret} readOnly className="font-mono text-lg tracking-wider" />
<Button
size="sm"
variant="outline"
onClick={handleCopy}
className={cn('h-auto shrink-0', isCopying ? 'cursor-default' : '')}
>
{isCopying ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)}
</Button>
</div>
<SecretInput
value={secret}
readOnly
showCopy
controlsOnHover
aria-label={localize('com_ui_secret_key')}
className="font-mono text-lg tracking-wider"
/>
</div>
</div>
<Button onClick={onNext} className="w-full">

View file

@ -5,12 +5,13 @@ import {
useDeleteAgentApiKeyMutation,
} from 'librechat-data-provider/react-query';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { Plus, Trash2, Copy, CopyCheck, Key, Eye, EyeOff, ShieldEllipsis } from 'lucide-react';
import { Plus, Trash2, Key, ShieldEllipsis } from 'lucide-react';
import {
Button,
Input,
Label,
Spinner,
SecretInput,
OGDialog,
OGDialogClose,
OGDialogTitle,
@ -21,7 +22,7 @@ import {
} from '@librechat/client';
import type { PermissionConfig } from '~/components/ui';
import { useUpdateRemoteAgentsPermissionsMutation } from '~/data-provider';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { useLocalize } from '~/hooks';
import { AdminSettingsDialog } from '~/components/ui';
function CreateKeyDialog({ onKeyCreated }: { onKeyCreated?: () => void }) {
@ -30,10 +31,7 @@ function CreateKeyDialog({ onKeyCreated }: { onKeyCreated?: () => void }) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [newKey, setNewKey] = useState<string | null>(null);
const [showKey, setShowKey] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const createMutation = useCreateAgentApiKeyMutation();
const copyKey = useCopyToClipboard({ text: newKey || '' });
const handleCreate = async () => {
if (!name.trim()) {
@ -54,15 +52,10 @@ function CreateKeyDialog({ onKeyCreated }: { onKeyCreated?: () => void }) {
const handleClose = () => {
setName('');
setNewKey(null);
setShowKey(false);
setOpen(false);
};
const handleCopy = () => {
if (isCopying) {
return;
}
copyKey(setIsCopying);
showToast({ message: localize('com_ui_api_key_copied'), status: 'success' });
};
@ -112,30 +105,15 @@ function CreateKeyDialog({ onKeyCreated }: { onKeyCreated?: () => void }) {
</div>
<div className="space-y-2">
<Label>{localize('com_ui_your_api_key')}</Label>
<div className="flex gap-2">
<Input
value={showKey ? newKey : '•'.repeat(newKey.length)}
readOnly
className="font-mono text-sm"
/>
<Button
variant="outline"
size="icon"
onClick={() => setShowKey(!showKey)}
title={showKey ? localize('com_ui_hide') : localize('com_ui_show')}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="outline"
size="icon"
onClick={handleCopy}
disabled={isCopying}
title={localize('com_ui_copy')}
>
{isCopying ? <CopyCheck className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<SecretInput
value={newKey}
readOnly
showCopy
controlsOnHover
onCopy={handleCopy}
aria-label={localize('com_ui_your_api_key')}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end">
<Button onClick={handleClose}>{localize('com_ui_done')}</Button>

View file

@ -1,6 +1,6 @@
import { Save } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { HoverCard, HoverCardTrigger } from '@librechat/client';
import { HoverCard, HoverCardTrigger, SecretInput } from '@librechat/client';
import { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider';
import PluginTooltip from './PluginTooltip';
import { useLocalize } from '~/hooks';
@ -40,6 +40,31 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
{authConfig.map((config: TPluginAuthConfig, i: number) => {
const authField = config.authField.split('||')[0];
const isOptional = config.optional === true;
const inputClassName =
'flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0';
const sharedProps = {
id: authField,
'aria-invalid': !!errors[authField],
'aria-describedby': `${authField}-error`,
'aria-label': config.label,
'aria-required': !isOptional,
/* autoFocus is generally disorienting, but here the required field must be navigated to
* anyway, and the form emulates a modal opening where users expect focus to shift. */
autoFocus: i === 0,
className: inputClassName,
...register(
authField,
isOptional
? {}
: {
required: `${config.label} is required.`,
minLength: {
value: 1,
message: `${config.label} must be at least 1 character long`,
},
},
),
};
return (
<div key={`${authField}-${i}`} className="flex w-full flex-col gap-1">
<label
@ -50,33 +75,17 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
</label>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<input
type="text"
autoComplete="off"
id={authField}
aria-invalid={!!errors[authField]}
aria-describedby={`${authField}-error`}
aria-label={config.label}
aria-required={!isOptional}
/* autoFocus is generally disabled due to the fact that it can disorient users,
* but in this case, the required field must be navigated to anyways, and the component's functionality
* emulates that of a new modal opening, where users would expect focus to be shifted to the new content */
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={i === 0}
{...register(
authField,
isOptional
? {}
: {
required: `${config.label} is required.`,
minLength: {
value: 1,
message: `${config.label} must be at least 1 character long`,
},
},
)}
className="flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0"
/>
{config.sensitive === false ? (
<input type="text" autoComplete="off" {...sharedProps} />
) : (
<SecretInput
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
{...sharedProps}
/>
)}
</HoverCardTrigger>
<PluginTooltip content={config.description} position="right" />
</HoverCard>

View file

@ -23,8 +23,29 @@ describe('PluginAuthForm', () => {
//@ts-ignore - dont need all props of plugin
render(<PluginAuthForm plugin={plugin} onSubmit={onSubmit} />);
expect(screen.getByLabelText('Key')).toBeInTheDocument();
expect(screen.getByLabelText('Secret')).toBeInTheDocument();
expect(screen.getByLabelText('Key')).toHaveAttribute('type', 'password');
expect(screen.getByLabelText('Secret')).toHaveAttribute('type', 'password');
});
it('masks fields by default and renders non-sensitive fields as plain text', () => {
const mixedPlugin = {
pluginKey: 'mixed-plugin',
authConfig: [
{ authField: 'token', label: 'Token' },
{ authField: 'secret', label: 'Secret', sensitive: true },
{ authField: 'url', label: 'URL', sensitive: false },
],
};
//@ts-ignore - dont need all props of plugin
render(<PluginAuthForm plugin={mixedPlugin} onSubmit={onSubmit} />);
expect(screen.getByLabelText('Token')).toHaveAttribute('type', 'password');
expect(screen.getByLabelText('Secret')).toHaveAttribute('type', 'password');
const urlField = screen.getByLabelText('URL');
expect(urlField).toHaveAttribute('type', 'text');
expect(urlField.parentElement?.querySelector('button')).toBeNull();
});
it('calls the onSubmit function with the form data when submitted', async () => {

View file

@ -1,11 +1,9 @@
import { useState } from 'react';
import * as Menu from '@ariakit/react/menu';
import { ChevronDown, Eye, EyeOff } from 'lucide-react';
import { Input, Label, DropdownPopup } from '@librechat/client';
import { ChevronDown } from 'lucide-react';
import { Input, Label, SecretInput, DropdownPopup } from '@librechat/client';
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
import type { UseFormRegister } from 'react-hook-form';
import type { MenuItemProps } from '~/common';
import { useLocalize } from '~/hooks';
interface InputConfig {
placeholder: string;
@ -45,21 +43,12 @@ export default function InputSection({
setDropdownOpen,
dropdownKey,
}: InputSectionProps) {
const localize = useLocalize();
const [passwordVisibility, setPasswordVisibility] = useState<Record<string, boolean>>({});
const selectedOption = dropdownOptions.find((opt) => opt.key === selectedKey);
const dropdownItems: MenuItemProps[] = dropdownOptions.map((option) => ({
label: option.label,
onClick: () => onSelectionChange(option.key),
}));
const togglePasswordVisibility = (fieldName: string) => {
setPasswordVisibility((prev) => ({
...prev,
[fieldName]: !prev[fieldName],
}));
};
return (
<div className="mb-6">
<div className="mb-2 flex items-center justify-between">
@ -85,47 +74,27 @@ export default function InputSection({
)}
</div>
{selectedOption?.inputs &&
Object.entries(selectedOption.inputs).map(([name, config], index) => (
Object.entries(selectedOption.inputs).map(([name, config]) => (
<div key={name}>
<div className="relative">
<Input
type={'text'} // so password autofill doesn't show
placeholder={config.placeholder}
autoComplete={config.type === 'password' ? 'one-time-code' : 'off'}
readOnly={config.type === 'password'}
onFocus={
config.type === 'password' ? (e) => (e.target.readOnly = false) : undefined
}
className={`${index > 0 ? 'mb-2' : 'mb-2'} ${
config.type === 'password' ? 'pr-10' : ''
}`}
{...register(name as keyof SearchApiKeyFormData)}
/>
{config.type === 'password' && (
<button
type="button"
onClick={() => togglePasswordVisibility(name)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary transition-colors hover:text-text-primary"
aria-label={
passwordVisibility[name]
? localize('com_ui_hide_password')
: localize('com_ui_show_password')
}
>
<div className="relative h-4 w-4">
{passwordVisibility[name] ? (
<EyeOff
className="absolute inset-0 h-4 w-4 duration-200 animate-in fade-in"
aria-hidden="true"
/>
) : (
<Eye
className="absolute inset-0 h-4 w-4 duration-200 animate-in fade-in"
aria-hidden="true"
/>
)}
</div>
</button>
{config.type === 'password' ? (
<SecretInput
placeholder={config.placeholder}
autoComplete="one-time-code"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
className="mb-2"
{...register(name as keyof SearchApiKeyFormData)}
/>
) : (
<Input
type="text"
placeholder={config.placeholder}
autoComplete="off"
className="mb-2"
{...register(name as keyof SearchApiKeyFormData)}
/>
)}
</div>
{config.link && (

View file

@ -13,6 +13,7 @@ import {
OGDialogHeader,
OGDialogContent,
OGDialogTrigger,
SecretInput,
} from '@librechat/client';
import { TranslationKeys, useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -23,6 +24,18 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
const { watch, setValue, trigger } = useFormContext();
const type = watch('type');
const renderAuthFields = () => {
if (type === AuthTypeEnum.None) {
return null;
}
if (type === AuthTypeEnum.ServiceHttp) {
return <ApiKey />;
}
return <OAuth />;
};
return (
<OGDialog open={openAuthDialog} onOpenChange={setOpenAuthDialog}>
<OGDialogTrigger asChild>
@ -136,7 +149,7 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
</div>
</RadioGroup.Root>
</div>
{type === 'none' ? null : type === 'service_http' ? <ApiKey /> : <OAuth />}
{renderAuthFields()}
{/* Cancel/Save */}
<div className="mt-5 flex flex-col gap-3 sm:mt-4 sm:flex-row-reverse">
<button
@ -168,18 +181,20 @@ const ApiKey = () => {
const { register, watch, setValue } = useFormContext();
const authorization_type = watch('authorization_type');
const type = watch('type');
const inputClasses = cn(
'mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm',
'border-border-medium bg-surface-primary outline-none',
'focus:ring-2 focus:ring-ring',
);
return (
<>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key')}</label>
<input
<SecretInput
placeholder="<HIDDEN>"
type="new-password"
autoComplete="new-password"
className={cn(
'mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm',
'border-border-medium bg-surface-primary outline-none',
'focus:ring-2 focus:ring-ring',
)}
controlsOnHover
className={inputClasses}
{...register('api_key', { required: type === AuthTypeEnum.ServiceHttp })}
/>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_auth_type')}</label>
@ -294,18 +309,18 @@ const OAuth = () => {
return (
<>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_client_id')}</label>
<input
<SecretInput
placeholder="<HIDDEN>"
type="password"
autoComplete="new-password"
controlsOnHover
className={inputClasses}
{...register('oauth_client_id', { required: false })}
/>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_client_secret')}</label>
<input
<SecretInput
placeholder="<HIDDEN>"
type="password"
autoComplete="new-password"
controlsOnHover
className={inputClasses}
{...register('oauth_client_secret', { required: false })}
/>

View file

@ -105,7 +105,12 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
<Label htmlFor="api_key" className="text-sm font-medium">
{localize('com_ui_api_key')}
</Label>
<SecretInput id="api_key" placeholder="sk-..." {...register('auth.api_key')} />
<SecretInput
id="api_key"
placeholder="sk-..."
controlsOnHover
{...register('auth.api_key')}
/>
</div>
)}
@ -160,9 +165,12 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
</>
)}
</Label>
<Input
<SecretInput
id="oauth_client_id"
autoComplete="off"
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
aria-invalid={errors.auth?.oauth_client_id ? 'true' : 'false'}
aria-describedby={
@ -188,6 +196,7 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
<SecretInput
id="oauth_client_secret"
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
controlsOnHover
{...register('auth.oauth_client_secret')}
/>
</div>

View file

@ -562,6 +562,7 @@ export function useMCPServerManager({
authField: key,
label: config.title,
description: config.description,
sensitive: config.sensitive,
}))
: []),
authenticated: serverData?.authenticated ?? false,
@ -609,6 +610,7 @@ export function useMCPServerManager({
fieldsSchema[field.authField] = {
title: field.label || field.authField,
description: field.description,
sensitive: field.sensitive,
};
});
}

View file

@ -1105,7 +1105,6 @@
"com_ui_hide_code": "Hide Code",
"com_ui_hide_image_details": "Hide Image Details",
"com_ui_hide_n_files": "Hide {{0}} files",
"com_ui_hide_password": "Hide password",
"com_ui_hide_qr": "Hide QR Code",
"com_ui_high": "High",
"com_ui_host": "Host",
@ -1492,7 +1491,6 @@
"com_ui_show_less": "Show less",
"com_ui_show_more": "Show more",
"com_ui_show_n_files": "Show {{0}} files",
"com_ui_show_password": "Show password",
"com_ui_show_qr": "Show QR Code",
"com_ui_sign_in_to_domain": "Sign-in to {{0}}",
"com_ui_simple": "Simple",

View file

@ -11,11 +11,32 @@ export interface SecretInputProps
onCopy?: () => void;
/** Duration in ms to show checkmark after copy (default: 2000) */
copyFeedbackDuration?: number;
label?: React.ReactNode;
labelClassName?: string;
containerClassName?: string;
controlsClassName?: string;
buttonClassName?: string;
controlsOnHover?: boolean;
}
const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
(
{ className, showCopy = false, onCopy, copyFeedbackDuration = 2000, disabled, value, ...props },
{
id,
label,
className,
showCopy = false,
labelClassName,
containerClassName,
controlsClassName,
buttonClassName,
controlsOnHover = false,
onCopy,
copyFeedbackDuration = 2000,
disabled,
value,
...props
},
ref,
) => {
const [isVisible, setIsVisible] = useState(false);
@ -49,13 +70,14 @@ const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
}, [value, isCopied, disabled, onCopy, copyFeedbackDuration]);
return (
<div className="relative flex items-center">
<div className={cn('group/secret-input relative', containerClassName)}>
<input
id={id}
type={isVisible ? 'text' : 'password'}
className={cn(
'flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
showCopy ? 'pr-20' : 'pr-10',
'flex h-10 w-full rounded-lg border border-border-light bg-transparent py-2 pl-3 text-sm transition-colors placeholder:text-muted-foreground hover:border-border-medium focus-visible:border-border-heavy focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className ?? '',
showCopy ? 'pr-20' : 'pr-11',
)}
ref={ref}
disabled={disabled}
@ -64,17 +86,30 @@ const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
spellCheck={false}
{...props}
/>
<div className="absolute right-1 flex items-center gap-0.5">
{label != null && (
<label htmlFor={id} className={cn(labelClassName ?? '')}>
{label}
</label>
)}
<div
className={cn(
'pointer-events-none absolute inset-y-0 right-1.5 flex items-center gap-0.5 [&>button]:pointer-events-auto',
controlsOnHover &&
'opacity-0 transition-opacity duration-150 group-focus-within/secret-input:opacity-100 group-hover/secret-input:opacity-100',
controlsClassName,
)}
>
{showCopy && (
<button
type="button"
onClick={handleCopy}
disabled={disabled || !value}
className={cn(
'flex size-8 items-center justify-center rounded-md text-text-secondary transition-colors',
'inline-flex size-7 shrink-0 items-center justify-center rounded-md text-text-secondary transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary [&>svg]:block',
disabled || !value
? 'cursor-not-allowed opacity-50'
: 'hover:bg-surface-hover hover:text-text-primary',
buttonClassName,
)}
aria-label={isCopied ? 'Copied' : 'Copy to clipboard'}
>
@ -86,12 +121,13 @@ const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
onClick={toggleVisibility}
disabled={disabled}
className={cn(
'flex size-8 items-center justify-center rounded-md text-text-secondary transition-colors',
'inline-flex size-7 shrink-0 items-center justify-center rounded-md text-text-secondary transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary [&>svg]:block',
disabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-surface-hover hover:text-text-primary',
buttonClassName,
)}
aria-label={isVisible ? 'Hide password' : 'Show password'}
aria-label={isVisible ? 'Hide secret' : 'Show secret'}
>
{isVisible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>

View file

@ -192,6 +192,12 @@ const BaseOptionsSchema = z.object({
z.object({
title: z.string(),
description: z.string(),
/**
* Whether the field holds a secret and should be masked in the UI.
* Defaults to masked when omitted; set to `false` for non-secret setup
* values (e.g. username, project key, base URL) to render as plain text.
*/
sensitive: z.boolean().optional(),
}),
)
.optional(),

View file

@ -629,6 +629,8 @@ export const tPluginAuthConfigSchema = z.object({
label: z.string(),
description: z.string(),
optional: z.boolean().optional(),
/** Whether the field holds a secret and should be masked in the UI (defaults to masked when omitted). */
sensitive: z.boolean().optional(),
});
export type TPluginAuthConfig = z.infer<typeof tPluginAuthConfigSchema>;