| import React, { useMemo } from 'react'; |
| import DOMPurify from 'dompurify'; |
| import { useForm, Controller } from 'react-hook-form'; |
| import { Input, Label, Button } from '@librechat/client'; |
| import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries'; |
| import { useLocalize } from '~/hooks'; |
|
|
| export interface CustomUserVarConfig { |
| title: string; |
| description?: string; |
| } |
|
|
| interface CustomUserVarsSectionProps { |
| serverName: string; |
| fields: Record<string, CustomUserVarConfig>; |
| onSave: (authData: Record<string, string>) => void; |
| onRevoke: () => void; |
| isSubmitting?: boolean; |
| } |
| interface AuthFieldProps { |
| name: string; |
| config: CustomUserVarConfig; |
| hasValue: boolean; |
| control: any; |
| errors: any; |
| } |
|
|
| function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps) { |
| const localize = useLocalize(); |
|
|
| const sanitizer = useMemo(() => { |
| const instance = DOMPurify(); |
| instance.addHook('afterSanitizeAttributes', (node) => { |
| if (node.tagName && node.tagName === 'A') { |
| node.setAttribute('target', '_blank'); |
| node.setAttribute('rel', 'noopener noreferrer'); |
| } |
| }); |
| return instance; |
| }, []); |
|
|
| const sanitizedDescription = useMemo(() => { |
| if (!config.description) { |
| return ''; |
| } |
| try { |
| return sanitizer.sanitize(config.description, { |
| ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code'], |
| ALLOWED_ATTR: ['href', 'class', 'target', 'rel'], |
| ALLOW_DATA_ATTR: false, |
| ALLOW_ARIA_ATTR: false, |
| }); |
| } catch (error) { |
| console.error('Sanitization failed', error); |
| return config.description; |
| } |
| }, [config.description, sanitizer]); |
|
|
| return ( |
| <div className="space-y-2"> |
| <div className="flex items-center justify-between"> |
| <Label htmlFor={name} className="text-sm font-medium"> |
| {config.title} |
| </Label> |
| {hasValue ? ( |
| <div className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary"> |
| <div className="h-1.5 w-1.5 rounded-full bg-green-500" /> |
| <span>{localize('com_ui_set')}</span> |
| </div> |
| ) : ( |
| <div className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary"> |
| <div className="h-1.5 w-1.5 rounded-full border border-border-medium" /> |
| <span>{localize('com_ui_unset')}</span> |
| </div> |
| )} |
| </div> |
| <Controller |
| name={name} |
| control={control} |
| defaultValue="" |
| render={({ field }) => ( |
| <Input |
| id={name} |
| type="text" |
| {...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" |
| /> |
| )} |
| /> |
| {sanitizedDescription && ( |
| <p |
| className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:underline" |
| dangerouslySetInnerHTML={{ __html: sanitizedDescription }} |
| /> |
| )} |
| {errors[name] && <p className="text-xs text-red-500">{errors[name]?.message}</p>} |
| </div> |
| ); |
| } |
|
|
| export default function CustomUserVarsSection({ |
| fields, |
| onSave, |
| onRevoke, |
| serverName, |
| isSubmitting = false, |
| }: CustomUserVarsSectionProps) { |
| const localize = useLocalize(); |
|
|
| const { data: authValuesData } = useMCPAuthValuesQuery(serverName, { |
| enabled: !!serverName, |
| }); |
|
|
| const { |
| reset, |
| control, |
| handleSubmit, |
| formState: { errors }, |
| } = useForm<Record<string, string>>({ |
| defaultValues: useMemo(() => { |
| const initial: Record<string, string> = {}; |
| Object.keys(fields).forEach((key) => { |
| initial[key] = ''; |
| }); |
| return initial; |
| }, [fields]), |
| }); |
|
|
| const onFormSubmit = (data: Record<string, string>) => { |
| onSave(data); |
| }; |
|
|
| const handleRevokeClick = () => { |
| onRevoke(); |
| reset(); |
| }; |
|
|
| if (!fields || Object.keys(fields).length === 0) { |
| return null; |
| } |
|
|
| return ( |
| <div className="flex-1 space-y-4"> |
| <form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4"> |
| {Object.entries(fields).map(([key, config]) => { |
| const hasValue = authValuesData?.authValueFlags?.[key] || false; |
| |
| return ( |
| <AuthField |
| key={key} |
| name={key} |
| config={config} |
| hasValue={hasValue} |
| control={control} |
| errors={errors} |
| /> |
| ); |
| })} |
| </form> |
| |
| <div className="flex justify-end gap-2"> |
| <Button |
| type="button" |
| variant="destructive" |
| disabled={isSubmitting} |
| onClick={handleRevokeClick} |
| > |
| {localize('com_ui_revoke')} |
| </Button> |
| <Button |
| type="button" |
| variant="submit" |
| disabled={isSubmitting} |
| onClick={handleSubmit(onFormSubmit)} |
| > |
| {isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')} |
| </Button> |
| </div> |
| </div> |
| ); |
| } |
|
|