tool oauth

This commit is contained in:
zxhlyh
2025-07-09 18:28:39 +08:00
parent ce8bf7b5a2
commit 8968a3e254
19 changed files with 432 additions and 124 deletions

View File

@ -1,6 +1,7 @@
import {
memo,
useCallback,
useMemo,
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
@ -14,8 +15,10 @@ import {
useUpdatePluginToolCredential,
} from '@/service/use-plugins-auth'
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 { FormTypeEnum } from '@/app/components/base/form/types'
import { useToastContext } from '@/app/components/base/toast'
export type ApiKeyModalProps = {
@ -23,34 +26,65 @@ export type ApiKeyModalProps = {
onClose?: () => void
editValues?: Record<string, any>
onRemove?: () => void
disabled?: boolean
}
const ApiKeyModal = ({
provider,
onClose,
editValues,
onRemove,
disabled,
}: ApiKeyModalProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { data } = useGetPluginToolCredentialSchema(provider, CredentialTypeEnum.API_KEY)
const { data = [] } = useGetPluginToolCredentialSchema(provider, CredentialTypeEnum.API_KEY)
const formSchemas = useMemo(() => {
return [
{
type: FormTypeEnum.textInput,
name: '__name__',
label: 'Authorization name',
required: false,
},
...data,
]
}, [data])
const { mutateAsync: addPluginToolCredential } = useAddPluginToolCredential(provider)
const { mutateAsync: updatePluginToolCredential } = useUpdatePluginToolCredential(provider)
const invalidatePluginToolCredentialInfo = useInvalidPluginToolCredentialInfo(provider)
const formRef = useRef<FromRefObject>(null)
const handleConfirm = useCallback(async () => {
const store = formRef.current?.getFormStore()
const values = store?.state.values
const form = formRef.current?.getForm()
const store = form?.store
const {
__name__,
__credential_id__,
...values
} = store?.state.values
const isPristineSecretInputNames: string[] = []
formSchemas.forEach((schema) => {
if (schema.type === FormTypeEnum.secretInput) {
const fieldMeta = form?.getFieldMeta(schema.name)
if (fieldMeta?.isPristine)
isPristineSecretInputNames.push(schema.name)
}
})
const transformedValues = transformFormSchemasSecretInput(isPristineSecretInputNames, values)
if (editValues) {
await updatePluginToolCredential({
credentials: values,
credentials: transformedValues,
credential_id: __credential_id__,
type: CredentialTypeEnum.API_KEY,
name: __name__ || '',
})
}
else {
await addPluginToolCredential({
credentials: values,
credentials: transformedValues,
type: CredentialTypeEnum.API_KEY,
name: __name__ || '',
})
}
notify({
@ -60,7 +94,7 @@ const ApiKeyModal = ({
onClose?.()
invalidatePluginToolCredentialInfo()
}, [addPluginToolCredential, onClose, invalidatePluginToolCredentialInfo, updatePluginToolCredential, notify, t, editValues])
}, [addPluginToolCredential, onClose, invalidatePluginToolCredentialInfo, updatePluginToolCredential, notify, t, editValues, formSchemas])
return (
<Modal
@ -96,11 +130,13 @@ const ApiKeyModal = ({
onConfirm={handleConfirm}
showExtraButton={!!editValues}
onExtraButtonClick={onRemove}
disabled={disabled}
>
<AuthForm
ref={formRef}
formSchemas={data}
formSchemas={formSchemas}
defaultValues={editValues}
disabled={disabled}
/>
</Modal>
)

View File

@ -36,7 +36,7 @@ const Authorize = ({
}
return {
buttonText: !canApiKey ? 'Use OAuth Authorization' : '',
buttonText: !canApiKey ? 'Use OAuth Authorization' : 'Use OAuth',
}
}, [canApiKey, theme])
@ -50,7 +50,7 @@ const Authorize = ({
}
return {
provider,
buttonText: !canOAuth ? 'API Key Authorization Configuration' : '',
buttonText: !canOAuth ? 'API Key Authorization Configuration' : 'Use API Key',
buttonVariant: !canOAuth ? 'primary' : 'secondary-accent',
}
}, [canOAuth, theme, provider])

View File

@ -0,0 +1,94 @@
import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
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 } from './types'
import {
Authorized,
usePluginAuth,
} from '.'
type AuthorizedInNodeProps = {
provider: string
onAuthorizationItemClick: (id: string) => void
credentialId?: string
}
const AuthorizedInNode = ({
provider = '',
onAuthorizationItemClick,
credentialId,
}: AuthorizedInNodeProps) => {
const [isOpen, setIsOpen] = useState(false)
const {
canApiKey,
canOAuth,
credentials,
disabled,
} = usePluginAuth(provider, isOpen)
const label = useMemo(() => {
if (!credentialId)
return 'Workspace default'
const credential = credentials.find(c => c.id === credentialId)
if (!credential)
return 'Auth removed'
return credential.name
}, [credentials, credentialId])
const renderTrigger = useCallback((open?: boolean) => {
return (
<Button
size='small'
className={cn(open && 'bg-components-button-ghost-bg-hover')}
>
<Indicator className='mr-1.5' />
{label}
<RiArrowDownSLine className='h-3.5 w-3.5 text-components-button-ghost-text' />
</Button>
)
}, [label])
const extraAuthorizationItems: Credential[] = [
{
id: '__workspace_default__',
name: 'Workspace default',
provider: '',
is_default: false,
isWorkspaceDefault: true,
},
]
const handleAuthorizationItemClick = useCallback((id: string) => {
onAuthorizationItemClick(id)
setIsOpen(false)
}, [
onAuthorizationItemClick,
setIsOpen,
])
return (
<Authorized
provider={provider}
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}
/>
)
}
export default memo(AuthorizedInNode)

View File

@ -13,6 +13,9 @@ import {
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'
@ -35,6 +38,16 @@ type AuthorizedProps = {
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[]
}
const Authorized = ({
provider,
@ -42,10 +55,27 @@ const Authorized = ({
canOAuth,
canApiKey,
disabled,
renderTrigger,
isOpen,
onOpenChange,
offset = 8,
placement = 'bottom-start',
triggerPopupSameWidth = true,
popupClassName,
disableSetDefault,
onItemClick,
extraAuthorizationItems,
}: AuthorizedProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const [isOpen, setIsOpen] = useState(false)
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)
@ -98,28 +128,57 @@ const Authorized = ({
return (
<>
<PortalToFollowElem
open={isOpen}
onOpenChange={setIsOpen}
placement='bottom-start'
offset={8}
triggerPopupSameWidth
open={mergedIsOpen}
onOpenChange={setMergedIsOpen}
placement={placement}
offset={offset}
triggerPopupSameWidth={triggerPopupSameWidth}
>
<PortalToFollowElemTrigger
onClick={() => setIsOpen(!isOpen)}
onClick={() => setMergedIsOpen(!mergedIsOpen)}
asChild
>
<Button
className={cn(
'w-full',
isOpen && 'bg-components-button-secondary-bg-hover',
)}>
<Indicator className='mr-2' />
{credentials.length} Authorizations
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
</Button>
{
renderTrigger
? renderTrigger(mergedIsOpen)
: (
<Button
className={cn(
'w-full',
isOpen && 'bg-components-button-secondary-bg-hover',
)}>
<Indicator className='mr-2' />
{credentials.length} Authorizations
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
</Button>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[100]'>
<div className='max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
<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,
)}>
{
!!extraAuthorizationItems?.length && (
<div className='p-1'>
{
extraAuthorizationItems.map(credential => (
<Item
key={credential.id}
credential={credential}
disabled={disabled}
onItemClick={onItemClick}
disableRename
disableEdit
disableDelete
disableSetDefault
/>
))
}
</div>
)
}
<div className='py-1'>
{
!!oAuthCredentials.length && (
@ -153,6 +212,8 @@ const Authorized = ({
onDelete={openConfirm}
onEdit={handleEdit}
onSetDefault={handleSetDefault}
disableSetDefault={disableSetDefault}
onItemClick={onItemClick}
/>
))
}
@ -195,6 +256,7 @@ const Authorized = ({
pendingOperationCredentialId.current = null
}}
onRemove={handleRemove}
disabled={disabled}
/>
)
}

View File

@ -1,5 +1,6 @@
import {
memo,
useMemo,
} from 'react'
import {
RiDeleteBinLine,
@ -20,6 +21,11 @@ type ItemProps = {
onDelete?: (id: string) => void
onEdit?: (id: string, values: Record<string, any>) => void
onSetDefault?: (id: string) => void
disableRename?: boolean
disableEdit?: boolean
disableDelete?: boolean
disableSetDefault?: boolean
onItemClick?: (id: string) => void
}
const Item = ({
credential,
@ -27,16 +33,25 @@ const Item = ({
onDelete,
onEdit,
onSetDefault,
disableRename,
disableEdit,
disableDelete,
disableSetDefault,
onItemClick,
}: ItemProps) => {
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='group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover'
onClick={() => onItemClick?.(credential.id)}
>
<div className='flex grow items-center space-x-1.5 pl-2'>
<Indicator className='mr-1.5' />
<div className='flex w-0 grow items-center space-x-1.5 pl-2'>
<Indicator className='mr-1.5 shrink-0' />
<div
className='system-md-regular truncate text-text-secondary'
title={credential.name}
@ -51,44 +66,72 @@ const Item = ({
)
}
</div>
<div className='ml-2 hidden shrink-0 items-center group-hover:flex'>
<Button
size='small'
disabled={disabled}
onClick={() => onSetDefault?.(credential.id)}
>
Set as default
</Button>
{
isOAuth && (
<Tooltip popupContent='rename'>
<ActionButton>
<RiEditLine className='h-4 w-4 text-text-tertiary' />
</ActionButton>
</Tooltip>
)
}
{
!isOAuth && (
<Tooltip popupContent='edit'>
<ActionButton
disabled={disabled}
onClick={() => onEdit?.(credential.id, credential.credentials)}
>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</ActionButton>
</Tooltip>
)
}
<Tooltip popupContent='delete'>
<ActionButton
disabled={disabled}
onClick={() => onDelete?.(credential.id)}
>
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary' />
</ActionButton>
</Tooltip>
</div>
{
showAction && (
<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)
}}
>
Set as default
</Button>
)
}
{
isOAuth && !disableRename && (
<Tooltip popupContent='rename'>
<ActionButton>
<RiEditLine className='h-4 w-4 text-text-tertiary' />
</ActionButton>
</Tooltip>
)
}
{
!isOAuth && !disableEdit && (
<Tooltip popupContent='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='delete'>
<ActionButton
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onDelete?.(credential.id)
}}
>
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary' />
</ActionButton>
</Tooltip>
)
}
</div>
)
}
</div>
)
}

View File

@ -0,0 +1,20 @@
import { useAppContext } from '@/context/app-context'
import { useGetPluginToolCredentialInfo } from '@/service/use-plugins-auth'
import { CredentialTypeEnum } from './types'
export const usePluginAuth = (provider: string, enable?: boolean) => {
const { data } = useGetPluginToolCredentialInfo(enable ? provider : '')
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)
return {
isAuthorized,
canOAuth,
canApiKey,
credentials: data?.credentials || [],
provider,
disabled: !isCurrentWorkspaceManager,
}
}

View File

@ -1 +1,4 @@
export { default as PluginAuth } from './plugin-auth'
export { default as Authorized } from './authorized'
export { default as AuthorizedInNode } from './authorized-in-node'
export { usePluginAuth } from './hooks'

View File

@ -7,9 +7,11 @@ import { CredentialTypeEnum } from './types'
type PluginAuthProps = {
provider?: string
children?: React.ReactNode
}
const PluginAuth = ({
provider = '',
children,
}: PluginAuthProps) => {
const { data } = useGetPluginToolCredentialInfo(provider)
const { isCurrentWorkspaceManager } = useAppContext()
@ -30,7 +32,7 @@ const PluginAuth = ({
)
}
{
isAuthorized && (
isAuthorized && !children && (
<Authorized
provider={provider}
credentials={data?.credentials}
@ -40,6 +42,9 @@ const PluginAuth = ({
/>
)
}
{
isAuthorized && children
}
</>
)
}

View File

@ -7,7 +7,8 @@ export type Credential = {
id: string
name: string
provider: string
credential_type: CredentialTypeEnum
credential_type?: CredentialTypeEnum
is_default: boolean
credentials: Record<string, any>
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
}