feat(end-user): implement end-user credential management in plugin-auth component

- Added support for end-user credentials with options for OAuth and API Key.
- Introduced new props in PluginAuth for managing end-user credential types and their states.
- Updated workflow types to include end-user credential fields.
- Enhanced UI to allow users to select and manage end-user credentials.
- Added translations for new UI elements related to end-user credentials.
This commit is contained in:
zhsama 2025-11-25 17:24:36 +08:00
parent 9df9db3a8f
commit b7d9483bc2
5 changed files with 362 additions and 3 deletions

View File

@ -1,20 +1,56 @@
import { memo } from 'react'
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import type { ReactNode } from 'react'
import {
RiAddLine,
RiArrowDownSLine,
RiCheckLine,
RiEqualizer2Line,
RiKey2Line,
RiUserStarLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Authorize from './authorize'
import Authorized from './authorized'
import AddApiKeyButton from './authorize/add-api-key-button'
import AddOAuthButton from './authorize/add-oauth-button'
import Item from './authorized/item'
import type { PluginPayload } from './types'
import { usePluginAuth } from './hooks/use-plugin-auth'
import Switch from '@/app/components/base/switch'
import cn from '@/utils/classnames'
type PluginAuthProps = {
pluginPayload: PluginPayload
children?: React.ReactNode
className?: string
showConnectGuide?: boolean
endUserCredentialEnabled?: boolean
endUserCredentialType?: string
onEndUserCredentialTypeChange?: (type: string) => void
onEndUserCredentialChange?: (enabled: boolean) => void
}
const PluginAuth = ({
pluginPayload,
children,
className,
showConnectGuide,
endUserCredentialEnabled,
endUserCredentialType,
onEndUserCredentialTypeChange,
onEndUserCredentialChange,
}: PluginAuthProps) => {
const { t } = useTranslation()
const {
isAuthorized,
canOAuth,
@ -24,11 +60,294 @@ const PluginAuth = ({
invalidPluginCredentialInfo,
notAllowCustomCredential,
} = usePluginAuth(pluginPayload, !!pluginPayload.provider)
const shouldShowGuide = !!showConnectGuide
const [showCredentialPanel, setShowCredentialPanel] = useState(false)
const [showAddMenu, setShowAddMenu] = useState(false)
const [showEndUserTypeMenu, setShowEndUserTypeMenu] = useState(false)
const configuredDisabled = !!endUserCredentialEnabled
const availableEndUserTypes = useMemo(() => {
const list: { value: string; label: string; icon: ReactNode }[] = []
if (canOAuth) {
list.push({
value: 'oauth2',
label: t('plugin.auth.endUserCredentials.optionOAuth'),
icon: <RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />,
})
}
if (canApiKey) {
list.push({
value: 'api-key',
label: t('plugin.auth.endUserCredentials.optionApiKey'),
icon: <RiKey2Line className='h-4 w-4 text-text-tertiary' />,
})
}
return list
}, [canOAuth, canApiKey, t])
const endUserCredentialLabel = useMemo(() => {
const found = availableEndUserTypes.find(item => item.value === endUserCredentialType)
return found?.label || availableEndUserTypes[0]?.label || '-'
}, [availableEndUserTypes, endUserCredentialType])
useEffect(() => {
if (!endUserCredentialEnabled) {
setShowEndUserTypeMenu(false)
return
}
if (!availableEndUserTypes.length)
return
const isValid = availableEndUserTypes.some(item => item.value === endUserCredentialType)
if (!isValid)
onEndUserCredentialTypeChange?.(availableEndUserTypes[0].value)
}, [endUserCredentialEnabled, endUserCredentialType, availableEndUserTypes, onEndUserCredentialTypeChange])
const handleSelectEndUserType = useCallback((value: string) => {
onEndUserCredentialTypeChange?.(value)
setShowEndUserTypeMenu(false)
}, [onEndUserCredentialTypeChange])
const containerClassName = useMemo(() => {
if (showConnectGuide)
return className
return !isAuthorized ? className : undefined
}, [isAuthorized, className, showConnectGuide])
useEffect(() => {
if (isAuthorized)
setShowCredentialPanel(false)
}, [isAuthorized])
const credentialList = useMemo(() => {
return (
<div className={cn(!credentials.length ? 'mt-0' : 'mt-3')}>
{
credentials.length > 0
? (
<div className='space-y-1'>
{credentials.map(credential => (
<Item
key={credential.id}
credential={credential}
disabled
disableRename
disableEdit
disableDelete
disableSetDefault
/>
))}
</div>
)
: null
}
</div>
)
}, [credentials, t])
const endUserSwitch = (
<div className='px-3 py-3'>
<div className='flex gap-3'>
<div className='mt-1'>
<RiUserStarLine className='h-5 w-5 text-text-tertiary' />
</div>
<div className='flex-1 space-y-2'>
<div className='flex items-center justify-between'>
<div className='system-sm-semibold text-text-secondary'>
{t('plugin.auth.endUserCredentials.title')}
</div>
<Switch
size='md'
defaultValue={!!endUserCredentialEnabled}
onChange={onEndUserCredentialChange}
disabled={disabled}
/>
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.auth.endUserCredentials.desc')}
</div>
{
endUserCredentialEnabled && availableEndUserTypes.length > 0 && (
<div className='flex items-center justify-between gap-3 pt-1'>
<div className='system-sm-semibold text-text-secondary'>
{t('plugin.auth.endUserCredentials.typeLabel')}
</div>
<PortalToFollowElem
open={showEndUserTypeMenu}
onOpenChange={setShowEndUserTypeMenu}
placement='bottom-end'
offset={6}
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className='flex h-9 min-w-[170px] items-center justify-between rounded-xl border border-components-button-secondary-border bg-components-button-secondary-bg px-3 text-left text-text-primary shadow-xs hover:bg-components-button-secondary-bg-hover'
onClick={() => setShowEndUserTypeMenu(v => !v)}
>
<span className='system-sm-semibold'>
{endUserCredentialLabel}
</span>
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[120]'>
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex flex-col'>
{availableEndUserTypes.map(item => (
<button
key={item.value}
type='button'
className='flex items-center justify-between px-4 py-3 text-left hover:bg-components-panel-on-panel-item-bg'
onClick={() => handleSelectEndUserType(item.value)}
>
<div className='flex items-center gap-2'>
{item.icon}
<span className='system-sm-semibold text-text-primary'>{item.label}</span>
</div>
{endUserCredentialType === item.value && (
<RiCheckLine className='h-4 w-4 text-primary-600' />
)}
</button>
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
</div>
</div>
</div>
)
return (
<div className={cn(!isAuthorized && className)}>
<div className={cn(containerClassName)}>
{
!isAuthorized && (
shouldShowGuide && (
<PortalToFollowElem
open={showCredentialPanel}
onOpenChange={setShowCredentialPanel}
placement='bottom-start'
offset={8}
triggerPopupSameWidth
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className='flex w-full items-center justify-center gap-2 rounded-xl bg-primary-600 px-4 py-3 text-left text-white shadow-xs hover:bg-primary-700'
onClick={() => setShowCredentialPanel(v => !v)}
>
<div className='system-sm-semibold text-white'>
{t('plugin.auth.connectCredentials')}
</div>
<RiArrowDownSLine
className={cn(
'h-4 w-4 text-white transition-transform',
showCredentialPanel && 'rotate-180',
)}
/>
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[100]'>
<div className='w-[420px] max-w-[calc(100vw-48px)] rounded-2xl border border-divider-subtle bg-components-panel-bg shadow-lg'>
<div className='border-b border-divider-subtle px-3 py-3'>
<div className='flex items-start justify-between gap-2'>
<div className='flex items-start gap-2'>
<RiKey2Line className='mt-0.5 h-4 w-4 text-text-tertiary' />
<div className='space-y-0.5'>
<div className='system-md-semibold text-text-primary'>
{t('plugin.auth.configuredCredentials.title')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.auth.configuredCredentials.desc')}
</div>
</div>
</div>
<PortalToFollowElem
open={showAddMenu}
onOpenChange={setShowAddMenu}
placement='bottom-end'
offset={6}
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full bg-primary-600 text-white hover:bg-primary-700',
configuredDisabled && 'pointer-events-none opacity-50',
)}
onClick={() => {
setShowCredentialPanel(true)
setShowAddMenu(v => !v)
}}
>
<RiAddLine className='h-5 w-5' />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[120]'>
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex flex-col gap-1 p-1'>
{
canOAuth && (
<AddOAuthButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addOAuth')}
disabled={disabled || configuredDisabled}
onUpdate={() => {
setShowAddMenu(false)
invalidPluginCredentialInfo()
}}
/>
)
}
{
canApiKey && (
<AddApiKeyButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addApi')}
disabled={disabled || configuredDisabled}
onUpdate={() => {
setShowAddMenu(false)
invalidPluginCredentialInfo()
}}
/>
)
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
<div className={cn(configuredDisabled && 'pointer-events-none opacity-50')}>
{credentialList}
</div>
{
credentials.length === 0 && (
<div className={cn(
'mt-4 rounded-2xl border border-components-panel-border bg-components-panel-on-panel-item-bg px-4 py-4',
configuredDisabled && 'pointer-events-none opacity-50',
)}>
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled || configuredDisabled}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
theme='secondary'
showDivider={!!(canOAuth && canApiKey)}
/>
</div>
)
}
</div>
{endUserSwitch}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
{
!shouldShowGuide && !isAuthorized && (
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}

View File

@ -56,6 +56,8 @@ export type ToolDefaultValue = PluginCommonDefaultValue & {
paramSchemas: Record<string, any>[]
output_schema?: Record<string, any>
credential_id?: string
use_end_user_credentials?: boolean
end_user_credential_type?: string
meta?: PluginMeta
plugin_id?: string
provider_icon?: Collection['icon']
@ -86,6 +88,8 @@ export type ToolValue = {
enabled?: boolean
extra?: Record<string, any>
credential_id?: string
use_end_user_credentials?: boolean
end_user_credential_type?: string
}
export type DataSourceItem = {

View File

@ -325,6 +325,22 @@ const BasePanel: FC<BasePanelProps> = ({
},
})
}, [handleNodeDataUpdateWithSyncDraft, id])
const handleEndUserCredentialChange = useCallback((enabled: boolean) => {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
use_end_user_credentials: enabled,
},
})
}, [handleNodeDataUpdateWithSyncDraft, id])
const handleEndUserCredentialTypeChange = useCallback((type: string) => {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
end_user_credential_type: type,
},
})
}, [handleNodeDataUpdateWithSyncDraft, id])
const { setShowAccountSettingModal } = useModalContext()
@ -516,6 +532,11 @@ const BasePanel: FC<BasePanelProps> = ({
needsToolAuth && (
<PluginAuth
className='px-4 pb-2'
showConnectGuide
endUserCredentialEnabled={data.use_end_user_credentials}
endUserCredentialType={data.end_user_credential_type}
onEndUserCredentialChange={handleEndUserCredentialChange}
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
pluginPayload={{
provider: currToolCollection?.name || '',
providerType: currToolCollection?.type || '',

View File

@ -103,6 +103,8 @@ export type CommonNodeType<T = {}> = {
retry_config?: WorkflowRetryConfig
default_value?: DefaultValueForm[]
credential_id?: string
use_end_user_credentials?: boolean
end_user_credential_type?: string
subscription_id?: string
provider_id?: string
_dimmed?: boolean

View File

@ -308,6 +308,19 @@ const translation = {
unavailable: 'Unavailable',
connectedWorkspace: 'Connected Workspace',
emptyAuth: 'Please configure authentication',
connectCredentials: 'Connect credentials to continue',
configuredCredentials: {
title: 'Configured Credentials',
desc: 'Set up by you or your team in advance',
empty: 'No workspace credentials configured yet',
},
endUserCredentials: {
title: 'End-user Credentials',
desc: 'Credentials are provided by the end user at runtime',
typeLabel: 'Credential Type',
optionOAuth: 'OAuth',
optionApiKey: 'API Key',
},
},
readmeInfo: {
title: 'README',