Compare commits

...

1 Commits

Author SHA1 Message Date
CodingOnStar
c29af7a15f feat(auth): implement credential management modals and hooks
- Added  component for handling delete confirmation and edit modals.
- Introduced  and  for displaying credentials.
- Created custom hooks  and  to manage credential actions and modal states.
- Refactored  component to utilize new hooks and components for improved readability and maintainability.
2026-01-14 17:05:17 +08:00
7 changed files with 2003 additions and 256 deletions

View File

@@ -0,0 +1,65 @@
import type { PluginPayload } from '../types'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import ApiKeyModal from '../authorize/api-key-modal'
type AuthorizedModalsProps = {
pluginPayload: PluginPayload
// Delete confirmation
deleteCredentialId: string | null
doingAction: boolean
onDeleteConfirm: () => void
onDeleteCancel: () => void
// Edit modal
editValues: Record<string, unknown> | null
disabled?: boolean
onEditClose: () => void
onRemove: () => void
onUpdate?: () => void
}
/**
* Component for managing authorized modals (delete confirmation and edit modal)
* Extracted to reduce complexity in the main Authorized component
*/
const AuthorizedModals = ({
pluginPayload,
deleteCredentialId,
doingAction,
onDeleteConfirm,
onDeleteCancel,
editValues,
disabled,
onEditClose,
onRemove,
onUpdate,
}: AuthorizedModalsProps) => {
const { t } = useTranslation()
return (
<>
{deleteCredentialId && (
<Confirm
isShow
title={t('list.delete.title', { ns: 'datasetDocuments' })}
isDisabled={doingAction}
onCancel={onDeleteCancel}
onConfirm={onDeleteConfirm}
/>
)}
{!!editValues && (
<ApiKeyModal
pluginPayload={pluginPayload}
editValues={editValues}
onClose={onEditClose}
onRemove={onRemove}
disabled={disabled || doingAction}
onUpdate={onUpdate}
/>
)}
</>
)
}
export default memo(AuthorizedModals)

View File

@@ -0,0 +1,123 @@
import type { Credential } from '../types'
import { memo } from 'react'
import { cn } from '@/utils/classnames'
import Item from './item'
type CredentialItemHandlers = {
onDelete?: (id: string) => void
onEdit?: (id: string, values: Record<string, unknown>) => void
onSetDefault?: (id: string) => void
onRename?: (payload: { credential_id: string, name: string }) => void
onItemClick?: (id: string) => void
}
type CredentialSectionProps = CredentialItemHandlers & {
title: string
credentials: Credential[]
disabled?: boolean
disableRename?: boolean
disableEdit?: boolean
disableDelete?: boolean
disableSetDefault?: boolean
showSelectedIcon?: boolean
selectedCredentialId?: string
}
/**
* Reusable component for rendering a section of credentials
* Used for OAuth, API Key, and extra authorization items
*/
const CredentialSection = ({
title,
credentials,
disabled,
disableRename,
disableEdit,
disableDelete,
disableSetDefault,
showSelectedIcon,
selectedCredentialId,
onDelete,
onEdit,
onSetDefault,
onRename,
onItemClick,
}: CredentialSectionProps) => {
if (!credentials.length)
return null
return (
<div className="p-1">
<div className={cn(
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
showSelectedIcon && 'pl-7',
)}
>
{title}
</div>
{credentials.map(credential => (
<Item
key={credential.id}
credential={credential}
disabled={disabled}
disableRename={disableRename}
disableEdit={disableEdit}
disableDelete={disableDelete}
disableSetDefault={disableSetDefault}
showSelectedIcon={showSelectedIcon}
selectedCredentialId={selectedCredentialId}
onDelete={onDelete}
onEdit={onEdit}
onSetDefault={onSetDefault}
onRename={onRename}
onItemClick={onItemClick}
/>
))}
</div>
)
}
export default memo(CredentialSection)
type ExtraCredentialSectionProps = {
credentials?: Credential[]
disabled?: boolean
onItemClick?: (id: string) => void
showSelectedIcon?: boolean
selectedCredentialId?: string
}
/**
* Specialized section for extra authorization items (read-only)
*/
export const ExtraCredentialSection = memo(({
credentials,
disabled,
onItemClick,
showSelectedIcon,
selectedCredentialId,
}: ExtraCredentialSectionProps) => {
if (!credentials?.length)
return null
return (
<div className="p-1">
{credentials.map(credential => (
<Item
key={credential.id}
credential={credential}
disabled={disabled}
onItemClick={onItemClick}
disableRename
disableEdit
disableDelete
disableSetDefault
showSelectedIcon={showSelectedIcon}
selectedCredentialId={selectedCredentialId}
/>
))}
</div>
)
})
ExtraCredentialSection.displayName = 'ExtraCredentialSection'

