mirror of https://github.com/langgenius/dify.git
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:
parent
9df9db3a8f
commit
b7d9483bc2
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue