mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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
3158e34ccd
commit
6474c8456d
10 changed files with 143 additions and 66 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 DOMPurify from 'dompurify';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Button, Label, SecretInput, OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||
import { Button, Input, Label, SecretInput, OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||
import type { ConfigFieldDetail } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
|
|
@ -90,18 +90,34 @@ export default function MCPConfigDialog({
|
|||
name={key}
|
||||
control={control}
|
||||
defaultValue={initialValues[key] || ''}
|
||||
render={({ field }) => (
|
||||
<SecretInput
|
||||
id={key}
|
||||
{...field}
|
||||
autoComplete="new-password"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
controlsOnHover
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Label, Button, SecretInput } 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 { useLocalize } from '~/hooks';
|
||||
|
|
@ -9,6 +9,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 {
|
||||
|
|
@ -83,29 +85,30 @@ function AuthField({ name, config, hasValue, control, errors, autoFocus }: AuthF
|
|||
name={name}
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<SecretInput
|
||||
id={name}
|
||||
// Prevent autofill: browser DOM mutations bypass React's synthetic
|
||||
// onChange, silently emptying react-hook-form state on submit.
|
||||
autoComplete="new-password"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
controlsOnHover
|
||||
/* 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
|
||||
|
|
|
|||
|
|
@ -31,4 +31,21 @@ describe('CustomUserVarsSection', () => {
|
|||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,35 +75,17 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
|
|||
</label>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||
<SecretInput
|
||||
autoComplete="new-password"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
controlsOnHover
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,27 @@ describe('PluginAuthForm', () => {
|
|||
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 () => {
|
||||
//@ts-ignore - dont need all props of plugin
|
||||
render(<PluginAuthForm plugin={plugin} onSubmit={onSubmit} />);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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