您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import {
  2. memo,
  3. useCallback,
  4. useRef,
  5. useState,
  6. } from 'react'
  7. import {
  8. RiArrowDownSLine,
  9. } from '@remixicon/react'
  10. import { useTranslation } from 'react-i18next'
  11. import {
  12. PortalToFollowElem,
  13. PortalToFollowElemContent,
  14. PortalToFollowElemTrigger,
  15. } from '@/app/components/base/portal-to-follow-elem'
  16. import type {
  17. PortalToFollowElemOptions,
  18. } from '@/app/components/base/portal-to-follow-elem'
  19. import Button from '@/app/components/base/button'
  20. import Indicator from '@/app/components/header/indicator'
  21. import cn from '@/utils/classnames'
  22. import Confirm from '@/app/components/base/confirm'
  23. import Authorize from '../authorize'
  24. import type { Credential } from '../types'
  25. import { CredentialTypeEnum } from '../types'
  26. import ApiKeyModal from '../authorize/api-key-modal'
  27. import Item from './item'
  28. import { useToastContext } from '@/app/components/base/toast'
  29. import type { PluginPayload } from '../types'
  30. import {
  31. useDeletePluginCredentialHook,
  32. useSetPluginDefaultCredentialHook,
  33. useUpdatePluginCredentialHook,
  34. } from '../hooks/use-credential'
  35. type AuthorizedProps = {
  36. pluginPayload: PluginPayload
  37. credentials: Credential[]
  38. canOAuth?: boolean
  39. canApiKey?: boolean
  40. disabled?: boolean
  41. renderTrigger?: (open?: boolean) => React.ReactNode
  42. isOpen?: boolean
  43. onOpenChange?: (open: boolean) => void
  44. offset?: PortalToFollowElemOptions['offset']
  45. placement?: PortalToFollowElemOptions['placement']
  46. triggerPopupSameWidth?: boolean
  47. popupClassName?: string
  48. disableSetDefault?: boolean
  49. onItemClick?: (id: string) => void
  50. extraAuthorizationItems?: Credential[]
  51. showItemSelectedIcon?: boolean
  52. selectedCredentialId?: string
  53. onUpdate?: () => void
  54. notAllowCustomCredential?: boolean
  55. }
  56. const Authorized = ({
  57. pluginPayload,
  58. credentials,
  59. canOAuth,
  60. canApiKey,
  61. disabled,
  62. renderTrigger,
  63. isOpen,
  64. onOpenChange,
  65. offset = 8,
  66. placement = 'bottom-start',
  67. triggerPopupSameWidth = true,
  68. popupClassName,
  69. disableSetDefault,
  70. onItemClick,
  71. extraAuthorizationItems,
  72. showItemSelectedIcon,
  73. selectedCredentialId,
  74. onUpdate,
  75. notAllowCustomCredential,
  76. }: AuthorizedProps) => {
  77. const { t } = useTranslation()
  78. const { notify } = useToastContext()
  79. const [isLocalOpen, setIsLocalOpen] = useState(false)
  80. const mergedIsOpen = isOpen ?? isLocalOpen
  81. const setMergedIsOpen = useCallback((open: boolean) => {
  82. if (onOpenChange)
  83. onOpenChange(open)
  84. setIsLocalOpen(open)
  85. }, [onOpenChange])
  86. const oAuthCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.OAUTH2)
  87. const apiKeyCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.API_KEY)
  88. const pendingOperationCredentialId = useRef<string | null>(null)
  89. const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
  90. const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
  91. const openConfirm = useCallback((credentialId?: string) => {
  92. if (credentialId)
  93. pendingOperationCredentialId.current = credentialId
  94. setDeleteCredentialId(pendingOperationCredentialId.current)
  95. }, [])
  96. const closeConfirm = useCallback(() => {
  97. setDeleteCredentialId(null)
  98. pendingOperationCredentialId.current = null
  99. }, [])
  100. const [doingAction, setDoingAction] = useState(false)
  101. const doingActionRef = useRef(doingAction)
  102. const handleSetDoingAction = useCallback((doing: boolean) => {
  103. doingActionRef.current = doing
  104. setDoingAction(doing)
  105. }, [])
  106. const handleConfirm = useCallback(async () => {
  107. if (doingActionRef.current)
  108. return
  109. if (!pendingOperationCredentialId.current) {
  110. setDeleteCredentialId(null)
  111. return
  112. }
  113. try {
  114. handleSetDoingAction(true)
  115. await deletePluginCredential({ credential_id: pendingOperationCredentialId.current })
  116. notify({
  117. type: 'success',
  118. message: t('common.api.actionSuccess'),
  119. })
  120. onUpdate?.()
  121. setDeleteCredentialId(null)
  122. pendingOperationCredentialId.current = null
  123. }
  124. finally {
  125. handleSetDoingAction(false)
  126. }
  127. }, [deletePluginCredential, onUpdate, notify, t, handleSetDoingAction])
  128. const [editValues, setEditValues] = useState<Record<string, any> | null>(null)
  129. const handleEdit = useCallback((id: string, values: Record<string, any>) => {
  130. pendingOperationCredentialId.current = id
  131. setEditValues(values)
  132. }, [])
  133. const handleRemove = useCallback(() => {
  134. setDeleteCredentialId(pendingOperationCredentialId.current)
  135. }, [])
  136. const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload)
  137. const handleSetDefault = useCallback(async (id: string) => {
  138. if (doingActionRef.current)
  139. return
  140. try {
  141. handleSetDoingAction(true)
  142. await setPluginDefaultCredential(id)
  143. notify({
  144. type: 'success',
  145. message: t('common.api.actionSuccess'),
  146. })
  147. onUpdate?.()
  148. }
  149. finally {
  150. handleSetDoingAction(false)
  151. }
  152. }, [setPluginDefaultCredential, onUpdate, notify, t, handleSetDoingAction])
  153. const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
  154. const handleRename = useCallback(async (payload: {
  155. credential_id: string
  156. name: string
  157. }) => {
  158. if (doingActionRef.current)
  159. return
  160. try {
  161. handleSetDoingAction(true)
  162. await updatePluginCredential(payload)
  163. notify({
  164. type: 'success',
  165. message: t('common.api.actionSuccess'),
  166. })
  167. onUpdate?.()
  168. }
  169. finally {
  170. handleSetDoingAction(false)
  171. }
  172. }, [updatePluginCredential, notify, t, handleSetDoingAction, onUpdate])
  173. const unavailableCredentials = credentials.filter(credential => credential.not_allowed_to_use)
  174. return (
  175. <>
  176. <PortalToFollowElem
  177. open={mergedIsOpen}
  178. onOpenChange={setMergedIsOpen}
  179. placement={placement}
  180. offset={offset}
  181. triggerPopupSameWidth={triggerPopupSameWidth}
  182. >
  183. <PortalToFollowElemTrigger
  184. onClick={() => setMergedIsOpen(!mergedIsOpen)}
  185. asChild
  186. >
  187. {
  188. renderTrigger
  189. ? renderTrigger(mergedIsOpen)
  190. : (
  191. <Button
  192. className={cn(
  193. 'w-full',
  194. isOpen && 'bg-components-button-secondary-bg-hover',
  195. )}>
  196. <Indicator className='mr-2' />
  197. {credentials.length}&nbsp;
  198. {
  199. credentials.length > 1
  200. ? t('plugin.auth.authorizations')
  201. : t('plugin.auth.authorization')
  202. }
  203. {
  204. !!unavailableCredentials.length && (
  205. ` (${unavailableCredentials.length} ${t('plugin.auth.unavailable')})`
  206. )
  207. }
  208. <RiArrowDownSLine className='ml-0.5 h-4 w-4' />
  209. </Button>
  210. )
  211. }
  212. </PortalToFollowElemTrigger>
  213. <PortalToFollowElemContent className='z-[100]'>
  214. <div className={cn(
  215. 'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg',
  216. popupClassName,
  217. )}>
  218. <div className='py-1'>
  219. {
  220. !!extraAuthorizationItems?.length && (
  221. <div className='p-1'>
  222. {
  223. extraAuthorizationItems.map(credential => (
  224. <Item
  225. key={credential.id}
  226. credential={credential}
  227. disabled={disabled}
  228. onItemClick={onItemClick}
  229. disableRename
  230. disableEdit
  231. disableDelete
  232. disableSetDefault
  233. showSelectedIcon={showItemSelectedIcon}
  234. selectedCredentialId={selectedCredentialId}
  235. />
  236. ))
  237. }
  238. </div>
  239. )
  240. }
  241. {
  242. !!oAuthCredentials.length && (
  243. <div className='p-1'>
  244. <div className={cn(
  245. 'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
  246. showItemSelectedIcon && 'pl-7',
  247. )}>
  248. OAuth
  249. </div>
  250. {
  251. oAuthCredentials.map(credential => (
  252. <Item
  253. key={credential.id}
  254. credential={credential}
  255. disabled={disabled}
  256. disableEdit
  257. onDelete={openConfirm}
  258. onSetDefault={handleSetDefault}
  259. onRename={handleRename}
  260. disableSetDefault={disableSetDefault}
  261. onItemClick={onItemClick}
  262. showSelectedIcon={showItemSelectedIcon}
  263. selectedCredentialId={selectedCredentialId}
  264. />
  265. ))
  266. }
  267. </div>
  268. )
  269. }
  270. {
  271. !!apiKeyCredentials.length && (
  272. <div className='p-1'>
  273. <div className={cn(
  274. 'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
  275. showItemSelectedIcon && 'pl-7',
  276. )}>
  277. API Keys
  278. </div>
  279. {
  280. apiKeyCredentials.map(credential => (
  281. <Item
  282. key={credential.id}
  283. credential={credential}
  284. disabled={disabled}
  285. onDelete={openConfirm}
  286. onEdit={handleEdit}
  287. onSetDefault={handleSetDefault}
  288. disableSetDefault={disableSetDefault}
  289. disableRename
  290. onItemClick={onItemClick}
  291. onRename={handleRename}
  292. showSelectedIcon={showItemSelectedIcon}
  293. selectedCredentialId={selectedCredentialId}
  294. />
  295. ))
  296. }
  297. </div>
  298. )
  299. }
  300. </div>
  301. {
  302. !notAllowCustomCredential && (
  303. <>
  304. <div className='h-[1px] bg-divider-subtle'></div>
  305. <div className='p-2'>
  306. <Authorize
  307. pluginPayload={pluginPayload}
  308. theme='secondary'
  309. showDivider={false}
  310. canOAuth={canOAuth}
  311. canApiKey={canApiKey}
  312. disabled={disabled}
  313. onUpdate={onUpdate}
  314. />
  315. </div>
  316. </>
  317. )
  318. }
  319. </div>
  320. </PortalToFollowElemContent>
  321. </PortalToFollowElem>
  322. {
  323. deleteCredentialId && (
  324. <Confirm
  325. isShow
  326. title={t('datasetDocuments.list.delete.title')}
  327. isDisabled={doingAction}
  328. onCancel={closeConfirm}
  329. onConfirm={handleConfirm}
  330. />
  331. )
  332. }
  333. {
  334. !!editValues && (
  335. <ApiKeyModal
  336. pluginPayload={pluginPayload}
  337. editValues={editValues}
  338. onClose={() => {
  339. setEditValues(null)
  340. pendingOperationCredentialId.current = null
  341. }}
  342. onRemove={handleRemove}
  343. disabled={disabled || doingAction}
  344. onUpdate={onUpdate}
  345. />
  346. )
  347. }
  348. </>
  349. )
  350. }
  351. export default memo(Authorized)