diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b23e612a0a..f8208a8265 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3182,24 +3182,11 @@ "count": 1 } }, - "web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/plugins/plugin-auth/authorize/index.tsx": { "no-restricted-imports": { "count": 1 } }, - "web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/plugins/plugin-auth/authorized-in-node.tsx": { "ts/no-explicit-any": { "count": 1 diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx index f39148afc8..f00df35d0c 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx @@ -1,10 +1,14 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import type { OAuthClientSettingsProps } from '../oauth-client-settings' +import type { FormSchema } from '@/app/components/base/form/types' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthCategory } from '../../types' const mockGetPluginOAuthUrl = vi.fn().mockResolvedValue({ authorization_url: 'https://auth.example.com' }) const mockOpenOAuthPopup = vi.fn() +const mockWriteText = vi.fn() +const mockOAuthClientSettingsProps: OAuthClientSettingsProps[] = [] vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => (obj: Record | string) => typeof obj === 'string' ? obj : obj.en_US || '', @@ -31,11 +35,37 @@ vi.mock('../../hooks/use-credential', () => ({ })) vi.mock('../oauth-client-settings', () => ({ - default: ({ onClose }: { onClose: () => void }) => ( -
- -
- ), + default: (props: OAuthClientSettingsProps) => { + mockOAuthClientSettingsProps.push(props) + const { + open = true, + onClose, + onOpenChange, + schemas, + } = props + + if (!open) + return null + + const handleClose = () => { + onOpenChange?.(false) + onClose?.() + } + + return ( +
+ + {schemas.map(schema => ( +
+
+ {React.isValidElement(schema.label) ? schema.label : String(schema.label || '')} +
+ {String(schema.default || '')} +
+ ))} +
+ ) + }, })) vi.mock('@/app/components/base/form/types', () => ({ @@ -56,6 +86,11 @@ describe('AddOAuthButton', () => { beforeEach(async () => { vi.clearAllMocks() + mockOAuthClientSettingsProps.length = 0 + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: mockWriteText }, + }) const mod = await import('../add-oauth-button') AddOAuthButton = mod.default }) @@ -72,6 +107,7 @@ describe('AddOAuthButton', () => { fireEvent.click(screen.getByTestId('oauth-settings-button')) expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument() + expect(mockOAuthClientSettingsProps.at(-1)?.open).toBe(true) }) it('should close OAuth settings modal', () => { @@ -84,13 +120,37 @@ describe('AddOAuthButton', () => { }) it('should trigger OAuth flow on main button click', async () => { + const mockOnUpdate = vi.fn() + render() + + const button = screen.getByText('Use OAuth').closest('button') + if (button) + fireEvent.click(button) + + await waitFor(() => { + expect(mockOpenOAuthPopup).toHaveBeenCalledWith('https://auth.example.com', expect.any(Function)) + }) + + const handleOAuthSuccess = mockOpenOAuthPopup.mock.calls[0]?.[1] + expect(handleOAuthSuccess).toBeTypeOf('function') + if (typeof handleOAuthSuccess === 'function') + handleOAuthSuccess() + + expect(mockOnUpdate).toHaveBeenCalled() + }) + + it('should not open OAuth popup when authorization URL is missing', async () => { + mockGetPluginOAuthUrl.mockResolvedValueOnce({}) render() const button = screen.getByText('Use OAuth').closest('button') if (button) fireEvent.click(button) - expect(mockGetPluginOAuthUrl).toHaveBeenCalled() + await waitFor(() => { + expect(mockGetPluginOAuthUrl).toHaveBeenCalled() + }) + expect(mockOpenOAuthPopup).not.toHaveBeenCalled() }) it('should be disabled when disabled prop is true', () => { @@ -99,4 +159,96 @@ describe('AddOAuthButton', () => { const button = screen.getByText('Use OAuth').closest('button') expect(button).toBeDisabled() }) + + it('should open OAuth settings from setup entry when OAuth is not configured', () => { + render( + , + ) + + fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) + + expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument() + expect(mockOAuthClientSettingsProps.at(-1)?.editValues).toMatchObject({ + __oauth_client__: 'custom', + }) + }) + + it('should show custom badge when OAuth custom client is enabled', () => { + render( + , + ) + + expect(screen.getByText('plugin.auth.custom')).toBeInTheDocument() + }) + + it('should build custom OAuth schema and edit values for settings modal', () => { + const schema = [ + { + name: 'client_id', + label: { en_US: 'Client ID' }, + type: 'text-input', + required: true, + default: 'schema-client-id', + }, + ] as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByTestId('oauth-settings-button')) + + const settingsProps = mockOAuthClientSettingsProps.at(-1) + expect(settingsProps?.editValues).toMatchObject({ + __oauth_client__: 'custom', + client_id: 'stored-client-id', + }) + expect(settingsProps?.hasOriginalClientParams).toBe(true) + expect(settingsProps?.schemas[0]).toMatchObject({ + name: '__oauth_client__', + default: 'custom', + }) + expect(settingsProps?.schemas[1]).toMatchObject({ + name: 'client_id', + default: 'stored-client-id', + show_on: [ + { + variable: '__oauth_client__', + value: 'custom', + }, + ], + }) + expect(screen.getByText('https://redirect.example.com')).toBeInTheDocument() + + fireEvent.click(within(screen.getByTestId('oauth-schema-label-client_id')).getByRole('button')) + + expect(mockWriteText).toHaveBeenCalledWith('https://redirect.example.com') + }) }) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx index 41f1aa3718..ad99f7ce8c 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx @@ -1,5 +1,6 @@ import type { ApiKeyModalProps } from '../api-key-modal' import type { FormSchema } from '@/app/components/base/form/types' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -384,6 +385,29 @@ describe('ApiKeyModal', () => { expect(mockOnClose).toHaveBeenCalled() }) + it('should close on backdrop click when nested inside another dialog', async () => { + const mockOnClose = vi.fn() + render( + + + + + , + ) + + const backdrop = document.querySelector('.bg-background-overlay') + expect(backdrop).toBeInTheDocument() + + fireEvent.pointerDown(backdrop!) + fireEvent.mouseDown(backdrop!) + fireEvent.click(backdrop!) + + await waitFor(() => { + expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') + }) + expect(mockOnClose).toHaveBeenCalled() + }) + it('should render readme entrance when detail is provided', () => { const payload = { ...basePayload, detail: { name: 'Test' } as never } render() diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx index 2c86820202..d99e3514db 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx @@ -1,4 +1,8 @@ +import type { OAuthClientSettingsProps } from '../oauth-client-settings' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthCategory } from '../../types' @@ -20,7 +24,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({}) const mockDeletePluginOAuthCustomClient = vi.fn().mockResolvedValue({}) const mockInvalidPluginOAuthClientSchema = vi.fn() -const mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } } +let mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } } +let mockAuthFormProps: Record | undefined vi.mock('../../hooks/use-credential', () => ({ useSetPluginOAuthCustomClientHook: () => ({ @@ -40,36 +45,19 @@ vi.mock('../../../readme-panel/store', () => ({ ReadmeShowType: { modal: 'modal' }, })) -vi.mock('@/app/components/base/modal/modal', () => ({ - default: ({ children, title, onClose: _onClose, onConfirm, onCancel, onExtraButtonClick, footerSlot }: { - children: React.ReactNode - title: string - onClose?: () => void - onConfirm?: () => void - onCancel?: () => void - onExtraButtonClick?: () => void - footerSlot?: React.ReactNode - [key: string]: unknown - }) => ( -
-
{title}
- {children} - - - - {!!footerSlot &&
{footerSlot}
} -
- ), -})) - -vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ - default: React.forwardRef((_props: Record, ref: React.Ref) => { +vi.mock('@/app/components/base/form/form-scenarios/auth', () => { + const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref } & Record) => { + mockAuthFormProps = props React.useImperativeHandle(ref, () => ({ getFormValues: () => mockFormValues, })) return
- }), -})) + } + + return { + default: MockAuthForm, + } +}) vi.mock('@tanstack/react-form', () => ({ useForm: (config: Record) => ({ @@ -89,11 +77,72 @@ const defaultSchemas = [ { name: 'client_id', label: 'Client ID', type: 'text-input', required: true }, ] as never +const PopoverSettingsHarness = ({ + OAuthClientSettings, + onClose, + onPopoverClose, +}: { + OAuthClientSettings: React.FC + onClose: () => void + onPopoverClose: () => void +}) => { + const [open, setOpen] = React.useState(true) + + return ( + { + setOpen(nextOpen) + if (!nextOpen) + onPopoverClose() + }} + > + OAuth} /> + +
+ +
+
+
+ ) +} + +const ControlledSettingsHarness = ({ + OAuthClientSettings, + onClose, +}: { + OAuthClientSettings: React.FC + onClose: () => void +}) => { + const [open, setOpen] = React.useState(true) + + return ( + <> +
{String(open)}
+ + + ) +} + describe('OAuthClientSettings', () => { - let OAuthClientSettings: (typeof import('../oauth-client-settings'))['default'] + let OAuthClientSettings: React.FC beforeEach(async () => { vi.clearAllMocks() + mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } } + mockAuthFormProps = undefined const mod = await import('../oauth-client-settings') OAuthClientSettings = mod.default }) @@ -120,6 +169,36 @@ describe('OAuthClientSettings', () => { expect(screen.getByTestId('auth-form')).toBeInTheDocument() }) + it('should render backdrop when nested inside another dialog', () => { + render( + + + + + , + ) + + expect(document.querySelector('.bg-background-overlay')).toBeInTheDocument() + }) + + it('should pass schema defaults to auth form', () => { + render( + , + ) + + expect(mockAuthFormProps?.defaultValues).toMatchObject({ + client_id: 'default-client-id', + }) + }) + it('should call onClose when cancel clicked', () => { const mockOnClose = vi.fn() render( @@ -134,6 +213,33 @@ describe('OAuthClientSettings', () => { expect(mockOnClose).toHaveBeenCalled() }) + it('should close through controlled open state when cancel clicked', async () => { + const mockOnClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId('modal-close')) + + await waitFor(() => { + expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') + }) + expect(mockOnClose).toHaveBeenCalled() + }) + + it('should close when backdrop is clicked', async () => { + const mockOnClose = vi.fn() + render() + + const backdrop = document.querySelector('.bg-background-overlay') + expect(backdrop).toBeInTheDocument() + + fireEvent.click(backdrop!) + + await waitFor(() => { + expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') + }) + expect(mockOnClose).toHaveBeenCalled() + }) + it('should save settings on save only button click', async () => { const mockOnClose = vi.fn() const mockOnUpdate = vi.fn() @@ -155,6 +261,38 @@ describe('OAuthClientSettings', () => { }) }) + it('should ignore duplicate save clicks while action is pending', async () => { + const mockOnClose = vi.fn() + let resolveSave: (value: object) => void = () => {} + mockSetPluginOAuthCustomClient.mockImplementationOnce(() => new Promise((resolve) => { + resolveSave = resolve + })) + + render( + , + ) + + fireEvent.click(screen.getByTestId('modal-cancel')) + + await waitFor(() => { + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1) + }) + + fireEvent.click(screen.getByTestId('modal-cancel')) + + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1) + + resolveSave({}) + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled() + }) + }) + it('should save and authorize on confirm button click', async () => { const mockOnAuth = vi.fn().mockResolvedValue(undefined) render( @@ -172,6 +310,34 @@ describe('OAuthClientSettings', () => { }) }) + it('should remove custom client settings', async () => { + const mockOnClose = vi.fn() + const mockOnUpdate = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('modal-extra')) + + await waitFor(() => { + expect(mockDeletePluginOAuthCustomClient).toHaveBeenCalled() + }) + expect(mockOnClose).toHaveBeenCalled() + expect(mockOnUpdate).toHaveBeenCalled() + expect(mockInvalidPluginOAuthClientSchema).toHaveBeenCalled() + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + message: 'common.api.actionSuccess', + type: 'success', + })) + }) + it('should render readme entrance when detail is provided', () => { const payload = { ...basePayload, detail: { name: 'Test' } as never } render( @@ -183,4 +349,26 @@ describe('OAuthClientSettings', () => { expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() }) + + it('should stay open when clicking inside the modal from a popover', async () => { + const user = userEvent.setup() + const mockOnClose = vi.fn() + const mockOnPopoverClose = vi.fn() + + render( + , + ) + + const form = await screen.findByTestId('auth-form') + + await user.click(form) + + expect(mockOnClose).not.toHaveBeenCalled() + expect(mockOnPopoverClose).not.toHaveBeenCalled() + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) }) diff --git a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx index 44b48db7a2..41ef893db8 100644 --- a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx @@ -3,11 +3,6 @@ import type { PluginPayload } from '../types' import type { FormSchema } from '@/app/components/base/form/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiClipboardLine, - RiEqualizer2Line, - RiInformation2Fill, -} from '@remixicon/react' import { memo, useCallback, @@ -40,10 +35,12 @@ export type AddOAuthButtonProps = { schema?: FormSchema[] is_oauth_custom_client_enabled?: boolean is_system_oauth_params_exists?: boolean - client_params?: Record + client_params?: Record redirect_uri?: string } } +type OAuthData = NonNullable + const AddOAuthButton = ({ pluginPayload, buttonVariant = 'primary', @@ -59,22 +56,27 @@ const AddOAuthButton = ({ const { t } = useTranslation() const renderI18nObject = useRenderI18nObject() const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false) + const [isOAuthSettingsMounted, setIsOAuthSettingsMounted] = useState(false) const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload) const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload) - const mergedOAuthData = useMemo(() => { + const mergedOAuthData = useMemo(() => { if (oAuthData) return oAuthData - return data + return data || {} }, [oAuthData, data]) const { schema = [], is_oauth_custom_client_enabled, is_system_oauth_params_exists, - client_params, + client_params = {}, redirect_uri, - } = mergedOAuthData as any || {} + } = mergedOAuthData const isConfigured = is_system_oauth_params_exists || is_oauth_custom_client_enabled + const openOAuthSettings = useCallback(() => { + setIsOAuthSettingsMounted(true) + setIsOAuthSettingsOpen(true) + }, []) const handleOAuth = useCallback(async () => { const { authorization_url } = await getPluginOAuthUrl() @@ -91,7 +93,7 @@ const AddOAuthButton = ({
- +
@@ -107,7 +109,7 @@ const AddOAuthButton = ({ navigator.clipboard.writeText(redirect_uri || '') }} > - +
) @@ -232,10 +234,10 @@ const AddOAuthButton = ({ )} onClick={(e) => { e.stopPropagation() - setIsOAuthSettingsOpen(true) + openOAuthSettings() }} > - +
) @@ -244,18 +246,20 @@ const AddOAuthButton = ({ !isConfigured && ( ) } { - isOAuthSettingsOpen && ( + isOAuthSettingsMounted && ( setIsOAuthSettingsOpen(false)} disabled={disabled || isLoading} diff --git a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx index 290621141c..e01886ccde 100644 --- a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx @@ -140,7 +140,10 @@ const ApiKeyModal = ({ open={open} onOpenChange={handleOpenChange} > - +
diff --git a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx index f52b76866a..d06ecf1d60 100644 --- a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx @@ -4,6 +4,7 @@ import type { FormSchema, } from '@/app/components/base/form/types' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useForm, @@ -17,7 +18,6 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import AuthForm from '@/app/components/base/form/form-scenarios/auth' -import Modal from '@/app/components/base/modal/modal' import { ReadmeEntrance } from '../../readme-panel/entrance' import { ReadmeShowType } from '../../readme-panel/store' import { @@ -26,10 +26,12 @@ import { useSetPluginOAuthCustomClientHook, } from '../hooks/use-credential' -type OAuthClientSettingsProps = { +export type OAuthClientSettingsProps = { pluginPayload: PluginPayload + open?: boolean + onOpenChange?: (open: boolean) => void onClose?: () => void - editValues?: Record + editValues?: Record disabled?: boolean schemas: FormSchema[] onAuth?: () => Promise @@ -38,6 +40,8 @@ type OAuthClientSettingsProps = { } const OAuthClientSettings = ({ pluginPayload, + open = true, + onOpenChange, onClose, editValues, disabled, @@ -53,11 +57,16 @@ const OAuthClientSettings = ({ doingActionRef.current = value setDoingAction(value) }, []) + const handleOpenChange = useCallback((nextOpen: boolean) => { + onOpenChange?.(nextOpen) + if (!nextOpen) + onClose?.() + }, [onClose, onOpenChange]) const defaultValues = schemas.reduce((acc, schema) => { if (schema.default) acc[schema.name] = schema.default return acc - }, {} as Record) + }, {} as Record) const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload) const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload) const formRef = useRef(null) @@ -87,6 +96,7 @@ const OAuthClientSettings = ({ }) toast.success(t('api.actionSuccess', { ns: 'common' })) + onOpenChange?.(false) onClose?.() onUpdate?.() invalidPluginOAuthClientSchema() @@ -94,7 +104,7 @@ const OAuthClientSettings = ({ finally { handleSetDoingAction(false) } - }, [onClose, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction]) + }, [onClose, onOpenChange, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction]) const handleConfirmAndAuthorize = useCallback(async () => { await handleConfirm() @@ -110,6 +120,7 @@ const OAuthClientSettings = ({ handleSetDoingAction(true) await deletePluginOAuthCustomClient() toast.success(t('api.actionSuccess', { ns: 'common' })) + onOpenChange?.(false) onClose?.() onUpdate?.() invalidPluginOAuthClientSchema() @@ -117,53 +128,89 @@ const OAuthClientSettings = ({ finally { handleSetDoingAction(false) } - }, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose]) + }, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose, onOpenChange]) const form = useForm({ defaultValues: editValues || defaultValues, }) const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__) + const isDisabled = disabled || doingAction + return ( - - -
- ) - } - containerClassName="pt-0" - wrapperClassName="z-1002!" - clickOutsideNotClose={true} + - {pluginPayload.detail && ( - - )} - - + +
+
+ + {t('auth.oauthClientSettings', { ns: 'plugin' })} + + +
+
+ {pluginPayload.detail && ( + + )} + +
+
+
+ {__oauth_client__ === 'custom' && hasOriginalClientParams && ( + + )} +
+
+ +
+ + +
+
+
+
+
) }