View File

@@ -0,0 +1,2 @@
export { useCredentialActions } from './use-credential-actions'
export { useModalState } from './use-modal-state'

View File

@@ -0,0 +1,116 @@
import type { MutableRefObject } from 'react'
import type { PluginPayload } from '../../types'
import {
useCallback,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import {
useDeletePluginCredentialHook,
useSetPluginDefaultCredentialHook,
useUpdatePluginCredentialHook,
} from '../../hooks/use-credential'
type UseCredentialActionsOptions = {
pluginPayload: PluginPayload
onUpdate?: () => void
}
type UseCredentialActionsReturn = {
doingAction: boolean
doingActionRef: MutableRefObject<boolean>
pendingOperationCredentialIdRef: MutableRefObject<string | null>
handleSetDoingAction: (doing: boolean) => void
handleDelete: (credentialId: string) => Promise<void>
handleSetDefault: (id: string) => Promise<void>
handleRename: (payload: { credential_id: string, name: string }) => Promise<void>
}
/**
* Custom hook for credential CRUD operations
* Consolidates delete, setDefault, rename actions with shared loading state
*/
export const useCredentialActions = ({
pluginPayload,
onUpdate,
}: UseCredentialActionsOptions): UseCredentialActionsReturn => {
const { t } = useTranslation()
const { notify } = useToastContext()
const [doingAction, setDoingAction] = useState(false)
const doingActionRef = useRef(doingAction)
const pendingOperationCredentialIdRef = useRef<string | null>(null)
const handleSetDoingAction = useCallback((doing: boolean) => {
doingActionRef.current = doing
setDoingAction(doing)
}, [])
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload)
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
const showSuccessNotification = useCallback(() => {
notify({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
})
}, [notify, t])
const handleDelete = useCallback(async (credentialId: string) => {
if (doingActionRef.current)
return
try {
handleSetDoingAction(true)
await deletePluginCredential({ credential_id: credentialId })
showSuccessNotification()
onUpdate?.()
}
finally {
handleSetDoingAction(false)
}
}, [deletePluginCredential, onUpdate, showSuccessNotification, handleSetDoingAction])
const handleSetDefault = useCallback(async (id: string) => {
if (doingActionRef.current)
return
try {
handleSetDoingAction(true)
await setPluginDefaultCredential(id)
showSuccessNotification()
onUpdate?.()
}
finally {
handleSetDoingAction(false)
}
}, [setPluginDefaultCredential, onUpdate, showSuccessNotification, handleSetDoingAction])
const handleRename = useCallback(async (payload: {
credential_id: string
name: string
}) => {
if (doingActionRef.current)
return
try {
handleSetDoingAction(true)
await updatePluginCredential(payload)
showSuccessNotification()
onUpdate?.()
}
finally {
handleSetDoingAction(false)
}
}, [updatePluginCredential, showSuccessNotification, handleSetDoingAction, onUpdate])
return {
doingAction,
doingActionRef,
pendingOperationCredentialIdRef,
handleSetDoingAction,
handleDelete,
handleSetDefault,
handleRename,
}
}

View File

