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