mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
🔐 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:
parent
547b28455a
commit
730878bc5a
25 changed files with 415 additions and 281 deletions
|
|
@ -146,6 +146,7 @@ const getMCPTools = async (req, res) => {
|
|||
authField: key,
|
||||
label: value.title || key,
|
||||
description: value.description || '',
|
||||
sensitive: value.sensitive,
|
||||
}));
|
||||
server.authenticated = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const CustomEndpoint = ({
|
|||
label={`${endpoint} API Key`}
|
||||
labelClassName="mb-1"
|
||||
inputClassName="mb-2"
|
||||
secret
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue