Compare commits

...

32 Commits

Author SHA1 Message Date
Harry
364e0f1450 fix: update OAuth button logic to improve configuration handling 2025-07-16 20:07:48 +08:00
zxhlyh
25e0013db9 fix 2025-07-16 16:00:53 +08:00
zxhlyh
75d8cb4978 fix 2025-07-16 15:06:04 +08:00
zxhlyh
52e7bb1848 fix 2025-07-16 15:03:33 +08:00
zxhlyh
686b4b8e0e fix: style 2025-07-16 13:58:17 +08:00
zxhlyh
3f65117007 fix: style 2025-07-16 11:28:17 +08:00
Yeuoly
0dcdfd6de3 refactor: improve OAuth client configuration logic and logging in add-oauth-button component 2025-07-15 19:12:51 +08:00
zxhlyh
e1bb6c00df fix 2025-07-15 18:23:13 +08:00
zxhlyh
67b5d73399 fix 2025-07-15 18:03:27 +08:00
zxhlyh
e4cf6a497b fix 2025-07-15 17:37:50 +08:00
zxhlyh
498d8ab33c fix 2025-07-15 15:49:23 +08:00
zxhlyh
a923087b57 Merge branch 'main' into feat/tool-oauth 2025-07-15 14:46:26 +08:00
zxhlyh
3f273cae73 fix 2025-07-15 14:45:50 +08:00
zxhlyh
964e29b27f fix: loading 2025-07-14 17:33:42 +08:00
zxhlyh
d0ba7adf33 form 2025-07-14 17:02:46 +08:00
zxhlyh
2572e99a4b form 2025-07-14 16:42:23 +08:00
zxhlyh
fd0a8d5834 Merge branch 'main' into feat/tool-oauth 2025-07-14 14:45:58 +08:00
zxhlyh
29035d333d fix: validate 2025-07-14 14:45:12 +08:00
zxhlyh
3b7df2f9b6 fix: provider 2025-07-14 11:12:10 +08:00
zxhlyh
580c9a668f fix 2025-07-11 18:17:17 +08:00
zxhlyh
83ab69d2eb tool oauth 2025-07-11 17:37:13 +08:00
zxhlyh
119d41099d Merge branch 'main' into feat/tool-oauth 2025-07-11 14:41:11 +08:00
zxhlyh
cb0082c0b8 tool oauth 2025-07-11 14:40:36 +08:00
zxhlyh
90f800408d merge main 2025-07-10 17:49:23 +08:00
zxhlyh
5869d6aacc tool oauth 2025-07-10 17:28:27 +08:00
zxhlyh
18699f8671 tool oauth 2025-07-10 17:12:48 +08:00
zxhlyh
bdf5af7a6f tool oauth 2025-07-10 11:38:51 +08:00
zxhlyh
bda76080a9 Merge branch 'main' into feat/tool-oauth 2025-07-09 18:29:10 +08:00
zxhlyh
8968a3e254 tool oauth 2025-07-09 18:28:39 +08:00
zxhlyh
ce8bf7b5a2 Merge branch 'main' into feat/tool-oauth 2025-07-08 18:21:49 +08:00
zxhlyh
0f1be60daa tool oauth 2025-07-08 18:20:30 +08:00
zxhlyh
c53d5c105b feat: tool oauth 2025-07-03 17:55:52 +08:00
46 changed files with 2998 additions and 332 deletions

View File

@@ -18,7 +18,6 @@ import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator' import Indicator from '@/app/components/header/indicator'
import Switch from '@/app/components/base/switch' import Switch from '@/app/components/base/switch'
import Toast from '@/app/components/base/toast'
import ConfigContext from '@/context/debug-configuration' import ConfigContext from '@/context/debug-configuration'
import type { AgentTool } from '@/types/app' import type { AgentTool } from '@/types/app'
import { type Collection, CollectionType } from '@/app/components/tools/types' import { type Collection, CollectionType } from '@/app/components/tools/types'
@@ -26,8 +25,6 @@ import { MAX_TOOLS_NUM } from '@/config'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other' import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import { updateBuiltInToolCredential } from '@/service/tools'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types' import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
@@ -57,13 +54,7 @@ const AgentTools: FC = () => {
const formattingChangedDispatcher = useFormattingChangedDispatcher() const formattingChangedDispatcher = useFormattingChangedDispatcher()
const [currentTool, setCurrentTool] = useState<AgentToolWithMoreInfo>(null) const [currentTool, setCurrentTool] = useState<AgentToolWithMoreInfo>(null)
const currentCollection = useMemo(() => {
if (!currentTool) return null
const collection = collectionList.find(collection => canFindTool(collection.id, currentTool?.provider_id) && collection.type === currentTool?.provider_type)
return collection
}, [currentTool, collectionList])
const [isShowSettingTool, setIsShowSettingTool] = useState(false) const [isShowSettingTool, setIsShowSettingTool] = useState(false)
const [isShowSettingAuth, setShowSettingAuth] = useState(false)
const tools = (modelConfig?.agentConfig?.tools as AgentTool[] || []).map((item) => { const tools = (modelConfig?.agentConfig?.tools as AgentTool[] || []).map((item) => {
const collection = collectionList.find( const collection = collectionList.find(
collection => collection =>
@@ -100,17 +91,6 @@ const AgentTools: FC = () => {
formattingChangedDispatcher() formattingChangedDispatcher()
} }
const handleToolAuthSetting = (value: AgentToolWithMoreInfo) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === value?.collection?.id && item.tool_name === value?.tool_name)
if (tool)
(tool as AgentTool).notAuthor = false
})
setModelConfig(newModelConfig)
setIsShowSettingTool(false)
formattingChangedDispatcher()
}
const [isDeleting, setIsDeleting] = useState<number>(-1) const [isDeleting, setIsDeleting] = useState<number>(-1)
const getToolValue = (tool: ToolDefaultValue) => { const getToolValue = (tool: ToolDefaultValue) => {
return { return {
@@ -144,6 +124,20 @@ const AgentTools: FC = () => {
return item.provider_name return item.provider_name
} }
const handleAuthorizationItemClick = useCallback((credentialId: string) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
if (tool)
(tool as AgentTool).credential_id = credentialId
})
setCurrentTool({
...currentTool,
credential_id: credentialId,
} as any)
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
return ( return (
<> <>
<Panel <Panel
@@ -302,7 +296,7 @@ const AgentTools: FC = () => {
{item.notAuthor && ( {item.notAuthor && (
<Button variant='secondary' size='small' onClick={() => { <Button variant='secondary' size='small' onClick={() => {
setCurrentTool(item) setCurrentTool(item)
setShowSettingAuth(true) setIsShowSettingTool(true)
}}> }}>
{t('tools.notAuthorized')} {t('tools.notAuthorized')}
<Indicator className='ml-2' color='orange' /> <Indicator className='ml-2' color='orange' />
@@ -322,21 +316,8 @@ const AgentTools: FC = () => {
isModel={currentTool?.collection?.type === CollectionType.model} isModel={currentTool?.collection?.type === CollectionType.model}
onSave={handleToolSettingChange} onSave={handleToolSettingChange}
onHide={() => setIsShowSettingTool(false)} onHide={() => setIsShowSettingTool(false)}
/> credentialId={currentTool?.credential_id}
)} onAuthorizationItemClick={handleAuthorizationItemClick}
{isShowSettingAuth && (
<ConfigCredential
collection={currentCollection as any}
onCancel={() => setShowSettingAuth(false)}
onSaved={async (value) => {
await updateBuiltInToolCredential((currentCollection as any).name, value)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
handleToolAuthSetting(currentTool)
setShowSettingAuth(false)
}}
/> />
)} )}
</> </>

View File

@@ -14,7 +14,6 @@ import Icon from '@/app/components/plugins/card/base/card-icon'
import OrgInfo from '@/app/components/plugins/card/base/org-info' import OrgInfo from '@/app/components/plugins/card/base/org-info'
import Description from '@/app/components/plugins/card/base/description' import Description from '@/app/components/plugins/card/base/description'
import TabSlider from '@/app/components/base/tab-slider-plain' import TabSlider from '@/app/components/base/tab-slider-plain'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
@@ -25,6 +24,10 @@ import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language' import { getLanguage } from '@/i18n/language'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import type { ToolWithProvider } from '@/app/components/workflow/types' import type { ToolWithProvider } from '@/app/components/workflow/types'
import {
AuthCategory,
PluginAuthInAgent,
} from '@/app/components/plugins/plugin-auth'
type Props = { type Props = {
showBackButton?: boolean showBackButton?: boolean
@@ -36,6 +39,8 @@ type Props = {
readonly?: boolean readonly?: boolean
onHide: () => void onHide: () => void
onSave?: (value: Record<string, any>) => void onSave?: (value: Record<string, any>) => void
credentialId?: string
onAuthorizationItemClick?: (id: string) => void
} }
const SettingBuiltInTool: FC<Props> = ({ const SettingBuiltInTool: FC<Props> = ({
@@ -48,6 +53,8 @@ const SettingBuiltInTool: FC<Props> = ({
readonly, readonly,
onHide, onHide,
onSave, onSave,
credentialId,
onAuthorizationItemClick,
}) => { }) => {
const { locale } = useContext(I18n) const { locale } = useContext(I18n)
const language = getLanguage(locale) const language = getLanguage(locale)
@@ -197,8 +204,20 @@ const SettingBuiltInTool: FC<Props> = ({
</div> </div>
<div className='system-md-semibold mt-1 text-text-primary'>{currTool?.label[language]}</div> <div className='system-md-semibold mt-1 text-text-primary'>{currTool?.label[language]}</div>
{!!currTool?.description[language] && ( {!!currTool?.description[language] && (
<Description className='mt-3' text={currTool.description[language]} descriptionLineRows={2}></Description> <Description className='mb-2 mt-3 h-auto' text={currTool.description[language]} descriptionLineRows={2}></Description>
)} )}
{
collection.allow_delete && collection.type === CollectionType.builtIn && (
<PluginAuthInAgent
pluginPayload={{
provider: collection.name,
category: AuthCategory.tool,
}}
credentialId={credentialId}
onAuthorizationItemClick={onAuthorizationItemClick}
/>
)
}
</div> </div>
{/* form */} {/* form */}
<div className='h-full'> <div className='h-full'>

View File

@@ -98,7 +98,7 @@ const Question: FC<QuestionProps> = ({
return ( return (
<div className='mb-2 flex justify-end last:mb-0'> <div className='mb-2 flex justify-end last:mb-0'>
<div className={cn('group relative mr-4 flex max-w-full items-start pl-14 overflow-x-hidden', isEditing && 'flex-1')}> <div className={cn('group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', isEditing && 'flex-1')}>
<div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}> <div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}>
<div <div
className="absolute hidden gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex" className="absolute hidden gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex"
@@ -117,7 +117,7 @@ const Question: FC<QuestionProps> = ({
</div> </div>
<div <div
ref={contentRef} ref={contentRef}
className='w-full rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 px-4 py-3 text-sm text-text-primary' className='bg-background-gradient-bg-fill-chat-bubble-bg-3 w-full rounded-2xl px-4 py-3 text-sm text-text-primary'
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}} style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
> >
{ {

View File

@@ -0,0 +1,177 @@
import {
isValidElement,
memo,
useMemo,
} from 'react'
import type { AnyFieldApi } from '@tanstack/react-form'
import { useStore } from '@tanstack/react-form'
import cn from '@/utils/classnames'
import Input from '@/app/components/base/input'
import PureSelect from '@/app/components/base/select/pure'
import type { FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { useRenderI18nObject } from '@/hooks/use-i18n'
export type BaseFieldProps = {
fieldClassName?: string
labelClassName?: string
inputContainerClassName?: string
inputClassName?: string
formSchema: FormSchema
field: AnyFieldApi
disabled?: boolean
}
const BaseField = ({
fieldClassName,
labelClassName,
inputContainerClassName,
inputClassName,
formSchema,
field,
disabled,
}: BaseFieldProps) => {
const renderI18nObject = useRenderI18nObject()
const {
label,
required,
placeholder,
options,
labelClassName: formLabelClassName,
show_on = [],
} = formSchema
const memorizedLabel = useMemo(() => {
if (isValidElement(label))
return label
if (typeof label === 'string')
return label
if (typeof label === 'object' && label !== null)
return renderI18nObject(label as Record<string, string>)
}, [label, renderI18nObject])
const memorizedPlaceholder = useMemo(() => {
if (typeof placeholder === 'string')
return placeholder
if (typeof placeholder === 'object' && placeholder !== null)
return renderI18nObject(placeholder as Record<string, string>)
}, [placeholder, renderI18nObject])
const memorizedOptions = useMemo(() => {
return options?.map((option) => {
return {
label: typeof option.label === 'string' ? option.label : renderI18nObject(option.label),
value: option.value,
}
}) || []
}, [options, renderI18nObject])
const value = useStore(field.form.store, s => s.values[field.name])
const values = useStore(field.form.store, (s) => {
return show_on.reduce((acc, condition) => {
acc[condition.variable] = s.values[condition.variable]
return acc
}, {} as Record<string, any>)
})
const show = useMemo(() => {
return show_on.every((condition) => {
const conditionValue = values[condition.variable]
return conditionValue === condition.value
})
}, [values, show_on])
if (!show)
return null
return (
<div className={cn(fieldClassName)}>
<div className={cn(labelClassName, formLabelClassName)}>
{memorizedLabel}
{
required && !isValidElement(label) && (
<span className='ml-1 text-text-destructive-secondary'>*</span>
)
}
</div>
<div className={cn(inputContainerClassName)}>
{
formSchema.type === FormTypeEnum.textInput && (
<Input
id={field.name}
name={field.name}
className={cn(inputClassName)}
value={value || ''}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={memorizedPlaceholder}
/>
)
}
{
formSchema.type === FormTypeEnum.secretInput && (
<Input
id={field.name}
name={field.name}
type='password'
className={cn(inputClassName)}
value={value || ''}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={memorizedPlaceholder}
/>
)
}
{
formSchema.type === FormTypeEnum.textNumber && (
<Input
id={field.name}
name={field.name}
type='number'
className={cn(inputClassName)}
value={value || ''}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={memorizedPlaceholder}
/>
)
}
{
formSchema.type === FormTypeEnum.select && (
<PureSelect
value={value}
onChange={v => field.handleChange(v)}
disabled={disabled}
placeholder={memorizedPlaceholder}
options={memorizedOptions}
triggerPopupSameWidth
/>
)
}
{
formSchema.type === FormTypeEnum.radio && (
<div className='flex items-center space-x-2'>
{
memorizedOptions.map(option => (
<div
key={option.value}
className={cn(
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 grow cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
)}
onClick={() => field.handleChange(option.value)}
>
{option.label}
</div>
))
}
</div>
)
}
</div>
</div>
)
}
export default memo(BaseField)

View File

@@ -0,0 +1,115 @@
import {
memo,
useCallback,
useImperativeHandle,
} from 'react'
import type {
AnyFieldApi,
AnyFormApi,
} from '@tanstack/react-form'
import { useForm } from '@tanstack/react-form'
import type {
FormRef,
FormSchema,
} from '@/app/components/base/form/types'
import {
BaseField,
} from '.'
import type {
BaseFieldProps,
} from '.'
import cn from '@/utils/classnames'
import {
useGetFormValues,
useGetValidators,
} from '@/app/components/base/form/hooks'
export type BaseFormProps = {
formSchemas?: FormSchema[]
defaultValues?: Record<string, any>
formClassName?: string
ref?: FormRef
disabled?: boolean
formFromProps?: AnyFormApi
} & Pick<BaseFieldProps, 'fieldClassName' | 'labelClassName' | 'inputContainerClassName' | 'inputClassName'>
const BaseForm = ({
formSchemas = [],
defaultValues,
formClassName,
fieldClassName,
labelClassName,
inputContainerClassName,
inputClassName,
ref,
disabled,
formFromProps,
}: BaseFormProps) => {
const formFromHook = useForm({
defaultValues,
})
const form: any = formFromProps || formFromHook
const { getFormValues } = useGetFormValues(form, formSchemas)
const { getValidators } = useGetValidators()
useImperativeHandle(ref, () => {
return {
getForm() {
return form
},
getFormValues: (option) => {
return getFormValues(option)
},
}
}, [form, getFormValues])
const renderField = useCallback((field: AnyFieldApi) => {
const formSchema = formSchemas?.find(schema => schema.name === field.name)
if (formSchema) {
return (
<BaseField
field={field}
formSchema={formSchema}
fieldClassName={fieldClassName}
labelClassName={labelClassName}
inputContainerClassName={inputContainerClassName}
inputClassName={inputClassName}
disabled={disabled}
/>
)
}
return null
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled])
const renderFieldWrapper = useCallback((formSchema: FormSchema) => {
const validators = getValidators(formSchema)
const {
name,
} = formSchema
return (
<form.Field
key={name}
name={name}
validators={validators}
>
{renderField}
</form.Field>
)
}, [renderField, form, getValidators])
if (!formSchemas?.length)
return null
return (
<form
className={cn(formClassName)}
>
{formSchemas.map(renderFieldWrapper)}
</form>
)
}
export default memo(BaseForm)

View File

@@ -0,0 +1,2 @@
export { default as BaseForm, type BaseFormProps } from './base-form'
export { default as BaseField, type BaseFieldProps } from './base-field'

View File

@@ -0,0 +1,23 @@
import { memo } from 'react'
import { BaseForm } from '../../components/base'
import type { BaseFormProps } from '../../components/base'
const AuthForm = ({
formSchemas = [],
defaultValues,
ref,
formFromProps,
}: BaseFormProps) => {
return (
<BaseForm
ref={ref}
formSchemas={formSchemas}
defaultValues={defaultValues}
formClassName='space-y-4'
labelClassName='h-6 flex items-center mb-1 system-sm-medium text-text-secondary'
formFromProps={formFromProps}
/>
)
}
export default memo(AuthForm)

View File

@@ -0,0 +1,3 @@
export * from './use-check-validated'
export * from './use-get-form-values'
export * from './use-get-validators'

View File

@@ -0,0 +1,48 @@
import { useCallback } from 'react'
import type { AnyFormApi } from '@tanstack/react-form'
import { useToastContext } from '@/app/components/base/toast'
import type { FormSchema } from '@/app/components/base/form/types'
export const useCheckValidated = (form: AnyFormApi, FormSchemas: FormSchema[]) => {
const { notify } = useToastContext()
const checkValidated = useCallback(() => {
const allError = form?.getAllErrors()
const values = form.state.values
if (allError) {
const fields = allError.fields
const errorArray = Object.keys(fields).reduce((acc: string[], key: string) => {
const currentSchema = FormSchemas.find(schema => schema.name === key)
const { show_on = [] } = currentSchema || {}
const showOnValues = show_on.reduce((acc, condition) => {
acc[condition.variable] = values[condition.variable]
return acc
}, {} as Record<string, any>)
const show = show_on?.every((condition) => {
const conditionValue = showOnValues[condition.variable]
return conditionValue === condition.value
})
const errors: any[] = show ? fields[key].errors : []
return [...acc, ...errors]
}, [] as string[])
if (errorArray.length) {
notify({
type: 'error',
message: errorArray[0],
})
return false
}
return true
}
return true
}, [form, notify, FormSchemas])
return {
checkValidated,
}
}

View File

@@ -0,0 +1,44 @@
import { useCallback } from 'react'
import type { AnyFormApi } from '@tanstack/react-form'
import { useCheckValidated } from './use-check-validated'
import type {
FormSchema,
GetValuesOptions,
} from '../types'
import { getTransformedValuesWhenSecretInputPristine } from '../utils'
export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => {
const { checkValidated } = useCheckValidated(form, formSchemas)
const getFormValues = useCallback((
{
needCheckValidatedValues,
needTransformWhenSecretFieldIsPristine,
}: GetValuesOptions,
) => {
const values = form?.store.state.values || {}
if (!needCheckValidatedValues) {
return {
values,
isCheckValidated: false,
}
}
if (checkValidated()) {
return {
values: needTransformWhenSecretFieldIsPristine ? getTransformedValuesWhenSecretInputPristine(formSchemas, form) : values,
isCheckValidated: true,
}
}
else {
return {
values: {},
isCheckValidated: false,
}
}
}, [form, checkValidated, formSchemas])
return {
getFormValues,
}
}

View File

@@ -0,0 +1,36 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import type { FormSchema } from '../types'
export const useGetValidators = () => {
const { t } = useTranslation()
const getValidators = useCallback((formSchema: FormSchema) => {
const {
name,
validators,
required,
} = formSchema
let mergedValidators = validators
if (required && !validators) {
mergedValidators = {
onMount: ({ value }: any) => {
if (!value)
return t('common.errorMsg.fieldRequired', { field: name })
},
onChange: ({ value }: any) => {
if (!value)
return t('common.errorMsg.fieldRequired', { field: name })
},
onBlur: ({ value }: any) => {
if (!value)
return t('common.errorMsg.fieldRequired', { field: name })
},
}
}
return mergedValidators
}, [t])
return {
getValidators,
}
}

View File

@@ -0,0 +1,76 @@
import type {
ForwardedRef,
ReactNode,
} from 'react'
import type {
AnyFormApi,
FieldValidators,
} from '@tanstack/react-form'
export type TypeWithI18N<T = string> = {
en_US: T
zh_Hans: T
[key: string]: T
}
export type FormShowOnObject = {
variable: string
value: string
}
export enum FormTypeEnum {
textInput = 'text-input',
textNumber = 'number-input',
secretInput = 'secret-input',
select = 'select',
radio = 'radio',
boolean = 'boolean',
files = 'files',
file = 'file',
modelSelector = 'model-selector',
toolSelector = 'tool-selector',
multiToolSelector = 'array[tools]',
appSelector = 'app-selector',
dynamicSelect = 'dynamic-select',
}
export type FormOption = {
label: TypeWithI18N | string
value: string
show_on?: FormShowOnObject[]
icon?: string
}
export type AnyValidators = FieldValidators<any, any, any, any, any, any, any, any, any, any>
export type FormSchema = {
type: FormTypeEnum
name: string
label: string | ReactNode | TypeWithI18N
required: boolean
default?: any
tooltip?: string | TypeWithI18N
show_on?: FormShowOnObject[]
url?: string
scope?: string
help?: string | TypeWithI18N
placeholder?: string | TypeWithI18N
options?: FormOption[]
labelClassName?: string
validators?: AnyValidators
}
export type FormValues = Record<string, any>
export type GetValuesOptions = {
needTransformWhenSecretFieldIsPristine?: boolean
needCheckValidatedValues?: boolean
}
export type FormRefObject = {
getForm: () => AnyFormApi
getFormValues: (obj: GetValuesOptions) => {
values: Record<string, any>
isCheckValidated: boolean
}
}
export type FormRef = ForwardedRef<FormRefObject>

View File

@@ -0,0 +1 @@
export * from './secret-input'

View File

@@ -0,0 +1,29 @@
import type { AnyFormApi } from '@tanstack/react-form'
import type { FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record<string, any>) => {
const transformedValues: Record<string, any> = { ...values }
isPristineSecretInputNames.forEach((name) => {
if (transformedValues[name])
transformedValues[name] = '[__HIDDEN__]'
})
return transformedValues
}
export const getTransformedValuesWhenSecretInputPristine = (formSchemas: FormSchema[], form: AnyFormApi) => {
const values = form?.store.state.values || {}
const isPristineSecretInputNames: string[] = []
for (let i = 0; i < formSchemas.length; i++) {
const schema = formSchemas[i]
if (schema.type === FormTypeEnum.secretInput) {
const fieldMeta = form?.getFieldMeta(schema.name)
if (fieldMeta?.isPristine)
isPristineSecretInputNames.push(schema.name)
}
}
return transformFormSchemasSecretInput(isPristineSecretInputNames, values)
}

View File

@@ -0,0 +1,127 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import type { ButtonProps } from '@/app/components/base/button'
import cn from '@/utils/classnames'
type ModalProps = {
onClose?: () => void
size?: 'sm' | 'md'
title: string
subTitle?: string
children?: React.ReactNode
confirmButtonText?: string
onConfirm?: () => void
cancelButtonText?: string
onCancel?: () => void
showExtraButton?: boolean
extraButtonText?: string
extraButtonVariant?: ButtonProps['variant']
onExtraButtonClick?: () => void
footerSlot?: React.ReactNode
bottomSlot?: React.ReactNode
disabled?: boolean
}
const Modal = ({
onClose,
size = 'sm',
title,
subTitle,
children,
confirmButtonText,
onConfirm,
cancelButtonText,
onCancel,
showExtraButton,
extraButtonVariant = 'warning',
extraButtonText,
onExtraButtonClick,
footerSlot,
bottomSlot,
disabled,
}: ModalProps) => {
const { t } = useTranslation()
return (
<PortalToFollowElem open>
<PortalToFollowElemContent
className='z-[9998] flex h-full w-full items-center justify-center bg-background-overlay'
onClick={onClose}
>
<div
className={cn(
'max-h-[80%] w-[480px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs',
size === 'sm' && 'w-[480px',
size === 'md' && 'w-[640px]',
)}
onClick={e => e.stopPropagation()}
>
<div className='title-2xl-semi-bold relative p-6 pb-3 pr-14 text-text-primary'>
{title}
{
subTitle && (
<div className='system-xs-regular mt-1 text-text-tertiary'>
{subTitle}
</div>
)
}
<div
className='absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
onClick={onClose}
>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
</div>
{
children && (
<div className='px-6 py-3'>{children}</div>
)
}
<div className='flex justify-between p-6 pt-5'>
<div>
{footerSlot}
</div>
<div className='flex items-center'>
{
showExtraButton && (
<>
<Button
variant={extraButtonVariant}
onClick={onExtraButtonClick}
disabled={disabled}
>
{extraButtonText || t('common.operation.remove')}
</Button>
<div className='mx-3 h-4 w-[1px] bg-divider-regular'></div>
</>
)
}
<Button
onClick={onCancel}
disabled={disabled}
>
{cancelButtonText || t('common.operation.cancel')}
</Button>
<Button
className='ml-2'
variant='primary'
onClick={onConfirm}
disabled={disabled}
>
{confirmButtonText || t('common.operation.save')}
</Button>
</div>
</div>
{bottomSlot}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(Modal)

View File

@@ -39,6 +39,9 @@ type PureSelectProps = {
itemClassName?: string itemClassName?: string
title?: string title?: string
}, },
placeholder?: string
disabled?: boolean
triggerPopupSameWidth?: boolean
} }
const PureSelect = ({ const PureSelect = ({
options, options,
@@ -47,6 +50,9 @@ const PureSelect = ({
containerProps, containerProps,
triggerProps, triggerProps,
popupProps, popupProps,
placeholder,
disabled,
triggerPopupSameWidth,
}: PureSelectProps) => { }: PureSelectProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
@@ -74,7 +80,7 @@ const PureSelect = ({
}, [onOpenChange]) }, [onOpenChange])
const selectedOption = options.find(option => option.value === value) const selectedOption = options.find(option => option.value === value)
const triggerText = selectedOption?.label || t('common.placeholder.select') const triggerText = selectedOption?.label || placeholder || t('common.placeholder.select')
return ( return (
<PortalToFollowElem <PortalToFollowElem
@@ -82,6 +88,7 @@ const PureSelect = ({
offset={offset || 4} offset={offset || 4}
open={mergedOpen} open={mergedOpen}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
triggerPopupSameWidth={triggerPopupSameWidth}
> >
<PortalToFollowElemTrigger <PortalToFollowElemTrigger
onClick={() => handleOpenChange(!mergedOpen)} onClick={() => handleOpenChange(!mergedOpen)}
@@ -135,6 +142,7 @@ const PureSelect = ({
)} )}
title={option.label} title={option.label}
onClick={() => { onClick={() => {
if (disabled) return
onChange?.(option.value) onChange?.(option.value)
handleOpenChange(false) handleOpenChange(false)
}} }}

View File

@@ -0,0 +1,50 @@
import {
memo,
useState,
} from 'react'
import Button from '@/app/components/base/button'
import type { ButtonProps } from '@/app/components/base/button'
import ApiKeyModal from './api-key-modal'
import type { PluginPayload } from '../types'
export type AddApiKeyButtonProps = {
pluginPayload: PluginPayload
buttonVariant?: ButtonProps['variant']
buttonText?: string
disabled?: boolean
onUpdate?: () => void
}
const AddApiKeyButton = ({
pluginPayload,
buttonVariant = 'secondary-accent',
buttonText = 'use api key',
disabled,
onUpdate,
}: AddApiKeyButtonProps) => {
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false)
return (
<>
<Button
className='w-full'
variant={buttonVariant}
onClick={() => setIsApiKeyModalOpen(true)}
disabled={disabled}
>
{buttonText}
</Button>
{
isApiKeyModalOpen && (
<ApiKeyModal
pluginPayload={pluginPayload}
onClose={() => setIsApiKeyModalOpen(false)}
onUpdate={onUpdate}
/>
)
}
</>
)
}
export default memo(AddApiKeyButton)

View File

@@ -0,0 +1,259 @@
import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiClipboardLine,
RiEqualizer2Line,
RiInformation2Fill,
} from '@remixicon/react'
import Button from '@/app/components/base/button'
import type { ButtonProps } from '@/app/components/base/button'
import OAuthClientSettings from './oauth-client-settings'
import cn from '@/utils/classnames'
import type { PluginPayload } from '../types'
import { openOAuthPopup } from '@/hooks/use-oauth'
import Badge from '@/app/components/base/badge'
import {
useGetPluginOAuthClientSchemaHook,
useGetPluginOAuthUrlHook,
} from '../hooks/use-credential'
import type { FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import ActionButton from '@/app/components/base/action-button'
import { useRenderI18nObject } from '@/hooks/use-i18n'
export type AddOAuthButtonProps = {
pluginPayload: PluginPayload
buttonVariant?: ButtonProps['variant']
buttonText?: string
className?: string
buttonLeftClassName?: string
buttonRightClassName?: string
dividerClassName?: string
disabled?: boolean
onUpdate?: () => void
}
const AddOAuthButton = ({
pluginPayload,
buttonVariant = 'primary',
buttonText = 'use oauth',
className,
buttonLeftClassName,
buttonRightClassName,
dividerClassName,
disabled,
onUpdate,
}: AddOAuthButtonProps) => {
const { t } = useTranslation()
const renderI18nObject = useRenderI18nObject()
const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false)
const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload)
const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload)
const {
schema = [],
is_oauth_custom_client_enabled,
is_system_oauth_params_exists,
client_params,
redirect_uri,
} = data || {}
const isConfigured = is_system_oauth_params_exists || is_oauth_custom_client_enabled
const handleOAuth = useCallback(async () => {
const { authorization_url } = await getPluginOAuthUrl()
if (authorization_url) {
openOAuthPopup(
authorization_url,
() => onUpdate?.(),
)
}
}, [getPluginOAuthUrl, onUpdate])
const renderCustomLabel = useCallback((item: FormSchema) => {
return (
<div className='w-full'>
<div className='mb-4 flex rounded-xl bg-background-section-burn p-4'>
<div className='mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg'>
<RiInformation2Fill className='h-5 w-5 text-text-accent' />
</div>
<div className='w-0 grow'>
<div className='system-sm-regular mb-1.5'>
{t('plugin.auth.clientInfo')}
</div>
{
redirect_uri && (
<div className='system-sm-medium flex w-full py-0.5'>
<div className='w-0 grow break-words'>{redirect_uri}</div>
<ActionButton
className='shrink-0'
onClick={() => {
navigator.clipboard.writeText(redirect_uri || '')
}}
>
<RiClipboardLine className='h-4 w-4' />
</ActionButton>
</div>
)
}
</div>
</div>
<div className='system-sm-medium flex h-6 items-center text-text-secondary'>
{renderI18nObject(item.label as Record<string, string>)}
{
item.required && (
<span className='ml-1 text-text-destructive-secondary'>*</span>
)
}
</div>
</div>
)
}, [t, redirect_uri, renderI18nObject])
const memorizedSchemas = useMemo(() => {
const result: FormSchema[] = schema.map((item, index) => {
return {
...item,
label: index === 0 ? renderCustomLabel(item) : item.label,
labelClassName: index === 0 ? 'h-auto' : undefined,
}
})
if (is_system_oauth_params_exists) {
result.unshift({
name: '__oauth_client__',
label: t('plugin.auth.oauthClient'),
type: FormTypeEnum.radio,
options: [
{
label: t('plugin.auth.default'),
value: 'default',
},
{
label: t('plugin.auth.custom'),
value: 'custom',
},
],
required: false,
default: is_oauth_custom_client_enabled ? 'custom' : 'default',
} as FormSchema)
result.forEach((item, index) => {
if (index > 0) {
item.show_on = [
{
variable: '__oauth_client__',
value: 'custom',
},
]
if (client_params)
item.default = client_params[item.name] || item.default
}
})
}
return result
}, [schema, renderCustomLabel, t, is_system_oauth_params_exists, is_oauth_custom_client_enabled, client_params])
const __auth_client__ = useMemo(() => {
if (isConfigured) {
if (is_oauth_custom_client_enabled)
return 'custom'
return 'default'
}
else {
if (is_system_oauth_params_exists)
return 'default'
return 'custom'
}
}, [isConfigured, is_oauth_custom_client_enabled, is_system_oauth_params_exists])
return (
<>
{
isConfigured && (
<Button
variant={buttonVariant}
className={cn(
'w-full px-0 py-0 hover:bg-components-button-primary-bg',
className,
)}
disabled={disabled}
onClick={handleOAuth}
>
<div className={cn(
'flex h-full w-0 grow items-center justify-center rounded-l-lg pl-0.5 hover:bg-components-button-primary-bg-hover',
buttonLeftClassName,
)}>
<div
className='truncate'
title={buttonText}
>
{buttonText}
</div>
{
is_oauth_custom_client_enabled && (
<Badge
className={cn(
'ml-1 mr-0.5',
buttonVariant === 'primary' && 'border-text-primary-on-surface bg-components-badge-bg-dimm text-text-primary-on-surface',
)}
>
{t('plugin.auth.custom')}
</Badge>
)
}
</div>
<div className={cn(
'h-4 w-[1px] shrink-0 bg-text-primary-on-surface opacity-[0.15]',
dividerClassName,
)}></div>
<div
className={cn(
'flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg hover:bg-components-button-primary-bg-hover',
buttonRightClassName,
)}
onClick={(e) => {
e.stopPropagation()
setIsOAuthSettingsOpen(true)
}}
>
<RiEqualizer2Line className='h-4 w-4' />
</div>
</Button>
)
}
{
!isConfigured && (
<Button
variant={buttonVariant}
onClick={() => setIsOAuthSettingsOpen(true)}
disabled={disabled}
className='w-full'
>
<RiEqualizer2Line className='mr-0.5 h-4 w-4' />
{t('plugin.auth.setupOAuth')}
</Button>
)
}
{
isOAuthSettingsOpen && (
<OAuthClientSettings
pluginPayload={pluginPayload}
onClose={() => setIsOAuthSettingsOpen(false)}
disabled={disabled || isLoading}
schemas={memorizedSchemas}
onAuth={handleOAuth}
editValues={{
...client_params,
__oauth_client__: __auth_client__,
}}
hasOriginalClientParams={Object.keys(client_params || {}).length > 0}
onUpdate={onUpdate}
/>
)
}
</>
)
}
export default memo(AddOAuthButton)

View File

@@ -0,0 +1,181 @@
import {
memo,
useCallback,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiExternalLinkLine } from '@remixicon/react'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import Modal from '@/app/components/base/modal/modal'
import { CredentialTypeEnum } from '../types'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import type { FormRefObject } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { useToastContext } from '@/app/components/base/toast'
import Loading from '@/app/components/base/loading'
import type { PluginPayload } from '../types'
import {
useAddPluginCredentialHook,
useGetPluginCredentialSchemaHook,
useUpdatePluginCredentialHook,
} from '../hooks/use-credential'
import { useRenderI18nObject } from '@/hooks/use-i18n'
export type ApiKeyModalProps = {
pluginPayload: PluginPayload
onClose?: () => void
editValues?: Record<string, any>
onRemove?: () => void
disabled?: boolean
onUpdate?: () => void
}
const ApiKeyModal = ({
pluginPayload,
onClose,
editValues,
onRemove,
disabled,
onUpdate,
}: ApiKeyModalProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const [doingAction, setDoingAction] = useState(false)
const doingActionRef = useRef(doingAction)
const handleSetDoingAction = useCallback((value: boolean) => {
doingActionRef.current = value
setDoingAction(value)
}, [])
const { data = [], isLoading } = useGetPluginCredentialSchemaHook(pluginPayload, CredentialTypeEnum.API_KEY)
const formSchemas = useMemo(() => {
return [
{
type: FormTypeEnum.textInput,
name: '__name__',
label: t('plugin.auth.authorizationName'),
required: false,
},
...data,
]
}, [data, t])
const defaultValues = formSchemas.reduce((acc, schema) => {
if (schema.default)
acc[schema.name] = schema.default
return acc
}, {} as Record<string, any>)
const helpField = formSchemas.find(schema => schema.url && schema.help)
const renderI18nObject = useRenderI18nObject()
const { mutateAsync: addPluginCredential } = useAddPluginCredentialHook(pluginPayload)
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
const formRef = useRef<FormRefObject>(null)
const handleConfirm = useCallback(async () => {
if (doingActionRef.current)
return
const {
isCheckValidated,
values,
} = formRef.current?.getFormValues({
needCheckValidatedValues: true,
needTransformWhenSecretFieldIsPristine: true,
}) || { isCheckValidated: false, values: {} }
if (!isCheckValidated)
return
try {
const {
__name__,
__credential_id__,
...restValues
} = values
handleSetDoingAction(true)
if (editValues) {
await updatePluginCredential({
credentials: restValues,
credential_id: __credential_id__,
name: __name__ || '',
})
}
else {
await addPluginCredential({
credentials: restValues,
type: CredentialTypeEnum.API_KEY,
name: __name__ || '',
})
}
notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
onClose?.()
onUpdate?.()
}
finally {
handleSetDoingAction(false)
}
}, [addPluginCredential, onClose, onUpdate, updatePluginCredential, notify, t, editValues, handleSetDoingAction])
return (
<Modal
size='md'
title={t('plugin.auth.useApiAuth')}
subTitle={t('plugin.auth.useApiAuthDesc')}
onClose={onClose}
onCancel={onClose}
footerSlot={
helpField && (
<a
className='system-xs-regular mr-2 flex items-center py-2 text-text-accent'
href={helpField?.url}
target='_blank'
>
<span className='break-all'>
{renderI18nObject(helpField?.help as any)}
</span>
<RiExternalLinkLine className='ml-1 h-3 w-3' />
</a>
)
}
bottomSlot={
<div className='flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary'>
<Lock01 className='mr-1 h-3 w-3 text-text-tertiary' />
{t('common.modelProvider.encrypted.front')}
<a
className='mx-1 text-text-accent'
target='_blank' rel='noopener noreferrer'
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
>
PKCS1_OAEP
</a>
{t('common.modelProvider.encrypted.back')}
</div>
}
onConfirm={handleConfirm}
showExtraButton={!!editValues}
onExtraButtonClick={onRemove}
disabled={disabled || isLoading || doingAction}
>
{
isLoading && (
<div className='flex h-40 items-center justify-center'>
<Loading />
</div>
)
}
{
!isLoading && !!data.length && (
<AuthForm
ref={formRef}
formSchemas={formSchemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
)
}
</Modal>
)
}
export default memo(ApiKeyModal)

View File

@@ -0,0 +1,104 @@
import {
memo,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import AddOAuthButton from './add-oauth-button'
import type { AddOAuthButtonProps } from './add-oauth-button'
import AddApiKeyButton from './add-api-key-button'
import type { AddApiKeyButtonProps } from './add-api-key-button'
import type { PluginPayload } from '../types'
type AuthorizeProps = {
pluginPayload: PluginPayload
theme?: 'primary' | 'secondary'
showDivider?: boolean
canOAuth?: boolean
canApiKey?: boolean
disabled?: boolean
onUpdate?: () => void
}
const Authorize = ({
pluginPayload,
theme = 'primary',
showDivider = true,
canOAuth,
canApiKey,
disabled,
onUpdate,
}: AuthorizeProps) => {
const { t } = useTranslation()
const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => {
if (theme === 'secondary') {
return {
buttonText: !canApiKey ? t('plugin.auth.useOAuthAuth') : t('plugin.auth.addOAuth'),
buttonVariant: 'secondary',
className: 'hover:bg-components-button-secondary-bg',
buttonLeftClassName: 'hover:bg-components-button-secondary-bg-hover',
buttonRightClassName: 'hover:bg-components-button-secondary-bg-hover',
dividerClassName: 'bg-divider-regular opacity-100',
pluginPayload,
}
}
return {
buttonText: !canApiKey ? t('plugin.auth.useOAuthAuth') : t('plugin.auth.addOAuth'),
pluginPayload,
}
}, [canApiKey, theme, pluginPayload, t])
const apiKeyButtonProps: AddApiKeyButtonProps = useMemo(() => {
if (theme === 'secondary') {
return {
pluginPayload,
buttonVariant: 'secondary',
buttonText: !canOAuth ? t('plugin.auth.useApiAuth') : t('plugin.auth.addApi'),
}
}
return {
pluginPayload,
buttonText: !canOAuth ? t('plugin.auth.useApiAuth') : t('plugin.auth.addApi'),
buttonVariant: !canOAuth ? 'primary' : 'secondary-accent',
}
}, [canOAuth, theme, pluginPayload, t])
return (
<>
<div className='flex items-center space-x-1.5'>
{
canOAuth && (
<div className='min-w-0 flex-[1]'>
<AddOAuthButton
{...oAuthButtonProps}
disabled={disabled}
onUpdate={onUpdate}
/>
</div>
)
}
{
showDivider && canOAuth && canApiKey && (
<div className='system-2xs-medium-uppercase flex shrink-0 flex-col items-center justify-between text-text-tertiary'>
<div className='h-2 w-[1px] bg-divider-subtle'></div>
or
<div className='h-2 w-[1px] bg-divider-subtle'></div>
</div>
)
}
{
canApiKey && (
<div className='min-w-0 flex-[1]'>
<AddApiKeyButton
{...apiKeyButtonProps}
disabled={disabled}
onUpdate={onUpdate}
/>
</div>
)
}
</div>
</>
)
}
export default memo(Authorize)

View File

@@ -0,0 +1,188 @@
import {
memo,
useCallback,
useRef,
useState,
} from 'react'
import { RiExternalLinkLine } from '@remixicon/react'
import {
useForm,
useStore,
} from '@tanstack/react-form'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal/modal'
import {
useDeletePluginOAuthCustomClientHook,
useInvalidPluginOAuthClientSchemaHook,
useSetPluginOAuthCustomClientHook,
} from '../hooks/use-credential'
import type { PluginPayload } from '../types'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import type {
FormRefObject,
FormSchema,
} from '@/app/components/base/form/types'
import { useToastContext } from '@/app/components/base/toast'
import Button from '@/app/components/base/button'
import { useRenderI18nObject } from '@/hooks/use-i18n'
type OAuthClientSettingsProps = {
pluginPayload: PluginPayload
onClose?: () => void
editValues?: Record<string, any>
disabled?: boolean
schemas: FormSchema[]
onAuth?: () => Promise<void>
hasOriginalClientParams?: boolean
onUpdate?: () => void
}
const OAuthClientSettings = ({
pluginPayload,
onClose,
editValues,
disabled,
schemas,
onAuth,
hasOriginalClientParams,
onUpdate,
}: OAuthClientSettingsProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const [doingAction, setDoingAction] = useState(false)
const doingActionRef = useRef(doingAction)
const handleSetDoingAction = useCallback((value: boolean) => {
doingActionRef.current = value
setDoingAction(value)
}, [])
const defaultValues = schemas.reduce((acc, schema) => {
if (schema.default)
acc[schema.name] = schema.default
return acc
}, {} as Record<string, any>)
const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload)
const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload)
const formRef = useRef<FormRefObject>(null)
const handleConfirm = useCallback(async () => {
if (doingActionRef.current)
return
try {
const {
isCheckValidated,
values,
} = formRef.current?.getFormValues({
needCheckValidatedValues: true,
needTransformWhenSecretFieldIsPristine: true,
}) || { isCheckValidated: false, values: {} }
if (!isCheckValidated)
throw new Error('error')
const {
__oauth_client__,
...restValues
} = values
handleSetDoingAction(true)
await setPluginOAuthCustomClient({
client_params: restValues,
enable_oauth_custom_client: __oauth_client__ === 'custom',
})
notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
onClose?.()
onUpdate?.()
invalidPluginOAuthClientSchema()
}
finally {
handleSetDoingAction(false)
}
}, [onClose, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, notify, t, handleSetDoingAction])
const handleConfirmAndAuthorize = useCallback(async () => {
await handleConfirm()
if (onAuth)
await onAuth()
}, [handleConfirm, onAuth])
const { mutateAsync: deletePluginOAuthCustomClient } = useDeletePluginOAuthCustomClientHook(pluginPayload)
const handleRemove = useCallback(async () => {
if (doingActionRef.current)
return
try {
handleSetDoingAction(true)
await deletePluginOAuthCustomClient()
notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
onClose?.()
onUpdate?.()
invalidPluginOAuthClientSchema()
}
finally {
handleSetDoingAction(false)
}
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, notify, t, handleSetDoingAction, onClose])
const form = useForm({
defaultValues: editValues || defaultValues,
})
const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__)
const helpField = schemas.find(schema => schema.url && schema.help)
const renderI18nObject = useRenderI18nObject()
return (
<Modal
title={t('plugin.auth.oauthClientSettings')}
confirmButtonText={t('plugin.auth.saveAndAuth')}
cancelButtonText={t('plugin.auth.saveOnly')}
extraButtonText={t('common.operation.cancel')}
showExtraButton
extraButtonVariant='secondary'
onExtraButtonClick={onClose}
onClose={onClose}
onCancel={handleConfirm}
onConfirm={handleConfirmAndAuthorize}
disabled={disabled || doingAction}
footerSlot={
__oauth_client__ === 'custom' && hasOriginalClientParams && (
<div className='grow'>
<Button
variant='secondary'
className='text-components-button-destructive-secondary-text'
disabled={disabled || doingAction || !editValues}
onClick={handleRemove}
>
{t('common.operation.remove')}
</Button>
</div>
)
}
>
<>
<AuthForm
formFromProps={form}
ref={formRef}
formSchemas={schemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
{
helpField && __oauth_client__ === 'custom' && (
<a
className='system-xs-regular mt-4 flex items-center text-text-accent'
href={helpField?.url}
target='_blank'
>
<span className='break-all'>
{renderI18nObject(helpField?.help as any)}
</span>
<RiExternalLinkLine className='ml-1 h-3 w-3' />
</a>
)}
</>
</Modal>
)
}
export default memo(OAuthClientSettings)

View File

@@ -0,0 +1,113 @@
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import cn from '@/utils/classnames'
import type {
Credential,
PluginPayload,
} from './types'
import {
Authorized,
usePluginAuth,
} from '.'
type AuthorizedInNodeProps = {
pluginPayload: PluginPayload
onAuthorizationItemClick: (id: string) => void
credentialId?: string
}
const AuthorizedInNode = ({
pluginPayload,
onAuthorizationItemClick,
credentialId,
}: AuthorizedInNodeProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const {
canApiKey,
canOAuth,
credentials,
disabled,
invalidPluginCredentialInfo,
} = usePluginAuth(pluginPayload, isOpen || !!credentialId)
const renderTrigger = useCallback((open?: boolean) => {
let label = ''
let removed = false
if (!credentialId) {
label = t('plugin.auth.workspaceDefault')
}
else {
const credential = credentials.find(c => c.id === credentialId)
label = credential ? credential.name : t('plugin.auth.authRemoved')
removed = !credential
}
return (
<Button
size='small'
className={cn(
open && !removed && 'bg-components-button-ghost-bg-hover',
removed && 'bg-transparent text-text-destructive',
)}
>
<Indicator
className='mr-1.5'
color={removed ? 'red' : 'green'}
/>
{label}
<RiArrowDownSLine
className={cn(
'h-3.5 w-3.5 text-components-button-ghost-text',
removed && 'text-text-destructive',
)}
/>
</Button>
)
}, [credentialId, credentials, t])
const extraAuthorizationItems: Credential[] = [
{
id: '__workspace_default__',
name: t('plugin.auth.workspaceDefault'),
provider: '',
is_default: !credentialId,
isWorkspaceDefault: true,
},
]
const handleAuthorizationItemClick = useCallback((id: string) => {
onAuthorizationItemClick(id)
setIsOpen(false)
}, [
onAuthorizationItemClick,
setIsOpen,
])
return (
<Authorized
pluginPayload={pluginPayload}
credentials={credentials}
canOAuth={canOAuth}
canApiKey={canApiKey}
renderTrigger={renderTrigger}
isOpen={isOpen}
onOpenChange={setIsOpen}
offset={4}
placement='bottom-end'
triggerPopupSameWidth={false}
popupClassName='w-[360px]'
disabled={disabled}
disableSetDefault
onItemClick={handleAuthorizationItemClick}
extraAuthorizationItems={extraAuthorizationItems}
showItemSelectedIcon
selectedCredentialId={credentialId || '__workspace_default__'}
onUpdate={invalidPluginCredentialInfo}
/>
)
}
export default memo(AuthorizedInNode)

View File

@@ -0,0 +1,342 @@
import {
memo,
useCallback,
useRef,
useState,
} from 'react'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import type {
PortalToFollowElemOptions,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import cn from '@/utils/classnames'
import Confirm from '@/app/components/base/confirm'
import Authorize from '../authorize'
import type { Credential } from '../types'
import { CredentialTypeEnum } from '../types'
import ApiKeyModal from '../authorize/api-key-modal'
import Item from './item'
import { useToastContext } from '@/app/components/base/toast'
import type { PluginPayload } from '../types'
import {
useDeletePluginCredentialHook,
useSetPluginDefaultCredentialHook,
useUpdatePluginCredentialHook,
} from '../hooks/use-credential'
type AuthorizedProps = {
pluginPayload: PluginPayload
credentials: Credential[]
canOAuth?: boolean
canApiKey?: boolean
disabled?: boolean
renderTrigger?: (open?: boolean) => React.ReactNode
isOpen?: boolean
onOpenChange?: (open: boolean) => void
offset?: PortalToFollowElemOptions['offset']
placement?: PortalToFollowElemOptions['placement']
triggerPopupSameWidth?: boolean
popupClassName?: string
disableSetDefault?: boolean
onItemClick?: (id: string) => void
extraAuthorizationItems?: Credential[]
showItemSelectedIcon?: boolean
selectedCredentialId?: string
onUpdate?: () => void
}
const Authorized = ({
pluginPayload,
credentials,
canOAuth,
canApiKey,
disabled,
renderTrigger,
isOpen,
onOpenChange,
offset = 8,
placement = 'bottom-start',
triggerPopupSameWidth = true,
popupClassName,
disableSetDefault,
onItemClick,
extraAuthorizationItems,
showItemSelectedIcon,
selectedCredentialId,
onUpdate,
}: AuthorizedProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const [isLocalOpen, setIsLocalOpen] = useState(false)
const mergedIsOpen = isOpen ?? isLocalOpen
const setMergedIsOpen = useCallback((open: boolean) => {
if (onOpenChange)
onOpenChange(open)
setIsLocalOpen(open)
}, [onOpenChange])
const oAuthCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.OAUTH2)
const apiKeyCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.API_KEY)
const pendingOperationCredentialId = useRef<string | null>(null)
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
const openConfirm = useCallback((credentialId?: string) => {
if (credentialId)
pendingOperationCredentialId.current = credentialId
setDeleteCredentialId(pendingOperationCredentialId.current)
}, [])
const closeConfirm = useCallback(() => {
setDeleteCredentialId(null)
pendingOperationCredentialId.current = null
}, [])
const [doingAction, setDoingAction] = useState(false)
const doingActionRef = useRef(doingAction)
const handleSetDoingAction = useCallback((doing: boolean) => {
doingActionRef.current = doing
setDoingAction(doing)
}, [])
const handleConfirm = useCallback(async () => {
if (doingActionRef.current)
return
if (!pendingOperationCredentialId.current) {
setDeleteCredentialId(null)
return
}
try {
handleSetDoingAction(true)
await deletePluginCredential({ credential_id: pendingOperationCredentialId.current })
notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
onUpdate?.()
setDeleteCredentialId(null)
pendingOperationCredentialId.current = null
}
finally {
handleSetDoingAction(false)
}
}, [deletePluginCredential, onUpdate, notify, t, handleSetDoingAction])
const [editValues, setEditValues] = useState<Record<string, any> | null>(null)
const handleEdit = useCallback((id: string, values: Record<string, any>) => {
pendingOperationCredentialId.current = id
setEditValues(values)
}, [])
const handleRemove = useCallback(() => {
setDeleteCredentialId(pendingOperationCredentialId.current)
}, [])
const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload)
const handleSetDefault = useCallback(async (id: string) => {
if (doingActionRef.current)
return
try {
handleSetDoingAction(true)
await setPluginDefaultCredential(id)
notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
onUpdate?.()
}
finally {
handleSetDoingAction(false)
}
}, [setPluginDefaultCredential, onUpdate, notify, t, handleSetDoingAction])
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
const handleRename = useCallback(async (payload: {
credential_id: string
name: string
}) => {
if (doingActionRef.current)
return
try {
handleSetDoingAction(true)
await updatePluginCredential(payload)
notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
onUpdate?.()
}
finally {
handleSetDoingAction(false)
}
}, [updatePluginCredential, notify, t, handleSetDoingAction, onUpdate])
return (
<>
<PortalToFollowElem
open={mergedIsOpen}
onOpenChange={setMergedIsOpen}
placement={placement}
offset={offset}
triggerPopupSameWidth={triggerPopupSameWidth}
>
<PortalToFollowElemTrigger
onClick={() => setMergedIsOpen(!mergedIsOpen)}
asChild
>
{
renderTrigger
? renderTrigger(mergedIsOpen)
: (
<Button
className={cn(
'w-full',
isOpen && 'bg-components-button-secondary-bg-hover',
)}>
<Indicator className='mr-2' />
{credentials.length}&nbsp;
{
credentials.length > 1
? t('plugin.auth.authorizations')
: t('plugin.auth.authorization')
}
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
</Button>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[100]'>
<div className={cn(
'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg',
popupClassName,
)}>
<div className='py-1'>
{
!!extraAuthorizationItems?.length && (
<div className='p-1'>
{
extraAuthorizationItems.map(credential => (
<Item
key={credential.id}
credential={credential}
disabled={disabled}
onItemClick={onItemClick}
disableRename
disableEdit
disableDelete
disableSetDefault
showSelectedIcon={showItemSelectedIcon}
selectedCredentialId={selectedCredentialId}
/>
))
}
</div>
)
}
{
!!oAuthCredentials.length && (
<div className='p-1'>
<div className={cn(
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
showItemSelectedIcon && 'pl-7',
)}>
OAuth
</div>
{
oAuthCredentials.map(credential => (
<Item
key={credential.id}
credential={credential}
disabled={disabled}
disableEdit
onDelete={openConfirm}
onSetDefault={handleSetDefault}
onRename={handleRename}
disableSetDefault={disableSetDefault}
onItemClick={onItemClick}
showSelectedIcon={showItemSelectedIcon}
selectedCredentialId={selectedCredentialId}
/>
))
}
</div>
)
}
{
!!apiKeyCredentials.length && (
<div className='p-1'>
<div className={cn(
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
showItemSelectedIcon && 'pl-7',
)}>
API Keys
</div>
{
apiKeyCredentials.map(credential => (
<Item
key={credential.id}
credential={credential}
disabled={disabled}
onDelete={openConfirm}
onEdit={handleEdit}
onSetDefault={handleSetDefault}
disableSetDefault={disableSetDefault}
disableRename
onItemClick={onItemClick}
onRename={handleRename}
showSelectedIcon={showItemSelectedIcon}
selectedCredentialId={selectedCredentialId}
/>
))
}
</div>
)
}
</div>
<div className='h-[1px] bg-divider-subtle'></div>
<div className='p-2'>
<Authorize
pluginPayload={pluginPayload}
theme='secondary'
showDivider={false}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
onUpdate={onUpdate}
/>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{
deleteCredentialId && (
<Confirm
isShow
title={t('datasetDocuments.list.delete.title')}
isDisabled={doingAction}
onCancel={closeConfirm}
onConfirm={handleConfirm}
/>
)
}
{
!!editValues && (
<ApiKeyModal
pluginPayload={pluginPayload}
editValues={editValues}
onClose={() => {
setEditValues(null)
pendingOperationCredentialId.current = null
}}
onRemove={handleRemove}
disabled={disabled || doingAction}
onUpdate={onUpdate}
/>
)
}
</>
)
}
export default memo(Authorized)

View File

@@ -0,0 +1,219 @@
import {
memo,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCheckLine,
RiDeleteBinLine,
RiEditLine,
RiEqualizer2Line,
} from '@remixicon/react'
import Indicator from '@/app/components/header/indicator'
import Badge from '@/app/components/base/badge'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import cn from '@/utils/classnames'
import type { Credential } from '../types'
import { CredentialTypeEnum } from '../types'
type ItemProps = {
credential: Credential
disabled?: boolean
onDelete?: (id: string) => void
onEdit?: (id: string, values: Record<string, any>) => void
onSetDefault?: (id: string) => void
onRename?: (payload: {
credential_id: string
name: string
}) => void
disableRename?: boolean
disableEdit?: boolean
disableDelete?: boolean
disableSetDefault?: boolean
onItemClick?: (id: string) => void
showSelectedIcon?: boolean
selectedCredentialId?: string
}
const Item = ({
credential,
disabled,
onDelete,
onEdit,
onSetDefault,
onRename,
disableRename,
disableEdit,
disableDelete,
disableSetDefault,
onItemClick,
showSelectedIcon,
selectedCredentialId,
}: ItemProps) => {
const { t } = useTranslation()
const [renaming, setRenaming] = useState(false)
const [renameValue, setRenameValue] = useState(credential.name)
const isOAuth = credential.credential_type === CredentialTypeEnum.OAUTH2
const showAction = useMemo(() => {
return !(disableRename && disableEdit && disableDelete && disableSetDefault)
}, [disableRename, disableEdit, disableDelete, disableSetDefault])
return (
<div
key={credential.id}
className={cn(
'group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover',
renaming && 'bg-state-base-hover',
)}
onClick={() => onItemClick?.(credential.id === '__workspace_default__' ? '' : credential.id)}
>
{
renaming && (
<div className='flex w-full items-center space-x-1'>
<Input
wrapperClassName='grow rounded-[6px]'
className='h-6'
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
placeholder={t('common.placeholder.input')}
onClick={e => e.stopPropagation()}
/>
<Button
size='small'
variant='primary'
onClick={(e) => {
e.stopPropagation()
onRename?.({
credential_id: credential.id,
name: renameValue,
})
setRenaming(false)
}}
>
{t('common.operation.save')}
</Button>
<Button
size='small'
onClick={(e) => {
e.stopPropagation()
setRenaming(false)
}}
>
{t('common.operation.cancel')}
</Button>
</div>
)
}
{
!renaming && (
<div className='flex w-0 grow items-center space-x-1.5'>
{
showSelectedIcon && (
<div className='h-4 w-4'>
{
selectedCredentialId === credential.id && (
<RiCheckLine className='h-4 w-4 text-text-accent' />
)
}
</div>
)
}
<Indicator className='ml-2 mr-1.5 shrink-0' />
<div
className='system-md-regular truncate text-text-secondary'
title={credential.name}
>
{credential.name}
</div>
{
credential.is_default && (
<Badge className='shrink-0'>
{t('plugin.auth.default')}
</Badge>
)
}
</div>
)
}
{
showAction && !renaming && (
<div className='ml-2 hidden shrink-0 items-center group-hover:flex'>
{
!credential.is_default && !disableSetDefault && (
<Button
size='small'
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onSetDefault?.(credential.id)
}}
>
{t('plugin.auth.setDefault')}
</Button>
)
}
{
!disableRename && (
<Tooltip popupContent={t('common.operation.rename')}>
<ActionButton
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
setRenaming(true)
setRenameValue(credential.name)
}}
>
<RiEditLine className='h-4 w-4 text-text-tertiary' />
</ActionButton>
</Tooltip>
)
}
{
!isOAuth && !disableEdit && (
<Tooltip popupContent={t('common.operation.edit')}>
<ActionButton
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onEdit?.(
credential.id,
{
...credential.credentials,
__name__: credential.name,
__credential_id__: credential.id,
},
)
}}
>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</ActionButton>
</Tooltip>
)
}
{
!disableDelete && (
<Tooltip popupContent={t('common.operation.delete')}>
<ActionButton
className='hover:bg-transparent'
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onDelete?.(credential.id)
}}
>
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary hover:text-text-destructive' />
</ActionButton>
</Tooltip>
)
}
</div>
)
}
</div>
)
}
export default memo(Item)

View File

@@ -0,0 +1,88 @@
import {
useAddPluginCredential,
useDeletePluginCredential,
useDeletePluginOAuthCustomClient,
useGetPluginCredentialInfo,
useGetPluginCredentialSchema,
useGetPluginOAuthClientSchema,
useGetPluginOAuthUrl,
useInvalidPluginCredentialInfo,
useInvalidPluginOAuthClientSchema,
useSetPluginDefaultCredential,
useSetPluginOAuthCustomClient,
useUpdatePluginCredential,
} from '@/service/use-plugins-auth'
import { useGetApi } from './use-get-api'
import type { PluginPayload } from '../types'
import type { CredentialTypeEnum } from '../types'
export const useGetPluginCredentialInfoHook = (pluginPayload: PluginPayload, enable?: boolean) => {
const apiMap = useGetApi(pluginPayload)
return useGetPluginCredentialInfo(enable ? apiMap.getCredentialInfo : '')
}
export const useDeletePluginCredentialHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useDeletePluginCredential(apiMap.deleteCredential)
}
export const useInvalidPluginCredentialInfoHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useInvalidPluginCredentialInfo(apiMap.getCredentialInfo)
}
export const useSetPluginDefaultCredentialHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useSetPluginDefaultCredential(apiMap.setDefaultCredential)
}
export const useGetPluginCredentialSchemaHook = (pluginPayload: PluginPayload, credentialType: CredentialTypeEnum) => {
const apiMap = useGetApi(pluginPayload)
return useGetPluginCredentialSchema(apiMap.getCredentialSchema(credentialType))
}
export const useAddPluginCredentialHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useAddPluginCredential(apiMap.addCredential)
}
export const useUpdatePluginCredentialHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useUpdatePluginCredential(apiMap.updateCredential)
}
export const useGetPluginOAuthUrlHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useGetPluginOAuthUrl(apiMap.getOauthUrl)
}
export const useGetPluginOAuthClientSchemaHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useGetPluginOAuthClientSchema(apiMap.getOauthClientSchema)
}
export const useInvalidPluginOAuthClientSchemaHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useInvalidPluginOAuthClientSchema(apiMap.getOauthClientSchema)
}
export const useSetPluginOAuthCustomClientHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useSetPluginOAuthCustomClient(apiMap.setCustomOauthClient)
}
export const useDeletePluginOAuthCustomClientHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useDeletePluginOAuthCustomClient(apiMap.deleteCustomOAuthClient)
}

