diff --git a/packages/twenty-front/public/images/integrations/integration-mcp-cover-dark.svg b/packages/twenty-front/public/images/integrations/integration-mcp-cover-dark.svg new file mode 100644 index 000000000..4a9491220 --- /dev/null +++ b/packages/twenty-front/public/images/integrations/integration-mcp-cover-dark.svg @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/packages/twenty-front/public/images/integrations/integration-mcp-cover-light.svg b/packages/twenty-front/public/images/integrations/integration-mcp-cover-light.svg new file mode 100644 index 000000000..96d553552 --- /dev/null +++ b/packages/twenty-front/public/images/integrations/integration-mcp-cover-light.svg @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index b4e996ffd..b69b827b8 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -222,6 +222,14 @@ const SettingsIntegrationDatabase = lazy(() => ), ); +const SettingsIntegrationMCP = lazy(() => + import('~/pages/settings/integrations/SettingsIntegrationMCPPage').then( + (module) => ({ + default: module.SettingsIntegrationMCPPage, + }), + ), +); + const SettingsIntegrationNewDatabaseConnection = lazy(() => import( '~/pages/settings/integrations/SettingsIntegrationNewDatabaseConnection' @@ -510,6 +518,10 @@ export const SettingsRoutes = ({ path={SettingsPath.IntegrationDatabaseConnection} element={} /> + } + /> {isFunctionSettingsEnabled && ( <> diff --git a/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx b/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx index 73b7c97cc..e44de0615 100644 --- a/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx +++ b/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx @@ -1,44 +1,51 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; -import * as ReactRouterDom from 'react-router-dom'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; +import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; +import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath'; +import { captchaState } from '@/client-config/states/captchaState'; +import { CaptchaDriverType } from '~/generated-metadata/graphql'; -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: jest.fn(), -})); +jest.mock('@/captcha/utils/isCaptchaRequiredForPath'); describe('useRequestFreshCaptchaToken', () => { + const mockGrecaptchaExecute = jest.fn(); + const mockTurnstileRender = jest.fn(); + const mockTurnstileExecute = jest.fn(); + beforeEach(() => { - // Mock useLocation to return a path that requires captcha - (ReactRouterDom.useLocation as jest.Mock).mockReturnValue({ - pathname: '/sign-in', - }); - - // Mock window.grecaptcha - window.grecaptcha = { - execute: jest.fn().mockImplementation(() => { - return Promise.resolve('google-recaptcha-token'); - }), - }; - - // Mock window.turnstile - window.turnstile = { - render: jest.fn().mockReturnValue('turnstile-widget-id'), - execute: jest.fn().mockImplementation((widgetId, options) => { - return options?.callback('turnstile-token'); - }), - }; - }); - - afterEach(() => { jest.clearAllMocks(); - delete window.grecaptcha; - delete window.turnstile; + + window.grecaptcha = { + execute: mockGrecaptchaExecute, + }; + + window.turnstile = { + render: mockTurnstileRender, + execute: mockTurnstileExecute, + }; + + (isCaptchaRequiredForPath as jest.Mock).mockReturnValue(true); + + mockGrecaptchaExecute.mockImplementation((_siteKey, _options) => { + return Promise.resolve('google-recaptcha-token'); + }); + + mockTurnstileRender.mockImplementation((_selector, _options) => { + return 'turnstile-widget-id'; + }); + + mockTurnstileExecute.mockImplementation((widgetId, options) => { + if (options !== undefined && typeof options.callback === 'function') { + options.callback('turnstile-token'); + } + }); }); - it('should not request a token if captcha is not required for the path', async () => { + it('should not request a token if captcha is not required for the current path', async () => { + (isCaptchaRequiredForPath as jest.Mock).mockReturnValue(false); + const { result } = renderHook(() => useRequestFreshCaptchaToken(), { wrapper: RecoilRoot, }); @@ -47,11 +54,12 @@ describe('useRequestFreshCaptchaToken', () => { await result.current.requestFreshCaptchaToken(); }); - expect(window.grecaptcha.execute).not.toHaveBeenCalled(); - expect(window.turnstile.execute).not.toHaveBeenCalled(); + expect(mockGrecaptchaExecute).not.toHaveBeenCalled(); + expect(mockTurnstileRender).not.toHaveBeenCalled(); + expect(mockTurnstileExecute).not.toHaveBeenCalled(); }); - it('should not request a token if captcha provider is not defined', async () => { + it('should not request a token if captcha provider is undefined', async () => { const { result } = renderHook(() => useRequestFreshCaptchaToken(), { wrapper: RecoilRoot, }); @@ -60,7 +68,67 @@ describe('useRequestFreshCaptchaToken', () => { await result.current.requestFreshCaptchaToken(); }); - expect(window.grecaptcha.execute).not.toHaveBeenCalled(); - expect(window.turnstile.execute).not.toHaveBeenCalled(); + expect(mockGrecaptchaExecute).not.toHaveBeenCalled(); + expect(mockTurnstileRender).not.toHaveBeenCalled(); + expect(mockTurnstileExecute).not.toHaveBeenCalled(); + }); + + it('should request a token from Google reCAPTCHA when provider is GOOGLE_RECAPTCHA', async () => { + const { result } = renderHook(() => useRequestFreshCaptchaToken(), { + wrapper: ({ children }) => ( + { + set(captchaState, { + provider: CaptchaDriverType.GOOGLE_RECAPTCHA, + siteKey: 'google-site-key', + }); + set(isRequestingCaptchaTokenState, false); + }} + > + {children} + + ), + }); + + await act(async () => { + await result.current.requestFreshCaptchaToken(); + }); + + expect(mockGrecaptchaExecute).toHaveBeenCalledWith('google-site-key', { + action: 'submit', + }); + + await waitFor(() => { + expect(mockGrecaptchaExecute).toHaveBeenCalled(); + }); + }); + + it('should request a token from Turnstile when provider is TURNSTILE', async () => { + const { result } = renderHook(() => useRequestFreshCaptchaToken(), { + wrapper: ({ children }) => ( + { + set(captchaState, { + provider: CaptchaDriverType.TURNSTILE, + siteKey: 'turnstile-site-key', + }); + set(isRequestingCaptchaTokenState, false); + }} + > + {children} + + ), + }); + + await act(async () => { + await result.current.requestFreshCaptchaToken(); + }); + + expect(mockTurnstileRender).toHaveBeenCalledWith('#captcha-widget', { + sitekey: 'turnstile-site-key', + }); + expect(mockTurnstileExecute).toHaveBeenCalledWith('turnstile-widget-id', { + callback: expect.any(Function), + }); }); }); diff --git a/packages/twenty-front/src/modules/settings/integrations/components/SettingsIntegrationMCP.tsx b/packages/twenty-front/src/modules/settings/integrations/components/SettingsIntegrationMCP.tsx new file mode 100644 index 000000000..bd0dc4d2e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/integrations/components/SettingsIntegrationMCP.tsx @@ -0,0 +1,169 @@ +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { Select } from '@/ui/input/components/Select'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { IconCopy, IconDatabase, IconSitemap } from 'twenty-ui/display'; +import { Button, CodeEditor } from 'twenty-ui/input'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; + +const StyledWrapper = styled.div` + background-color: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.md}; +`; + +const StyledImage = styled.img` + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + height: 100%; + object-fit: cover; + width: 100%; +`; + +const StyledSchemaSelector = styled.div` + align-items: center; + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + display: flex; + flex-direction: row; + justify-content: space-between; + padding: ${({ theme }) => theme.spacing(3)}; +`; + +const StyledLabel = styled.span` + color: ${({ theme }) => theme.font.color.light}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; +`; + +const StyledCopyButton = styled.div` + position: absolute; + top: ${({ theme }) => theme.spacing(3)}; + right: ${({ theme }) => theme.spacing(3)}; + z-index: 1; +`; + +const StyledEditorContainer = styled.div` + .monaco-editor, + .monaco-editor .overflow-guard { + background-color: transparent !important; + border: none !important; + } + + .monaco-editor .line-hover { + background-color: transparent !important; + } +`; + +export const SettingsIntegrationMCP = () => { + const theme = useTheme(); + const { enqueueSuccessSnackBar } = useSnackBar(); + const { t } = useLingui(); + + const generateMcpContent = (pathSuffix: string, serverName: string) => { + return JSON.stringify( + { + mcpServers: { + [serverName]: { + type: 'remote', + url: `${REACT_APP_SERVER_BASE_URL}${pathSuffix}`, + headers: { + Authorization: 'Bearer [API_KEY]', + }, + }, + }, + }, + null, + 2, + ); + }; + + const options = [ + { + label: 'Core Schema', + value: 'core-schema', + Icon: IconDatabase, + content: generateMcpContent('/mcp', 'twenty'), + }, + { + label: 'Metadata Schema', + value: 'metadata-schema', + Icon: IconSitemap, + content: generateMcpContent('/mcp/metadata', 'twenty-metadata'), + }, + ]; + const [selectedSchemaValue, setSelectedSchemaValue] = useState( + options[0].value, + ); + + const selectedOption = + options.find((option) => option.value === selectedSchemaValue) || + options[0]; + + const onChange = (value: string) => { + setSelectedSchemaValue(value); + }; + + return ( + + + +