Files
dify/web/app/components/plugins/plugin-auth/authorized/index.tsx
T

379 lines
14 KiB
TypeScript

import type { PopoverContentProps } from '@langgenius/dify-ui/popover'
import type { ComponentPropsWithRef, ReactElement } from 'react'
import type { Credential, PluginPayload } from '../types'
import {
AlertDialog,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { memo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/notifications'
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
import Authorize from '../authorize'
import ApiKeyModal from '../authorize/api-key-modal'
import {
useDeletePluginCredentialHook,
useSetPluginDefaultCredentialHook,
useUpdatePluginCredentialHook,
} from '../hooks/use-credential'
import { CredentialTypeEnum } from '../types'
import Item from './item'
type AuthorizedProps = Pick<PopoverContentProps, 'side' | 'align' | 'sideOffset'> & {
pluginPayload: PluginPayload
credentials: Credential[]
canOAuth?: boolean
canApiKey?: boolean
renderTrigger?: (open: boolean, props: ComponentPropsWithRef<'button'>) => ReactElement
isOpen?: boolean
onOpenChange?: (open: boolean) => void
triggerPopupSameWidth?: boolean
popupClassName?: string
disableSetDefault?: boolean
onItemClick?: (id: string) => void
extraAuthorizationItems?: Credential[]
showItemSelectedIcon?: boolean
selectedCredentialId?: string
onUpdate?: () => void
notAllowCustomCredential?: boolean
}
const Authorized = ({
pluginPayload,
credentials,
canOAuth,
canApiKey,
renderTrigger,
isOpen,
onOpenChange,
sideOffset,
side,
align = 'start',
triggerPopupSameWidth = true,
popupClassName,
disableSetDefault,
onItemClick,
extraAuthorizationItems,
showItemSelectedIcon,
selectedCredentialId,
onUpdate,
notAllowCustomCredential,
}: AuthorizedProps) => {
const { t } = useTranslation(['common', 'datasetDocuments', 'plugin'])
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
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 pendingOperationCredentialIdRef = useRef<string | null>(null)
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
const openConfirm = useCallback(
(credentialId?: string) => {
if (!canManageCredential) return
setMergedIsOpen(false)
if (credentialId) pendingOperationCredentialIdRef.current = credentialId
setDeleteCredentialId(pendingOperationCredentialIdRef.current)
},
[canManageCredential, setMergedIsOpen],
)
const closeConfirm = useCallback(() => {
setDeleteCredentialId(null)
pendingOperationCredentialIdRef.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 || !canManageCredential) return
if (!pendingOperationCredentialIdRef.current) {
setDeleteCredentialId(null)
return
}
try {
handleSetDoingAction(true)
await deletePluginCredential({ credential_id: pendingOperationCredentialIdRef.current })
toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }))
onUpdate?.()
setDeleteCredentialId(null)
pendingOperationCredentialIdRef.current = null
} finally {
handleSetDoingAction(false)
}
}, [canManageCredential, deletePluginCredential, onUpdate, t, handleSetDoingAction])
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null)
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false)
const handleEdit = useCallback(
(id: string, values: Record<string, unknown>) => {
if (!canManageCredential) return
setMergedIsOpen(false)
pendingOperationCredentialIdRef.current = id
setEditValues(values)
setIsApiKeyModalOpen(true)
},
[canManageCredential, setMergedIsOpen],
)
const handleApiKeyModalOpenChange = useCallback((open: boolean) => {
setIsApiKeyModalOpen(open)
if (!open) pendingOperationCredentialIdRef.current = null
}, [])
// Lifted state for the "+ Add API Key" modal so it isn't unmounted when the
// popover closes due to outside-click detection on the modal's portal.
const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false)
const handleAddApiKeyClick = useCallback(() => {
if (!canCreateCredential) return
setMergedIsOpen(false)
setIsAddApiKeyOpen(true)
}, [canCreateCredential, setMergedIsOpen])
const handleRemove = useCallback(() => {
if (!canManageCredential) return
setDeleteCredentialId(pendingOperationCredentialIdRef.current)
}, [canManageCredential])
const { mutateAsync: setPluginDefaultCredential } =
useSetPluginDefaultCredentialHook(pluginPayload)
const handleSetDefault = useCallback(
async (id: string) => {
if (doingActionRef.current || !canUseCredential) return
try {
handleSetDoingAction(true)
await setPluginDefaultCredential(id)
toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }))
onUpdate?.()
} finally {
handleSetDoingAction(false)
}
},
[canUseCredential, setPluginDefaultCredential, onUpdate, t, handleSetDoingAction],
)
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
const handleRename = useCallback(
async (payload: { credential_id: string; name: string }) => {
if (doingActionRef.current || !canManageCredential) return
try {
handleSetDoingAction(true)
await updatePluginCredential(payload)
toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }))
onUpdate?.()
} finally {
handleSetDoingAction(false)
}
},
[canManageCredential, updatePluginCredential, t, handleSetDoingAction, onUpdate],
)
const unavailableCredentials = credentials.filter((credential) => credential.not_allowed_to_use)
const unavailableCredential = credentials.find(
(credential) => credential.not_allowed_to_use && credential.is_default,
)
return (
<>
<Popover open={mergedIsOpen} onOpenChange={setMergedIsOpen}>
<PopoverTrigger
className={triggerPopupSameWidth ? 'w-full' : undefined}
render={(props, state) =>
renderTrigger ? (
renderTrigger(state.open, props)
) : (
<Button
{...props}
className={cn(
props.className,
state.open && 'bg-components-button-secondary-bg-hover',
)}
>
<StatusDot status={unavailableCredential ? 'disabled' : 'success'} />
{credentials.length}
&nbsp;
{credentials.length > 1
? t(($) => $['auth.authorizations'], { ns: 'plugin' })
: t(($) => $['auth.authorization'], { ns: 'plugin' })}
{!!unavailableCredentials.length &&
` (${unavailableCredentials.length} ${t(($) => $['auth.unavailable'], { ns: 'plugin' })})`}
<span className="i-ri-arrow-down-s-line size-4" />
</Button>
)
}
/>
<PopoverContent
aria-label={t(($) => $['auth.authorizations'], { ns: 'plugin' })}
side={side}
align={align}
sideOffset={sideOffset}
className={cn(
'border-0 bg-transparent p-0 shadow-none backdrop-blur-none',
triggerPopupSameWidth && 'w-(--anchor-width)',
)}
>
<div
className={cn(
'max-h-90 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}
onItemClick={onItemClick}
disableRename
disableEdit
disableDelete
disableSetDefault
showSelectedIcon={showItemSelectedIcon}
selectedCredentialId={selectedCredentialId}
/>
))}
</div>
)}
{!!oAuthCredentials.length && (
<div className="p-1">
<div
className={cn(
'px-3 pt-1 pb-0.5 system-xs-medium text-text-tertiary',
showItemSelectedIcon && 'pl-7',
)}
>
OAuth
</div>
{oAuthCredentials.map((credential) => (
<Item
key={credential.id}
credential={credential}
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(
'px-3 pt-1 pb-0.5 system-xs-medium text-text-tertiary',
showItemSelectedIcon && 'pl-7',
)}
>
API Keys
</div>
{apiKeyCredentials.map((credential) => (
<Item
key={credential.id}
credential={credential}
onDelete={openConfirm}
onEdit={handleEdit}
onSetDefault={handleSetDefault}
disableSetDefault={disableSetDefault}
disableRename
onItemClick={onItemClick}
onRename={handleRename}
showSelectedIcon={showItemSelectedIcon}
selectedCredentialId={selectedCredentialId}
/>
))}
</div>
)}
</div>
{!notAllowCustomCredential && (
<>
<div className="h-px bg-divider-subtle"></div>
<div className="p-2">
<Authorize
pluginPayload={pluginPayload}
theme="secondary"
showDivider={false}
canOAuth={canOAuth}
canApiKey={canApiKey}
onUpdate={onUpdate}
onApiKeyClick={handleAddApiKeyClick}
/>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
<AlertDialog open={!!deleteCredentialId} onOpenChange={(open) => !open && closeConfirm()}>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<AlertDialogHeader>
<AlertDialogTitle>
{t(($) => $['list.delete.title'], { ns: 'datasetDocuments' })}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
disabled={!canManageCredential || doingAction}
onClick={handleConfirm}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{!!editValues && (
<ApiKeyModal
open={isApiKeyModalOpen}
onOpenChange={handleApiKeyModalOpenChange}
pluginPayload={pluginPayload}
editValues={editValues}
onClose={() => handleApiKeyModalOpenChange(false)}
onRemove={handleRemove}
disabled={!canManageCredential || doingAction}
onUpdate={onUpdate}
/>
)}
{isAddApiKeyOpen && (
<ApiKeyModal
open={isAddApiKeyOpen}
onOpenChange={setIsAddApiKeyOpen}
pluginPayload={pluginPayload}
onClose={() => setIsAddApiKeyOpen(false)}
disabled={!canCreateCredential || doingAction}
onUpdate={onUpdate}
/>
)}
</>
)
}
export default memo(Authorized)