View File

@@ -0,0 +1,41 @@
import {
AuthCategory,
} from '../types'
import type {
CredentialTypeEnum,
PluginPayload,
} from '../types'
export const useGetApi = ({ category = AuthCategory.tool, provider }: PluginPayload) => {
if (category === AuthCategory.tool) {
return {
getCredentialInfo: `/workspaces/current/tool-provider/builtin/${provider}/credential/info`,
setDefaultCredential: `/workspaces/current/tool-provider/builtin/${provider}/default-credential`,
getCredentials: `/workspaces/current/tool-provider/builtin/${provider}/credentials`,
addCredential: `/workspaces/current/tool-provider/builtin/${provider}/add`,
updateCredential: `/workspaces/current/tool-provider/builtin/${provider}/update`,
deleteCredential: `/workspaces/current/tool-provider/builtin/${provider}/delete`,
getCredentialSchema: (credential_type: CredentialTypeEnum) => `/workspaces/current/tool-provider/builtin/${provider}/credential/schema/${credential_type}`,
getOauthUrl: `/oauth/plugin/${provider}/tool/authorization-url`,
getOauthClientSchema: `/workspaces/current/tool-provider/builtin/${provider}/oauth/client-schema`,
setCustomOauthClient: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
getCustomOAuthClientValues: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
deleteCustomOAuthClient: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
}
}
return {
getCredentialInfo: '',
setDefaultCredential: '',
getCredentials: '',
addCredential: '',
updateCredential: '',
deleteCredential: '',
getCredentialSchema: () => '',
getOauthUrl: '',
getOauthClientSchema: '',
setCustomOauthClient: '',
getCustomOAuthClientValues: '',
deleteCustomOAuthClient: '',
}
}

