tool oauth

This commit is contained in:
zxhlyh 2025-07-11 17:37:13 +08:00
parent 119d41099d
commit 83ab69d2eb
10 changed files with 250 additions and 102 deletions

View File

@ -36,6 +36,8 @@ const BaseField = ({
required,
placeholder,
options,
labelClassName: formLabelClassName,
show_on = [],
} = formSchema
const memorizedLabel = useMemo(() => {
@ -64,10 +66,25 @@ const BaseField = ({
}) || []
}, [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)}>
<div className={cn(labelClassName, formLabelClassName)}>
{memorizedLabel}
{
required && (
@ -132,6 +149,26 @@ const BaseField = ({
/>
)
}
{
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>
)

View File

@ -34,7 +34,7 @@ export enum FormTypeEnum {
export type FormOption = {
label: TypeWithI18N | string
value: string
show_on: FormShowOnObject[]
show_on?: FormShowOnObject[]
icon?: string
}
@ -51,11 +51,12 @@ export type FormSchema = {
help?: string | TypeWithI18N
placeholder?: string | TypeWithI18N
options?: FormOption[]
labelClassName?: string
}
export type FormValues = Record<string, any>
export type FromRefObject = {
export type FormRefObject = {
getForm: () => AnyFormApi
}
export type FormRef = ForwardedRef<FromRefObject>
export type FormRef = ForwardedRef<FormRefObject>

View File

@ -1,18 +1,31 @@
import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
import { RiEqualizer2Line } from '@remixicon/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,
useInvalidPluginCredentialInfoHook,
} 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
@ -34,62 +47,173 @@ const AddOAuthButton = ({
dividerClassName,
disabled,
}: AddOAuthButtonProps) => {
const { t } = useTranslation()
const renderI18nObject = useRenderI18nObject()
const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false)
const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload)
const { data } = 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 || !!client_params
const invalidatePluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
const handleOAuth = useCallback(async () => {
const { authorization_url } = await getPluginOAuthUrl()
if (authorization_url) {
openOAuthPopup(
authorization_url,
() => {
console.log('success')
},
invalidatePluginCredentialInfo,
)
}
}, [getPluginOAuthUrl])
}, [getPluginOAuthUrl, invalidatePluginCredentialInfo])
const renderCustomLabel = useCallback((item: FormSchema) => {
return (
<div>
<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='grow'>
<div className='system-sm-regular mb-2'>
{t('plugin.auth.clientInfo')}
</div>
{
redirect_uri && (
<div className='system-sm-medium flex items-center'>
{redirect_uri}
<ActionButton
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>)}
</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])
return (
<>
<Button
variant={buttonVariant}
className={cn(
'grow px-0 py-0 hover:bg-components-button-primary-bg',
className,
)}
disabled={disabled}
onClick={handleOAuth}
>
<div className={cn(
'flex h-full grow items-center justify-center rounded-l-lg hover:bg-components-button-primary-bg-hover',
buttonLeftClassName,
)}>
{buttonText}
</div>
<div className={cn(
'h-4 w-[1px] 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}
className={cn(
'grow px-0 py-0 hover:bg-components-button-primary-bg',
className,
)}
disabled={disabled}
onClick={handleOAuth}
>
<div className={cn(
'flex h-full grow items-center justify-center rounded-l-lg hover:bg-components-button-primary-bg-hover',
buttonLeftClassName,
)}>
{buttonText}
</div>
{
is_oauth_custom_client_enabled && (
<Badge>
{t('plugin.auth.custom')}
</Badge>
)
}
<div className={cn(
'h-4 w-[1px] 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}
>
<RiEqualizer2Line className='mr-0.5 h-4 w-4' />
{t('plugin.auth.setupOAuth')}
</Button>
)
}
{
isOAuthSettingsOpen && (
<OAuthClientSettings
pluginPayload={pluginPayload}
onClose={() => setIsOAuthSettingsOpen(false)}
disabled={disabled}
schemas={memorizedSchemas}
onAuth={handleOAuth}
/>
)
}

View File

@ -11,7 +11,7 @@ import Modal from '@/app/components/base/modal/modal'
import { CredentialTypeEnum } from '../types'
import { transformFormSchemasSecretInput } from '../utils'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import type { FromRefObject } from '@/app/components/base/form/types'
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'
@ -62,7 +62,7 @@ const ApiKeyModal = ({
const { mutateAsync: addPluginCredential } = useAddPluginCredentialHook(pluginPayload)
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
const invalidatePluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
const formRef = useRef<FromRefObject>(null)
const formRef = useRef<FormRefObject>(null)
const handleConfirm = useCallback(async () => {
const form = formRef.current?.getForm()
const store = form?.store

View File

@ -1,20 +1,20 @@
import {
memo,
useCallback,
useMemo,
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal/modal'
import {
useGetPluginOAuthClientSchemaHook,
useInvalidPluginCredentialInfoHook,
useSetPluginOAuthCustomClientHook,
} from '../hooks/use-credential'
import type { PluginPayload } from '../types'
import Loading from '@/app/components/base/loading'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import type { FromRefObject } from '@/app/components/base/form/types'
import type {
FormRefObject,
FormSchema,
} from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { transformFormSchemasSecretInput } from '../utils'
import { useToastContext } from '@/app/components/base/toast'
@ -24,36 +24,36 @@ type OAuthClientSettingsProps = {
onClose?: () => void
editValues?: Record<string, any>
disabled?: boolean
schemas: FormSchema[]
onAuth?: () => Promise<void>
}
const OAuthClientSettings = ({
pluginPayload,
onClose,
editValues,
disabled,
schemas,
onAuth,
}: OAuthClientSettingsProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const {
data,
isLoading,
} = useGetPluginOAuthClientSchemaHook(pluginPayload)
const formSchemas = useMemo(() => {
return data?.schema || []
}, [data])
const defaultValues = formSchemas.reduce((acc, schema) => {
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 invalidatePluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
const formRef = useRef<FromRefObject>(null)
const formRef = useRef<FormRefObject>(null)
const handleConfirm = useCallback(async () => {
const form = formRef.current?.getForm()
const store = form?.store
const values = store?.state.values
const {
__oauth_client__,
...values
} = store?.state.values
const isPristineSecretInputNames: string[] = []
formSchemas.forEach((schema) => {
schemas.forEach((schema) => {
if (schema.type === FormTypeEnum.secretInput) {
const fieldMeta = form?.getFieldMeta(schema.name)
if (fieldMeta?.isPristine)
@ -74,36 +74,32 @@ const OAuthClientSettings = ({
onClose?.()
invalidatePluginCredentialInfo()
}, [onClose, invalidatePluginCredentialInfo, setPluginOAuthCustomClient, notify, t, formSchemas])
}, [onClose, invalidatePluginCredentialInfo, setPluginOAuthCustomClient, notify, t, schemas])
const handleConfirmAndAuthorize = useCallback(async () => {
await handleConfirm()
if (onAuth)
await onAuth()
}, [handleConfirm, onAuth])
return (
<Modal
title='Oauth client settings'
confirmButtonText='Save & Authorize'
cancelButtonText='Save only'
extraButtonText='Cancel'
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}
onConfirm={handleConfirm}
onCancel={handleConfirm}
onConfirm={handleConfirmAndAuthorize}
>
{
isLoading && (
<div className='flex h-40 items-center justify-center'>
<Loading />
</div>
)
}
{
!isLoading && !!data?.schema.length && (
<AuthForm
ref={formRef}
formSchemas={formSchemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
)
}
<AuthForm
ref={formRef}
formSchemas={schemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
</Modal>
)
}

View File

@ -4,7 +4,6 @@ import {
useGetPluginCredentialInfo,
useGetPluginCredentialSchema,
useGetPluginOAuthClientSchema,
useGetPluginOAuthCustomClientSchema,
useGetPluginOAuthUrl,
useInvalidPluginCredentialInfo,
useSetPluginDefaultCredential,
@ -73,9 +72,3 @@ export const useSetPluginOAuthCustomClientHook = (pluginPayload: PluginPayload)
return useSetPluginOAuthCustomClient(apiMap.setCustomOauthClient)
}
export const useGetPluginOAuthCustomClientSchemaHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useGetPluginOAuthCustomClientSchema(apiMap.getCustomOAuthClient)
}

View File

@ -19,7 +19,7 @@ export const useGetApi = ({ category = AuthCategory.tool, provider }: PluginPayl
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`,
getCustomOAuthClient: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
getCustomOAuthClientValues: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
}
}
@ -34,6 +34,6 @@ export const useGetApi = ({ category = AuthCategory.tool, provider }: PluginPayl
getOauthUrl: '',
getOauthClientSchema: '',
setCustomOauthClient: '',
getCustomOAuthClient: '',
getCustomOAuthClientValues: '',
}
}

View File

@ -216,6 +216,7 @@ const translation = {
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',
@ -233,6 +234,8 @@ const translation = {
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',
},
}

View File

@ -216,6 +216,7 @@ const translation = {
difyVersionNotCompatible: '当前 Dify 版本不兼容该插件,其最低版本要求为 {{minimalDifyVersion}}',
auth: {
default: '默认',
custom: '自定义',
setDefault: '设为默认',
useOAuth: '使用 OAuth',
useOAuthAuth: '使用 OAuth 授权',
@ -233,6 +234,8 @@ const translation = {
authorizationName: '凭据名称',
workspaceDefault: '工作区默认',
authRemoved: '凭据已移除',
clientInfo: '由于未找到此工具提供者的系统客户端密钥,因此需要手动设置,对于 redirect_uri请使用',
oauthClient: 'OAuth 客户端',
},
}

View File

@ -123,6 +123,9 @@ export const useGetPluginOAuthClientSchema = (
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),
})
}
@ -139,15 +142,3 @@ export const useSetPluginOAuthCustomClient = (
},
})
}
export const useGetPluginOAuthCustomClientSchema = (
url: string,
) => {
return useQuery({
queryKey: [NAME_SPACE, 'oauth-custom-client-schema', url],
queryFn: () => get<{
client_id: string
client_secret: string
}>(url),
})
}