@@ -0,0 +1,71 @@
import type { MutableRefObject } from 'react'
import {
useCallback,
useState,
} from 'react'
type CredentialValues = Record<string, unknown>
type UseModalStateOptions = {
pendingOperationCredentialIdRef: MutableRefObject<string | null>
}
type UseModalStateReturn = {
// Delete modal state
deleteCredentialId: string | null
openDeleteConfirm: (credentialId?: string) => void
closeDeleteConfirm: () => void
// Edit modal state
editValues: CredentialValues | null
openEditModal: (id: string, values: CredentialValues) => void
closeEditModal: () => void
// Remove action (used from edit modal)
handleRemoveFromEdit: () => void
}
/**
* Custom hook for managing modal states
* Handles delete confirmation and edit modal with shared pending credential tracking
*/
export const useModalState = ({
pendingOperationCredentialIdRef,
}: UseModalStateOptions): UseModalStateReturn => {
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
const [editValues, setEditValues] = useState<CredentialValues | null>(null)
const openDeleteConfirm = useCallback((credentialId?: string) => {
if (credentialId)
pendingOperationCredentialIdRef.current = credentialId
setDeleteCredentialId(pendingOperationCredentialIdRef.current)
}, [pendingOperationCredentialIdRef])
const closeDeleteConfirm = useCallback(() => {
setDeleteCredentialId(null)
pendingOperationCredentialIdRef.current = null
}, [pendingOperationCredentialIdRef])
const openEditModal = useCallback((id: string, values: CredentialValues) => {
pendingOperationCredentialIdRef.current = id
setEditValues(values)
}, [pendingOperationCredentialIdRef])
const closeEditModal = useCallback(() => {
setEditValues(null)
pendingOperationCredentialIdRef.current = null
}, [pendingOperationCredentialIdRef])
const handleRemoveFromEdit = useCallback(() => {
setDeleteCredentialId(pendingOperationCredentialIdRef.current)
}, [pendingOperationCredentialIdRef])
return {
deleteCredentialId,
openDeleteConfirm,
closeDeleteConfirm,
editValues,
openEditModal,
closeEditModal,
handleRemoveFromEdit,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,29 +8,23 @@ import {
import { import {
memo, memo,
useCallback, useCallback,
useRef, useMemo,
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import { import {
PortalToFollowElem, PortalToFollowElem,
PortalToFollowElemContent, PortalToFollowElemContent,
PortalToFollowElemTrigger, PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem' } from '@/app/components/base/portal-to-follow-elem'
import { useToastContext } from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator' import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import Authorize from '../authorize' import Authorize from '../authorize'
import ApiKeyModal from '../authorize/api-key-modal'
import {
useDeletePluginCredentialHook,
useSetPluginDefaultCredentialHook,
useUpdatePluginCredentialHook,
} from '../hooks/use-credential'
import { CredentialTypeEnum } from '../types' import { CredentialTypeEnum } from '../types'
import Item from './item' import AuthorizedModals from './authorized-modals'
import CredentialSection, { ExtraCredentialSection } from './credential-section'
import { useCredentialActions, useModalState } from './hooks'
type AuthorizedProps = { type AuthorizedProps = {
pluginPayload: PluginPayload pluginPayload: PluginPayload
@@ -53,6 +47,7 @@ type AuthorizedProps = {
onUpdate?: () => void onUpdate?: () => void
notAllowCustomCredential?: boolean notAllowCustomCredential?: boolean
} }
const Authorized = ({ const Authorized = ({
pluginPayload, pluginPayload,
credentials, credentials,
@@ -75,105 +70,55 @@ const Authorized = ({
notAllowCustomCredential, notAllowCustomCredential,
}: AuthorizedProps) => { }: AuthorizedProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { notify } = useToastContext()
// Dropdown open state
const [isLocalOpen, setIsLocalOpen] = useState(false) const [isLocalOpen, setIsLocalOpen] = useState(false)
const mergedIsOpen = isOpen ?? isLocalOpen const mergedIsOpen = isOpen ?? isLocalOpen
const setMergedIsOpen = useCallback((open: boolean) => { const setMergedIsOpen = useCallback((open: boolean) => {
if (onOpenChange) onOpenChange?.(open)
onOpenChange(open)
setIsLocalOpen(open) setIsLocalOpen(open)
}, [onOpenChange]) }, [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)
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
const openConfirm = useCallback((credentialId?: string) => {
if (credentialId)
pendingOperationCredentialId.current = credentialId
setDeleteCredentialId(pendingOperationCredentialId.current) // Credential actions hook
}, []) const {
const closeConfirm = useCallback(() => { doingAction,
setDeleteCredentialId(null) doingActionRef,
pendingOperationCredentialId.current = null pendingOperationCredentialIdRef,
}, []) handleSetDefault,
const [doingAction, setDoingAction] = useState(false) handleRename,
const doingActionRef = useRef(doingAction) handleDelete,
const handleSetDoingAction = useCallback((doing: boolean) => { } = useCredentialActions({ pluginPayload, onUpdate })
doingActionRef.current = doing
setDoingAction(doing) // Modal state management hook
}, []) const {
const handleConfirm = useCallback(async () => { deleteCredentialId,
if (doingActionRef.current) openDeleteConfirm,
closeDeleteConfirm,
editValues,
openEditModal,
closeEditModal,
handleRemoveFromEdit,
} = useModalState({ pendingOperationCredentialIdRef })
// Handle delete confirmation
const handleDeleteConfirm = useCallback(async () => {
if (doingActionRef.current || !pendingOperationCredentialIdRef.current)
return return
if (!pendingOperationCredentialId.current) { await handleDelete(pendingOperationCredentialIdRef.current)
setDeleteCredentialId(null) closeDeleteConfirm()
return }, [doingActionRef, pendingOperationCredentialIdRef, handleDelete, closeDeleteConfirm])
}
try { // Filter credentials by type
handleSetDoingAction(true) const { oAuthCredentials, apiKeyCredentials } = useMemo(() => ({
await deletePluginCredential({ credential_id: pendingOperationCredentialId.current }) oAuthCredentials: credentials.filter(c => c.credential_type === CredentialTypeEnum.OAUTH2),
notify({ apiKeyCredentials: credentials.filter(c => c.credential_type === CredentialTypeEnum.API_KEY),
type: 'success', }), [credentials])
message: t('api.actionSuccess', { ns: 'common' }),
}) // Unavailable credentials info
onUpdate?.() const { unavailableCredentials, hasUnavailableDefault } = useMemo(() => ({
setDeleteCredentialId(null) unavailableCredentials: credentials.filter(c => c.not_allowed_to_use),
pendingOperationCredentialId.current = null hasUnavailableDefault: credentials.some(c => c.not_allowed_to_use && c.is_default),
} }), [credentials])
finally {
handleSetDoingAction(false)
}
}, [deletePluginCredential, onUpdate, notify, t, handleSetDoingAction])
const [editValues, setEditValues] = useState<Record<string, any> | null>(null)
const handleEdit = useCallback((id: string, values: Record<string, any>) => {
pendingOperationCredentialId.current = id
setEditValues(values)
}, [])
const handleRemove = useCallback(() => {
setDeleteCredentialId(pendingOperationCredentialId.current)
}, [])
const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload)
const handleSetDefault = useCallback(async (id: string) => {
if (doingActionRef.current)
return
try {
handleSetDoingAction(true)
await setPluginDefaultCredential(id)
notify({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
})
onUpdate?.()
}
finally {
handleSetDoingAction(false)
}
}, [setPluginDefaultCredential, onUpdate, notify, t, handleSetDoingAction])
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
const handleRename = useCallback(async (payload: {
credential_id: string
name: string
}) => {
if (doingActionRef.current)
return
try {
handleSetDoingAction(true)
await updatePluginCredential(payload)
notify({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
})
onUpdate?.()
}
finally {
handleSetDoingAction(false)
}
}, [updatePluginCredential, notify, 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 ( return (
<> <>
@@ -188,33 +133,27 @@ const Authorized = ({
onClick={() => setMergedIsOpen(!mergedIsOpen)} onClick={() => setMergedIsOpen(!mergedIsOpen)}
asChild asChild
> >
{ {renderTrigger
renderTrigger ? renderTrigger(mergedIsOpen)
? renderTrigger(mergedIsOpen) : (
: ( <Button
<Button className={cn(
className={cn( 'w-full',
'w-full', isOpen && 'bg-components-button-secondary-bg-hover',
isOpen && 'bg-components-button-secondary-bg-hover', )}
)} >
> <Indicator className="mr-2" color={hasUnavailableDefault ? 'gray' : 'green'} />
<Indicator className="mr-2" color={unavailableCredential ? 'gray' : 'green'} /> {credentials.length}
{credentials.length} &nbsp;
&nbsp; {credentials.length > 1
{ ? t('auth.authorizations', { ns: 'plugin' })
credentials.length > 1 : t('auth.authorization', { ns: 'plugin' })}
? t('auth.authorizations', { ns: 'plugin' }) {!!unavailableCredentials.length && (
: t('auth.authorization', { ns: 'plugin' }) ` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
} )}
{ <RiArrowDownSLine className="ml-0.5 h-4 w-4" />
!!unavailableCredentials.length && ( </Button>
` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})` )}
)
}
<RiArrowDownSLine className="ml-0.5 h-4 w-4" />
</Button>
)
}
</PortalToFollowElemTrigger> </PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[100]"> <PortalToFollowElemContent className="z-[100]">
<div className={cn( <div className={cn(
@@ -223,137 +162,72 @@ const Authorized = ({
)} )}
> >
<div className="py-1"> <div className="py-1">
{ <ExtraCredentialSection
!!extraAuthorizationItems?.length && ( credentials={extraAuthorizationItems}
<div className="p-1"> disabled={disabled}
{ onItemClick={onItemClick}
extraAuthorizationItems.map(credential => ( showSelectedIcon={showItemSelectedIcon}
<Item selectedCredentialId={selectedCredentialId}
key={credential.id} />
credential={credential} <CredentialSection
disabled={disabled} title="OAuth"
onItemClick={onItemClick} credentials={oAuthCredentials}
disableRename disabled={disabled}
disableEdit disableEdit
disableDelete disableSetDefault={disableSetDefault}
disableSetDefault showSelectedIcon={showItemSelectedIcon}
showSelectedIcon={showItemSelectedIcon} selectedCredentialId={selectedCredentialId}
selectedCredentialId={selectedCredentialId} onDelete={openDeleteConfirm}
/> onSetDefault={handleSetDefault}
)) onRename={handleRename}
} onItemClick={onItemClick}
</div> />
) <CredentialSection
} title="API Keys"
{ credentials={apiKeyCredentials}
!!oAuthCredentials.length && ( disabled={disabled}
<div className="p-1"> disableRename
<div className={cn( disableSetDefault={disableSetDefault}
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary', showSelectedIcon={showItemSelectedIcon}
showItemSelectedIcon && 'pl-7', selectedCredentialId={selectedCredentialId}
)} onDelete={openDeleteConfirm}
> onEdit={openEditModal}
OAuth onSetDefault={handleSetDefault}
</div> onRename={handleRename}
{ onItemClick={onItemClick}
oAuthCredentials.map(credential => ( />
<Item
key={credential.id}
credential={credential}
disabled={disabled}
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(
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
showItemSelectedIcon && 'pl-7',
)}
>
API Keys
</div>
{
apiKeyCredentials.map(credential => (
<Item
key={credential.id}
credential={credential}
disabled={disabled}
onDelete={openConfirm}
onEdit={handleEdit}
onSetDefault={handleSetDefault}
disableSetDefault={disableSetDefault}
disableRename
onItemClick={onItemClick}
onRename={handleRename}
showSelectedIcon={showItemSelectedIcon}
selectedCredentialId={selectedCredentialId}
/>
))
}
</div>
)
}
</div> </div>
{ {!notAllowCustomCredential && (
!notAllowCustomCredential && ( <>
<> <div className="h-[1px] bg-divider-subtle"></div>
<div className="h-[1px] bg-divider-subtle"></div> <div className="p-2">
<div className="p-2"> <Authorize
<Authorize pluginPayload={pluginPayload}
pluginPayload={pluginPayload} theme="secondary"
theme="secondary" showDivider={false}
showDivider={false} canOAuth={canOAuth}
canOAuth={canOAuth} canApiKey={canApiKey}
canApiKey={canApiKey} disabled={disabled}
disabled={disabled} onUpdate={onUpdate}
onUpdate={onUpdate} />
/> </div>
</div> </>
</> )}
)
}
</div> </div>
</PortalToFollowElemContent> </PortalToFollowElemContent>
</PortalToFollowElem> </PortalToFollowElem>
{ <AuthorizedModals
deleteCredentialId && ( pluginPayload={pluginPayload}
<Confirm deleteCredentialId={deleteCredentialId}
isShow doingAction={doingAction}
title={t('list.delete.title', { ns: 'datasetDocuments' })} onDeleteConfirm={handleDeleteConfirm}
isDisabled={doingAction} onDeleteCancel={closeDeleteConfirm}
onCancel={closeConfirm} editValues={editValues}
onConfirm={handleConfirm} disabled={disabled}
/> onEditClose={closeEditModal}
) onRemove={handleRemoveFromEdit}
} onUpdate={onUpdate}
{ />
!!editValues && (
<ApiKeyModal
pluginPayload={pluginPayload}
editValues={editValues}
onClose={() => {
setEditValues(null)
pendingOperationCredentialId.current = null
}}
onRemove={handleRemove}
disabled={disabled || doingAction}
onUpdate={onUpdate}
/>
)
}
</> </>
) )
} }