View File

@@ -0,0 +1,25 @@
import { useAppContext } from '@/context/app-context'
import {
useGetPluginCredentialInfoHook,
useInvalidPluginCredentialInfoHook,
} from './use-credential'
import { CredentialTypeEnum } from '../types'
import type { PluginPayload } from '../types'
export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) => {
const { data } = useGetPluginCredentialInfoHook(pluginPayload, enable)
const { isCurrentWorkspaceManager } = useAppContext()
const isAuthorized = !!data?.credentials.length
const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2)
const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY)
const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
return {
isAuthorized,
canOAuth,
canApiKey,
credentials: data?.credentials || [],
disabled: !isCurrentWorkspaceManager,
invalidPluginCredentialInfo,
}
}

View File

@@ -0,0 +1,6 @@
export { default as PluginAuth } from './plugin-auth'
export { default as Authorized } from './authorized'
export { default as AuthorizedInNode } from './authorized-in-node'
export { default as PluginAuthInAgent } from './plugin-auth-in-agent'
export { usePluginAuth } from './hooks/use-plugin-auth'
export * from './types'

View File

@@ -0,0 +1,123 @@
import {
memo,
useCallback,
useState,
} from 'react'
import { RiArrowDownSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Authorize from './authorize'
import Authorized from './authorized'
import type {
Credential,
PluginPayload,
} from './types'
import { usePluginAuth } from './hooks/use-plugin-auth'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import cn from '@/utils/classnames'
type PluginAuthInAgentProps = {
pluginPayload: PluginPayload
credentialId?: string
onAuthorizationItemClick?: (id: string) => void
}
const PluginAuthInAgent = ({
pluginPayload,
credentialId,
onAuthorizationItemClick,
}: PluginAuthInAgentProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const {
isAuthorized,
canOAuth,
canApiKey,
credentials,
disabled,
invalidPluginCredentialInfo,
} = usePluginAuth(pluginPayload, true)
const extraAuthorizationItems: Credential[] = [
{
id: '__workspace_default__',
name: t('plugin.auth.workspaceDefault'),
provider: '',
is_default: !credentialId,
isWorkspaceDefault: true,
},
]
const handleAuthorizationItemClick = useCallback((id: string) => {
onAuthorizationItemClick?.(id)
setIsOpen(false)
}, [
onAuthorizationItemClick,
setIsOpen,
])
const renderTrigger = useCallback((isOpen?: boolean) => {
let label = ''
let removed = false
if (!credentialId) {
label = t('plugin.auth.workspaceDefault')
}
else {
const credential = credentials.find(c => c.id === credentialId)
label = credential ? credential.name : t('plugin.auth.authRemoved')
removed = !credential
}
return (
<Button
className={cn(
'w-full',
isOpen && 'bg-components-button-secondary-bg-hover',
removed && 'text-text-destructive',
)}>
<Indicator
className='mr-2'
color={removed ? 'red' : 'green'}
/>
{label}
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
</Button>
)
}, [credentialId, credentials, t])
return (
<>
{
!isAuthorized && (
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
onUpdate={invalidPluginCredentialInfo}
/>
)
}
{
isAuthorized && (
<Authorized
pluginPayload={pluginPayload}
credentials={credentials}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
disableSetDefault
onItemClick={handleAuthorizationItemClick}
extraAuthorizationItems={extraAuthorizationItems}
showItemSelectedIcon
renderTrigger={renderTrigger}
isOpen={isOpen}
onOpenChange={setIsOpen}
selectedCredentialId={credentialId || '__workspace_default__'}
onUpdate={invalidPluginCredentialInfo}
/>
)
}
</>
)
}
export default memo(PluginAuthInAgent)

View File

@@ -0,0 +1,59 @@
import { memo } from 'react'
import Authorize from './authorize'
import Authorized from './authorized'
import type { PluginPayload } from './types'
import { usePluginAuth } from './hooks/use-plugin-auth'
import cn from '@/utils/classnames'
type PluginAuthProps = {
pluginPayload: PluginPayload
children?: React.ReactNode
className?: string
}
const PluginAuth = ({
pluginPayload,
children,
className,
}: PluginAuthProps) => {
const {
isAuthorized,
canOAuth,
canApiKey,
credentials,
disabled,
invalidPluginCredentialInfo,
} = usePluginAuth(pluginPayload, !!pluginPayload.provider)
return (
<div className={cn(!isAuthorized && className)}>
{
!isAuthorized && (
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
onUpdate={invalidPluginCredentialInfo}
/>
)
}
{
isAuthorized && !children && (
<Authorized
pluginPayload={pluginPayload}
credentials={credentials}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
onUpdate={invalidPluginCredentialInfo}
/>
)
}
{
isAuthorized && children
}
</div>
)
}
export default memo(PluginAuth)

View File

@@ -0,0 +1,25 @@
export enum AuthCategory {
tool = 'tool',
datasource = 'datasource',
model = 'model',
}
export type PluginPayload = {
category: AuthCategory
provider: string
}
export enum CredentialTypeEnum {
OAUTH2 = 'oauth2',
API_KEY = 'api-key',
}
export type Credential = {
id: string
name: string
provider: string
credential_type?: CredentialTypeEnum
is_default: boolean
credentials?: Record<string, any>
isWorkspaceDefault?: boolean
}

View File

@@ -0,0 +1,10 @@
export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record<string, any>) => {
const transformedValues: Record<string, any> = { ...values }
isPristineSecretInputNames.forEach((name) => {
if (transformedValues[name])
transformedValues[name] = '[__HIDDEN__]'
})
return transformedValues
}

View File

@@ -1,17 +1,9 @@
import React, { useMemo, useState } from 'react' import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator'
import ToolItem from '@/app/components/tools/provider/tool-item' import ToolItem from '@/app/components/tools/provider/tool-item'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import { import {
useAllToolProviders, useAllToolProviders,
useBuiltinTools, useBuiltinTools,
useInvalidateAllToolProviders,
useRemoveProviderCredentials,
useUpdateProviderCredentials,
} from '@/service/use-tools' } from '@/service/use-tools'
import type { PluginDetail } from '@/app/components/plugins/types' import type { PluginDetail } from '@/app/components/plugins/types'
@@ -23,35 +15,14 @@ const ActionList = ({
detail, detail,
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { isCurrentWorkspaceManager } = useAppContext()
const providerBriefInfo = detail.declaration.tool.identity const providerBriefInfo = detail.declaration.tool.identity
const providerKey = `${detail.plugin_id}/${providerBriefInfo.name}` const providerKey = `${detail.plugin_id}/${providerBriefInfo.name}`
const { data: collectionList = [] } = useAllToolProviders() const { data: collectionList = [] } = useAllToolProviders()
const invalidateAllToolProviders = useInvalidateAllToolProviders()
const provider = useMemo(() => { const provider = useMemo(() => {
return collectionList.find(collection => collection.name === providerKey) return collectionList.find(collection => collection.name === providerKey)
}, [collectionList, providerKey]) }, [collectionList, providerKey])
const { data } = useBuiltinTools(providerKey) const { data } = useBuiltinTools(providerKey)
const [showSettingAuth, setShowSettingAuth] = useState(false)
const handleCredentialSettingUpdate = () => {
invalidateAllToolProviders()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setShowSettingAuth(false)
}
const { mutate: updatePermission, isPending } = useUpdateProviderCredentials({
onSuccess: handleCredentialSettingUpdate,
})
const { mutate: removePermission } = useRemoveProviderCredentials({
onSuccess: handleCredentialSettingUpdate,
})
if (!data || !provider) if (!data || !provider)
return null return null
@@ -60,26 +31,7 @@ const ActionList = ({
<div className='mb-1 py-1'> <div className='mb-1 py-1'>
<div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'> <div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'>
{t('plugin.detailPanel.actionNum', { num: data.length, action: data.length > 1 ? 'actions' : 'action' })} {t('plugin.detailPanel.actionNum', { num: data.length, action: data.length > 1 ? 'actions' : 'action' })}
{provider.is_team_authorization && provider.allow_delete && (
<Button
variant='secondary'
size='small'
onClick={() => setShowSettingAuth(true)}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
</div> </div>
{!provider.is_team_authorization && provider.allow_delete && (
<Button
variant='primary'
className='w-full'
onClick={() => setShowSettingAuth(true)}
disabled={!isCurrentWorkspaceManager}
>{t('workflow.nodes.tool.authorize')}</Button>
)}
</div> </div>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
{data.map(tool => ( {data.map(tool => (
@@ -93,18 +45,6 @@ const ActionList = ({
/> />
))} ))}
</div> </div>
{showSettingAuth && (
<ConfigCredential
collection={provider}
onCancel={() => setShowSettingAuth(false)}
onSaved={async value => updatePermission({
providerName: provider.name,
credentials: value,
})}
onRemove={async () => removePermission(provider.name)}
isSaving={isPending}
/>
)}
</div> </div>
) )
} }

View File

@@ -36,6 +36,9 @@ import { useInvalidateAllToolProviders } from '@/service/use-tools'
import { API_PREFIX } from '@/config' import { API_PREFIX } from '@/config'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
import { PluginAuth } from '@/app/components/plugins/plugin-auth'
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { useAllToolProviders } from '@/service/use-tools'
const i18nPrefix = 'plugin.action' const i18nPrefix = 'plugin.action'
@@ -68,7 +71,14 @@ const DetailHeader = ({
meta, meta,
plugin_id, plugin_id,
} = detail } = detail
const { author, category, name, label, description, icon, verified } = detail.declaration const { author, category, name, label, description, icon, verified, tool } = detail.declaration
const isTool = category === PluginType.tool
const providerBriefInfo = tool?.identity
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
const { data: collectionList = [] } = useAllToolProviders(isTool)
const provider = useMemo(() => {
return collectionList.find(collection => collection.name === providerKey)
}, [collectionList, providerKey])
const isFromGitHub = source === PluginSource.github const isFromGitHub = source === PluginSource.github
const isFromMarketplace = source === PluginSource.marketplace const isFromMarketplace = source === PluginSource.marketplace
@@ -262,7 +272,17 @@ const DetailHeader = ({
</ActionButton> </ActionButton>
</div> </div>
</div> </div>
<Description className='mt-3' text={description[locale]} descriptionLineRows={2}></Description> <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description>
{
category === PluginType.tool && (
<PluginAuth
pluginPayload={{
provider: provider?.name || '',
category: AuthCategory.tool,
}}
/>
)
}
{isShowPluginInfo && ( {isShowPluginInfo && (
<PluginInfo <PluginInfo
repository={isFromGitHub ? meta?.repo : ''} repository={isFromGitHub ? meta?.repo : ''}

View File

@@ -3,9 +3,6 @@ import type { FC } from 'react'
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Link from 'next/link' import Link from 'next/link'
import {
RiArrowLeftLine,
} from '@remixicon/react'
import { import {
PortalToFollowElem, PortalToFollowElem,
PortalToFollowElemContent, PortalToFollowElemContent,
@@ -15,24 +12,17 @@ import ToolTrigger from '@/app/components/plugins/plugin-detail-panel/tool-selec
import ToolItem from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-item' import ToolItem from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-item'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import ToolForm from '@/app/components/workflow/nodes/tool/components/tool-form' import ToolForm from '@/app/components/workflow/nodes/tool/components/tool-form'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import ToolCredentialForm from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-credentials-form'
import Toast from '@/app/components/base/toast'
import Textarea from '@/app/components/base/textarea' import Textarea from '@/app/components/base/textarea'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import TabSlider from '@/app/components/base/tab-slider-plain' import TabSlider from '@/app/components/base/tab-slider-plain'
import ReasoningConfigForm from '@/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form' import ReasoningConfigForm from '@/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form'
import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { useAppContext } from '@/context/app-context'
import { import {
useAllBuiltInTools, useAllBuiltInTools,
useAllCustomTools, useAllCustomTools,
useAllMCPTools, useAllMCPTools,
useAllWorkflowTools, useAllWorkflowTools,
useInvalidateAllBuiltInTools, useInvalidateAllBuiltInTools,
useUpdateProviderCredentials,
} from '@/service/use-tools' } from '@/service/use-tools'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { usePluginInstalledCheck } from '@/app/components/plugins/plugin-detail-panel/tool-selector/hooks' import { usePluginInstalledCheck } from '@/app/components/plugins/plugin-detail-panel/tool-selector/hooks'
@@ -46,6 +36,10 @@ import { MARKETPLACE_API_PREFIX } from '@/config'
import type { Node } from 'reactflow' import type { Node } from 'reactflow'
import type { NodeOutPutVar } from '@/app/components/workflow/types' import type { NodeOutPutVar } from '@/app/components/workflow/types'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import {
AuthCategory,
PluginAuthInAgent,
} from '@/app/components/plugins/plugin-auth'
type Props = { type Props = {
disabled?: boolean disabled?: boolean
@@ -196,23 +190,6 @@ const ToolSelector: FC<Props> = ({
} as any) } as any)
} }
// authorization
const { isCurrentWorkspaceManager } = useAppContext()
const [isShowSettingAuth, setShowSettingAuth] = useState(false)
const handleCredentialSettingUpdate = () => {
invalidateAllBuiltinTools()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setShowSettingAuth(false)
onShowChange(false)
}
const { mutate: updatePermission } = useUpdateProviderCredentials({
onSuccess: handleCredentialSettingUpdate,
})
// install from marketplace // install from marketplace
const currentTool = useMemo(() => { const currentTool = useMemo(() => {
return currentProvider?.tools.find(tool => tool.name === value?.tool_name) return currentProvider?.tools.find(tool => tool.name === value?.tool_name)
@@ -226,6 +203,12 @@ const ToolSelector: FC<Props> = ({
invalidateAllBuiltinTools() invalidateAllBuiltinTools()
invalidateInstalledPluginList() invalidateInstalledPluginList()
} }
const handleAuthorizationItemClick = (id: string) => {
onSelect({
...value,
credential_id: id,
} as any)
}
return ( return (
<> <>
@@ -264,7 +247,6 @@ const ToolSelector: FC<Props> = ({
onSwitchChange={handleEnabledChange} onSwitchChange={handleEnabledChange}
onDelete={onDelete} onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization} noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
onAuth={() => setShowSettingAuth(true)}
uninstalled={!currentProvider && inMarketPlace} uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool} versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier} installInfo={manifest?.latest_package_identifier}
@@ -284,171 +266,131 @@ const ToolSelector: FC<Props> = ({
)} )}
</PortalToFollowElemTrigger> </PortalToFollowElemTrigger>
<PortalToFollowElemContent> <PortalToFollowElemContent>
<div className={cn('relative max-h-[642px] min-h-20 w-[361px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', !isShowSettingAuth && 'overflow-y-auto pb-2')}> <div className={cn('relative max-h-[642px] min-h-20 w-[361px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', 'overflow-y-auto pb-2')}>
{!isShowSettingAuth && ( <>
<> <div className='system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary'>{t(`plugin.detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`)}</div>
<div className='system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary'>{t(`plugin.detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`)}</div> {/* base form */}
{/* base form */} <div className='flex flex-col gap-3 px-4 py-2'>
<div className='flex flex-col gap-3 px-4 py-2'> <div className='flex flex-col gap-1'>
<div className='flex flex-col gap-1'> <div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('plugin.detailPanel.toolSelector.toolLabel')}</div>
<div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('plugin.detailPanel.toolSelector.toolLabel')}</div> <ToolPicker
<ToolPicker placement='bottom'
placement='bottom' offset={offset}
offset={offset} trigger={
trigger={ <ToolTrigger
<ToolTrigger open={panelShowState || isShowChooseTool}
open={panelShowState || isShowChooseTool} value={value}
value={value} provider={currentProvider}
provider={currentProvider}
/>
}
isShow={panelShowState || isShowChooseTool}
onShowChange={trigger ? onPanelShowStateChange as any : setIsShowChooseTool}
disabled={false}
supportAddCustomTool
onSelect={handleSelectTool}
onSelectMultiple={handleSelectMultipleTool}
scope={scope}
selectedTools={selectedTools}
canChooseMCPTool={canChooseMCPTool}
/>
</div>
<div className='flex flex-col gap-1'>
<div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('plugin.detailPanel.toolSelector.descriptionLabel')}</div>
<Textarea
className='resize-none'
placeholder={t('plugin.detailPanel.toolSelector.descriptionPlaceholder')}
value={value?.extra?.description || ''}
onChange={handleDescriptionChange}
disabled={!value?.provider_name}
/>
</div>
</div>
{/* authorization */}
{currentProvider && currentProvider.type === CollectionType.builtIn && currentProvider.allow_delete && (
<>
<Divider className='my-1 w-full' />
<div className='px-4 py-2'>
{!currentProvider.is_team_authorization && (
<Button
variant='primary'
className={cn('w-full shrink-0')}
onClick={() => setShowSettingAuth(true)}
disabled={!isCurrentWorkspaceManager}
>
{t('tools.auth.unauthorized')}
</Button>
)}
{currentProvider.is_team_authorization && (
<Button
variant='secondary'
className={cn('w-full shrink-0')}
onClick={() => setShowSettingAuth(true)}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
</div>
</>
)}
{/* tool settings */}
{(currentToolSettings.length > 0 || currentToolParams.length > 0) && currentProvider?.is_team_authorization && (
<>
<Divider className='my-1 w-full' />
{/* tabs */}
{nodeId && showTabSlider && (
<TabSlider
className='mt-1 shrink-0 px-4'
itemClassName='py-3'
noBorderBottom
smallItem
value={currType}
onChange={(value) => {
setCurrType(value)
}}
options={[
{ value: 'settings', text: t('plugin.detailPanel.toolSelector.settings')! },
{ value: 'params', text: t('plugin.detailPanel.toolSelector.params')! },
]}
/> />
)} }
{nodeId && showTabSlider && currType === 'params' && ( isShow={panelShowState || isShowChooseTool}
<div className='px-4 py-2'> onShowChange={trigger ? onPanelShowStateChange as any : setIsShowChooseTool}
disabled={false}
supportAddCustomTool
onSelect={handleSelectTool}
onSelectMultiple={handleSelectMultipleTool}
scope={scope}
selectedTools={selectedTools}
canChooseMCPTool={canChooseMCPTool}
/>
</div>
<div className='flex flex-col gap-1'>
<div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('plugin.detailPanel.toolSelector.descriptionLabel')}</div>
<Textarea
className='resize-none'
placeholder={t('plugin.detailPanel.toolSelector.descriptionPlaceholder')}
value={value?.extra?.description || ''}
onChange={handleDescriptionChange}
disabled={!value?.provider_name}
/>
</div>
</div>
{/* authorization */}
{currentProvider && currentProvider.type === CollectionType.builtIn && currentProvider.allow_delete && (
<>
<Divider className='my-1 w-full' />
<div className='px-4 py-2'>
<PluginAuthInAgent
pluginPayload={{
provider: currentProvider.name,
category: AuthCategory.tool,
}}
credentialId={value?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
/>
</div>
</>
)}
{/* tool settings */}
{(currentToolSettings.length > 0 || currentToolParams.length > 0) && currentProvider?.is_team_authorization && (
<>
<Divider className='my-1 w-full' />
{/* tabs */}
{nodeId && showTabSlider && (
<TabSlider
className='mt-1 shrink-0 px-4'
itemClassName='py-3'
noBorderBottom
smallItem
value={currType}
onChange={(value) => {
setCurrType(value)
}}
options={[
{ value: 'settings', text: t('plugin.detailPanel.toolSelector.settings')! },
{ value: 'params', text: t('plugin.detailPanel.toolSelector.params')! },
]}
/>
)}
{nodeId && showTabSlider && currType === 'params' && (
<div className='px-4 py-2'>
<div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip1')}</div>
<div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip2')}</div>
</div>
)}
{/* user settings only */}
{userSettingsOnly && (
<div className='p-4 pb-1'>
<div className='system-sm-semibold-uppercase text-text-primary'>{t('plugin.detailPanel.toolSelector.settings')}</div>
</div>
)}
{/* reasoning config only */}
{nodeId && reasoningConfigOnly && (
<div className='mb-1 p-4 pb-1'>
<div className='system-sm-semibold-uppercase text-text-primary'>{t('plugin.detailPanel.toolSelector.params')}</div>
<div className='pb-1'>
<div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip1')}</div> <div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip1')}</div>
<div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip2')}</div> <div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip2')}</div>
</div> </div>
)} </div>
{/* user settings only */} )}
{userSettingsOnly && ( {/* user settings form */}
<div className='p-4 pb-1'> {(currType === 'settings' || userSettingsOnly) && (
<div className='system-sm-semibold-uppercase text-text-primary'>{t('plugin.detailPanel.toolSelector.settings')}</div> <div className='px-4 py-2'>
</div> <ToolForm
)} inPanel
{/* reasoning config only */} readOnly={false}
{nodeId && reasoningConfigOnly && (
<div className='mb-1 p-4 pb-1'>
<div className='system-sm-semibold-uppercase text-text-primary'>{t('plugin.detailPanel.toolSelector.params')}</div>
<div className='pb-1'>
<div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip1')}</div>
<div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip2')}</div>
</div>
</div>
)}
{/* user settings form */}
{(currType === 'settings' || userSettingsOnly) && (
<div className='px-4 py-2'>
<ToolForm
inPanel
readOnly={false}
nodeId={nodeId}
schema={settingsFormSchemas as any}
value={getPlainValue(value?.settings || {})}
onChange={handleSettingsFormChange}
/>
</div>
)}
{/* reasoning config form */}
{nodeId && (currType === 'params' || reasoningConfigOnly) && (
<ReasoningConfigForm
value={value?.parameters || {}}
onChange={handleParamsFormChange}
schemas={paramsFormSchemas as any}
nodeOutputVars={nodeOutputVars}
availableNodes={availableNodes}
nodeId={nodeId} nodeId={nodeId}
schema={settingsFormSchemas as any}
value={getPlainValue(value?.settings || {})}
onChange={handleSettingsFormChange}
/> />
)} </div>
</> )}
)} {/* reasoning config form */}
</> {nodeId && (currType === 'params' || reasoningConfigOnly) && (
)} <ReasoningConfigForm
{/* authorization panel */} value={value?.parameters || {}}
{isShowSettingAuth && currentProvider && ( onChange={handleParamsFormChange}
<> schemas={paramsFormSchemas as any}
<div className='relative flex flex-col gap-1 pt-3.5'> nodeOutputVars={nodeOutputVars}
<div className='absolute -top-2 left-2 w-[345px] rounded-t-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pt-2 backdrop-blur-sm'></div> availableNodes={availableNodes}
<div nodeId={nodeId}
className='system-xs-semibold-uppercase flex h-6 cursor-pointer items-center gap-1 px-3 text-text-accent-secondary' />
onClick={() => setShowSettingAuth(false)} )}
> </>
<RiArrowLeftLine className='h-4 w-4' /> )}
BACK </>
</div>
<div className='system-xl-semibold px-4 text-text-primary'>{t('tools.auth.setupModalTitle')}</div>
<div className='system-xs-regular px-4 text-text-tertiary'>{t('tools.auth.setupModalTitleDescription')}</div>
</div>
<ToolCredentialForm
collection={currentProvider}
onCancel={() => setShowSettingAuth(false)}
onSaved={async value => updatePermission({
providerName: currentProvider.name,
credentials: value,
})}
/>
</>
)}
</div> </div>
</PortalToFollowElemContent> </PortalToFollowElemContent>
</PortalToFollowElem> </PortalToFollowElem>

View File

@@ -30,7 +30,6 @@ type Props = {
onSwitchChange?: (value: boolean) => void onSwitchChange?: (value: boolean) => void
onDelete?: () => void onDelete?: () => void
noAuth?: boolean noAuth?: boolean
onAuth?: () => void
isError?: boolean isError?: boolean
errorTip?: any errorTip?: any
uninstalled?: boolean uninstalled?: boolean
@@ -38,6 +37,7 @@ type Props = {
onInstall?: () => void onInstall?: () => void
versionMismatch?: boolean versionMismatch?: boolean
open: boolean open: boolean
authRemoved?: boolean
canChooseMCPTool?: boolean, canChooseMCPTool?: boolean,
} }
@@ -53,13 +53,13 @@ const ToolItem = ({
onSwitchChange, onSwitchChange,
onDelete, onDelete,
noAuth, noAuth,
onAuth,
uninstalled, uninstalled,
installInfo, installInfo,
onInstall, onInstall,
isError, isError,
errorTip, errorTip,
versionMismatch, versionMismatch,
authRemoved,
canChooseMCPTool, canChooseMCPTool,
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -125,11 +125,17 @@ const ToolItem = ({
<McpToolNotSupportTooltip /> <McpToolNotSupportTooltip />
)} )}
{!isError && !uninstalled && !versionMismatch && noAuth && ( {!isError && !uninstalled && !versionMismatch && noAuth && (
<Button variant='secondary' size='small' onClick={onAuth}> <Button variant='secondary' size='small'>
{t('tools.notAuthorized')} {t('tools.notAuthorized')}
<Indicator className='ml-2' color='orange' /> <Indicator className='ml-2' color='orange' />
</Button> </Button>
)} )}
{!isError && !uninstalled && !versionMismatch && authRemoved && (
<Button variant='secondary' size='small'>
{t('plugin.auth.authRemoved')}
<Indicator className='ml-2' color='red' />
</Button>
)}
{!isError && !uninstalled && versionMismatch && installInfo && ( {!isError && !uninstalled && versionMismatch && installInfo && (
<div onClick={e => e.stopPropagation()}> <div onClick={e => e.stopPropagation()}>
<SwitchPluginVersion <SwitchPluginVersion

View File

@@ -33,6 +33,7 @@ export type ToolDefaultValue = {
params: Record<string, any> params: Record<string, any>
paramSchemas: Record<string, any>[] paramSchemas: Record<string, any>[]
output_schema: Record<string, any> output_schema: Record<string, any>
credential_id?: string
meta?: PluginMeta meta?: PluginMeta
} }
@@ -46,4 +47,5 @@ export type ToolValue = {
parameters?: Record<string, any> parameters?: Record<string, any>
enabled?: boolean enabled?: boolean
extra?: Record<string, any> extra?: Record<string, any>
credential_id?: string
} }

View File

@@ -59,6 +59,12 @@ import { useLogs } from '@/app/components/workflow/run/hooks'
import PanelWrap from '../before-run-form/panel-wrap' import PanelWrap from '../before-run-form/panel-wrap'
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel' import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import {
AuthorizedInNode,
PluginAuth,
} from '@/app/components/plugins/plugin-auth'
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { canFindTool } from '@/utils'
type BasePanelProps = { type BasePanelProps = {
children: ReactNode children: ReactNode
@@ -221,6 +227,22 @@ const BasePanel: FC<BasePanelProps> = ({
return {} return {}
})() })()
const buildInTools = useStore(s => s.buildInTools)
const currCollection = useMemo(() => {
return buildInTools.find(item => canFindTool(item.id, data.provider_id))
}, [buildInTools, data.provider_id])
const showPluginAuth = useMemo(() => {
return data.type === BlockEnum.Tool && currCollection?.allow_delete
}, [currCollection, data.type])
const handleAuthorizationItemClick = useCallback((credential_id: string) => {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
credential_id,
},
})
}, [handleNodeDataUpdateWithSyncDraft, id])
if(logParams.showSpecialResultPanel) { if(logParams.showSpecialResultPanel) {
return ( return (
<div className={cn( <div className={cn(
@@ -353,12 +375,42 @@ const BasePanel: FC<BasePanelProps> = ({
onChange={handleDescriptionChange} onChange={handleDescriptionChange}
/> />
</div> </div>
<div className='pl-4'> {
<Tab showPluginAuth && (
value={tabType} <PluginAuth
onChange={setTabType} className='px-4 pb-2'
/> pluginPayload={{
</div> provider: currCollection?.name || '',
category: AuthCategory.tool,
}}
>
<div className='flex items-center justify-between pl-4 pr-3'>
<Tab
value={tabType}
onChange={setTabType}
/>
<AuthorizedInNode
pluginPayload={{
provider: currCollection?.name || '',
category: AuthCategory.tool,
}}
onAuthorizationItemClick={handleAuthorizationItemClick}
credentialId={data.credential_id}
/>
</div>
</PluginAuth>
)
}
{
!showPluginAuth && (
<div className='flex items-center justify-between pl-4 pr-3'>
<Tab
value={tabType}
onChange={setTabType}
/>
</div>
)
}
<Split /> <Split />
</div> </div>

View File

@@ -61,7 +61,7 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => {
> >
<div <div
className={classNames( className={classNames(
'size-5 border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge relative flex items-center justify-center rounded-[6px]', 'relative flex size-5 items-center justify-center rounded-[6px] border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge',
)} )}
ref={containerRef} ref={containerRef}
> >
@@ -73,7 +73,7 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => {
src={icon} src={icon}
alt='tool icon' alt='tool icon'
className={classNames( className={classNames(
'w-full h-full size-3.5 object-cover', 'size-3.5 h-full w-full object-cover',
notSuccess && 'opacity-50', notSuccess && 'opacity-50',
)} )}
onError={() => setIconFetchError(true)} onError={() => setIconFetchError(true)}
@@ -82,7 +82,7 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => {
if (typeof icon === 'object') { if (typeof icon === 'object') {
return <AppIcon return <AppIcon
className={classNames( className={classNames(
'w-full h-full size-3.5 object-cover', 'size-3.5 h-full w-full object-cover',
notSuccess && 'opacity-50', notSuccess && 'opacity-50',
)} )}
icon={icon?.content} icon={icon?.content}

View File

@@ -5,10 +5,8 @@ import Split from '../_base/components/split'
import type { ToolNodeType } from './types' import type { ToolNodeType } from './types'
import useConfig from './use-config' import useConfig from './use-config'
import ToolForm from './components/tool-form' import ToolForm from './components/tool-form'
import Button from '@/app/components/base/button'
import Field from '@/app/components/workflow/nodes/_base/components/field' import Field from '@/app/components/workflow/nodes/_base/components/field'
import type { NodePanelProps } from '@/app/components/workflow/types' import type { NodePanelProps } from '@/app/components/workflow/types'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show' import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
@@ -32,10 +30,6 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
setToolSettingValue, setToolSettingValue,
currCollection, currCollection,
isShowAuthBtn, isShowAuthBtn,
showSetAuth,
showSetAuthModal,
hideSetAuthModal,
handleSaveAuth,
isLoading, isLoading,
outputSchema, outputSchema,
hasObjectOutput, hasObjectOutput,
@@ -52,19 +46,6 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
return ( return (
<div className='pt-2'> <div className='pt-2'>
{!readOnly && isShowAuthBtn && (
<>
<div className='px-4'>
<Button
variant='primary'
className='w-full'
onClick={showSetAuthModal}
>
{t(`${i18nPrefix}.authorize`)}
</Button>
</div>
</>
)}
{!isShowAuthBtn && ( {!isShowAuthBtn && (
<div className='relative'> <div className='relative'>
{toolInputVarSchema.length > 0 && ( {toolInputVarSchema.length > 0 && (
@@ -109,15 +90,6 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
</div> </div>
)} )}
{showSetAuth && (
<ConfigCredential
collection={currCollection!}
onCancel={hideSetAuthModal}
onSaved={handleSaveAuth}
isHideRemoveBtn
/>
)}
<div> <div>
<OutputVars> <OutputVars>
<> <>

View File

@@ -93,6 +93,7 @@ export type CommonNodeType<T = {}> = {
error_strategy?: ErrorHandleTypeEnum error_strategy?: ErrorHandleTypeEnum
retry_config?: WorkflowRetryConfig retry_config?: WorkflowRetryConfig
default_value?: DefaultValueForm[] default_value?: DefaultValueForm[]
credential_id?: string
} & T & Partial<Pick<ToolDefaultValue, 'provider_id' | 'provider_type' | 'provider_name' | 'tool_name'>> } & T & Partial<Pick<ToolDefaultValue, 'provider_id' | 'provider_type' | 'provider_name' | 'tool_name'>>
export type CommonEdgeType = { export type CommonEdgeType = {

View File

@@ -214,6 +214,29 @@ const translation = {
requestAPlugin: 'Request a plugin', requestAPlugin: 'Request a plugin',
publishPlugins: 'Publish plugins', publishPlugins: 'Publish plugins',
difyVersionNotCompatible: 'The current Dify version is not compatible with this plugin, please upgrade to the minimum version required: {{minimalDifyVersion}}', difyVersionNotCompatible: 'The current Dify version is not compatible with this plugin, please upgrade to the minimum version required: {{minimalDifyVersion}}',
auth: {
default: 'Default',
custom: 'Custom',
setDefault: 'Set as default',
useOAuth: 'Use OAuth',
useOAuthAuth: 'Use OAuth Authorization',
addOAuth: 'Add OAuth',
setupOAuth: 'Setup OAuth Client',
useApi: 'Use API Key',
addApi: 'Add API Key',
useApiAuth: 'API Key Authorization Configuration',
useApiAuthDesc: 'After configuring credentials, all members within the workspace can use this tool when orchestrating applications.',
oauthClientSettings: 'OAuth Client Settings',
saveOnly: 'Save only',
saveAndAuth: 'Save and Authorize',
authorization: 'Authorization',
authorizations: 'Authorizations',
authorizationName: 'Authorization Name',
workspaceDefault: 'Workspace Default',
authRemoved: 'Auth removed',
clientInfo: 'As no system client secrets found for this tool provider, setup it manually is required, for redirect_uri, please use',
oauthClient: 'OAuth Client',
},
} }
export default translation export default translation

View File

@@ -214,6 +214,29 @@ const translation = {
requestAPlugin: '申请插件', requestAPlugin: '申请插件',
publishPlugins: '发布插件', publishPlugins: '发布插件',
difyVersionNotCompatible: '当前 Dify 版本不兼容该插件,其最低版本要求为 {{minimalDifyVersion}}', difyVersionNotCompatible: '当前 Dify 版本不兼容该插件,其最低版本要求为 {{minimalDifyVersion}}',
auth: {
default: '默认',
custom: '自定义',
setDefault: '设为默认',
useOAuth: '使用 OAuth',
useOAuthAuth: '使用 OAuth 授权',
addOAuth: '添加 OAuth',
setupOAuth: '设置 OAuth 客户端',
useApi: '使用 API Key',
addApi: '添加 API Key',
useApiAuth: 'API Key 授权配置',
useApiAuthDesc: '配置凭据后,工作区内的所有成员在编排应用时都可以使用此工具。',
oauthClientSettings: 'OAuth 客户端设置',
saveOnly: '仅保存',
saveAndAuth: '保存并授权',
authorization: '凭据',
authorizations: '凭据',
authorizationName: '凭据名称',
workspaceDefault: '工作区默认',
authRemoved: '凭据已移除',
clientInfo: '由于未找到此工具提供者的系统客户端密钥,因此需要手动设置,对于 redirect_uri请使用',
oauthClient: 'OAuth 客户端',
},
} }
export default translation export default translation

View File

@@ -0,0 +1,161 @@
import {
useMutation,
useQuery,
} from '@tanstack/react-query'
import { del, get, post } from './base'
import { useInvalid } from './use-base'
import type {
Credential,
CredentialTypeEnum,
} from '@/app/components/plugins/plugin-auth/types'
import type { FormSchema } from '@/app/components/base/form/types'
const NAME_SPACE = 'plugins-auth'
export const useGetPluginCredentialInfo = (
url: string,
) => {
return useQuery({
enabled: !!url,
queryKey: [NAME_SPACE, 'credential-info', url],
queryFn: () => get<{
supported_credential_types: string[]
credentials: Credential[]
is_oauth_custom_client_enabled: boolean
}>(url),
staleTime: 0,
})
}
export const useInvalidPluginCredentialInfo = (
url: string,
) => {
return useInvalid([NAME_SPACE, 'credential-info', url])
}
export const useSetPluginDefaultCredential = (
url: string,
) => {
return useMutation({
mutationFn: (id: string) => {
return post(url, { body: { id } })
},
})
}
export const useGetPluginCredentialList = (
url: string,
) => {
return useQuery({
queryKey: [NAME_SPACE, 'credential-list', url],
queryFn: () => get(url),
})
}
export const useAddPluginCredential = (
url: string,
) => {
return useMutation({
mutationFn: (params: {
credentials: Record<string, any>
type: CredentialTypeEnum
name?: string
}) => {
return post(url, { body: params })
},
})
}
export const useUpdatePluginCredential = (
url: string,
) => {
return useMutation({
mutationFn: (params: {
credential_id: string
credentials?: Record<string, any>
name?: string
}) => {
return post(url, { body: params })
},
})
}
export const useDeletePluginCredential = (
url: string,
) => {
return useMutation({
mutationFn: (params: { credential_id: string }) => {
return post(url, { body: params })
},
})
}
export const useGetPluginCredentialSchema = (
url: string,
) => {
return useQuery({
queryKey: [NAME_SPACE, 'credential-schema', url],
queryFn: () => get<FormSchema[]>(url),
})
}
export const useGetPluginOAuthUrl = (
url: string,
) => {
return useMutation({
mutationKey: [NAME_SPACE, 'oauth-url', url],
mutationFn: () => {
return get<
{
authorization_url: string
state: string
context_id: string
}>(url)
},
})
}
export const useGetPluginOAuthClientSchema = (
url: string,
) => {
return useQuery({
queryKey: [NAME_SPACE, 'oauth-client-schema', url],
queryFn: () => get<{
schema: FormSchema[]
is_oauth_custom_client_enabled: boolean
is_system_oauth_params_exists?: boolean
client_params?: Record<string, any>
redirect_uri?: string
}>(url),
staleTime: 0,
})
}
export const useInvalidPluginOAuthClientSchema = (
url: string,
) => {
return useInvalid([NAME_SPACE, 'oauth-client-schema', url])
}
export const useSetPluginOAuthCustomClient = (
url: string,
) => {
return useMutation({
mutationFn: (params: {
client_params: Record<string, any>
enable_oauth_custom_client: boolean
}) => {
return post<{ result: string }>(url, { body: params })
},
})
}
export const useDeletePluginOAuthCustomClient = (
url: string,
) => {
return useMutation({
mutationFn: () => {
return del<{ result: string }>(url)
},
})
}

View File

@@ -16,10 +16,11 @@ import {
const NAME_SPACE = 'tools' const NAME_SPACE = 'tools'
const useAllToolProvidersKey = [NAME_SPACE, 'allToolProviders'] const useAllToolProvidersKey = [NAME_SPACE, 'allToolProviders']
export const useAllToolProviders = () => { export const useAllToolProviders = (enabled = true) => {
return useQuery<Collection[]>({ return useQuery<Collection[]>({
queryKey: useAllToolProvidersKey, queryKey: useAllToolProvidersKey,
queryFn: () => get<Collection[]>('/workspaces/current/tool-providers'), queryFn: () => get<Collection[]>('/workspaces/current/tool-providers'),
enabled,
}) })
} }

View File

@@ -130,6 +130,7 @@ export type AgentTool = {
enabled: boolean enabled: boolean
isDeleted?: boolean isDeleted?: boolean
notAuthor?: boolean notAuthor?: boolean
credential_id?: string
} }
export type ToolItem = { export type ToolItem = {