diff --git a/packages/twenty-front/src/loading/components/__stories__/UserOrMetadataLoader.stories.tsx b/packages/twenty-front/src/loading/components/__stories__/UserOrMetadataLoader.stories.tsx index 1ea2d67f4..23af939ad 100644 --- a/packages/twenty-front/src/loading/components/__stories__/UserOrMetadataLoader.stories.tsx +++ b/packages/twenty-front/src/loading/components/__stories__/UserOrMetadataLoader.stories.tsx @@ -2,12 +2,13 @@ import { getOperationName } from '@apollo/client/utilities'; import { expect } from '@storybook/jest'; import { Meta, StoryObj } from '@storybook/react'; import { within } from '@storybook/test'; -import { HttpResponse, graphql } from 'msw'; +import { HttpResponse, graphql, http } from 'msw'; import { GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataByDomain'; import { GET_CLIENT_CONFIG } from '@/client-config/graphql/queries/getClientConfig'; import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries'; import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { RecordIndexPage } from '~/pages/object-record/RecordIndexPage'; import { PageDecorator, @@ -21,6 +22,9 @@ import { mockedUserData } from '~/testing/mock-data/users'; const userMetadataLoaderMocks = { msw: { handlers: [ + http.get(`${REACT_APP_SERVER_BASE_URL}/client-config`, () => { + return HttpResponse.json(mockedClientConfig); + }), graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => { return HttpResponse.json({ data: { diff --git a/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts b/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts index 2261b584e..ecc8c9aa1 100644 --- a/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts +++ b/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts @@ -1,6 +1,5 @@ import { downloadFile } from '../downloadFile'; -// Mock fetch global.fetch = jest.fn(() => Promise.resolve({ status: 200, @@ -16,13 +15,10 @@ window.URL.revokeObjectURL = jest.fn(); // `global.fetch` and `window.fetch` are also undefined describe.skip('downloadFile', () => { it('should download a file', () => { - // Call downloadFile downloadFile('url/to/file.pdf', 'file.pdf'); - // Assert on fetch expect(fetch).toHaveBeenCalledWith('url/to/file.pdf'); - // Assert on element creation const link = document.querySelector( 'a[href="mock-url"][download="file.pdf"]', ); @@ -32,10 +28,8 @@ describe.skip('downloadFile', () => { // @ts-ignore expect(link?.style?.display).toBe('none'); - // Assert on element click expect(link).toHaveBeenCalledTimes(1); - // Clean up mocks jest.clearAllMocks(); }); }); diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx index b0a7346cf..774bb2361 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx @@ -2,26 +2,11 @@ import { useRecoilValue } from 'recoil'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback'; -import { AppPath } from '@/types/AppPath'; -import { useLocation } from 'react-router-dom'; -import { isMatchingLocation } from '~/utils/isMatchingLocation'; export const ClientConfigProvider: React.FC = ({ children, }) => { - const { isLoaded, isErrored, error } = useRecoilValue( - clientConfigApiStatusState, - ); - - const location = useLocation(); - - // TODO: Implement a better loading strategy - if ( - !isLoaded && - !isMatchingLocation(location, AppPath.Verify) && - !isMatchingLocation(location, AppPath.VerifyEmail) - ) - return null; + const { isErrored, error } = useRecoilValue(clientConfigApiStatusState); return isErrored && error instanceof Error ? ( { const setIsDebugMode = useSetRecoilState(isDebugModeState); @@ -87,16 +87,16 @@ export const ClientConfigProviderEffect = () => { isConfigVariablesInDbEnabledState, ); - const { data, loading, error } = useGetClientConfigQuery({ - skip: clientConfigApiStatus.isLoaded, - }); + const { data, loading, error, fetchClientConfig } = useClientConfig(); + + useEffect(() => { + if (!clientConfigApiStatus.isLoaded) { + fetchClientConfig(); + } + }, [clientConfigApiStatus.isLoaded, fetchClientConfig]); useEffect(() => { if (loading) return; - setClientConfigApiStatus((currentStatus) => ({ - ...currentStatus, - isLoaded: true, - })); if (error instanceof Error) { setClientConfigApiStatus((currentStatus) => ({ @@ -165,6 +165,10 @@ export const ClientConfigProviderEffect = () => { setIsConfigVariablesInDbEnabled( data?.clientConfig?.isConfigVariablesInDbEnabled, ); + setClientConfigApiStatus((currentStatus) => ({ + ...currentStatus, + isLoaded: true, + })); }, [ data, setIsDebugMode, diff --git a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts new file mode 100644 index 000000000..92e94cf98 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts @@ -0,0 +1,58 @@ +import { useRecoilCallback, useRecoilValue } from 'recoil'; +import { ClientConfig } from '~/generated/graphql'; +import { clientConfigApiStatusState } from '../states/clientConfigApiStatusState'; +import { getClientConfig } from '../utils/getClientConfig'; + +type UseClientConfigResult = { + data: { clientConfig: ClientConfig } | undefined; + loading: boolean; + error: Error | undefined; + fetchClientConfig: () => Promise; + refetch: () => Promise; +}; + +export const useClientConfig = (): UseClientConfigResult => { + const clientConfigApiStatus = useRecoilValue(clientConfigApiStatusState); + + const fetchClientConfig = useRecoilCallback( + ({ set }) => + async () => { + set(clientConfigApiStatusState, (prev) => ({ + ...prev, + isLoading: true, + isErrored: false, + error: undefined, + })); + + try { + const clientConfig = await getClientConfig(); + set(clientConfigApiStatusState, (prev) => ({ + ...prev, + isLoading: false, + isLoaded: true, + data: { clientConfig }, + })); + } catch (err) { + const error = + err instanceof Error + ? err + : new Error('Failed to fetch client config'); + set(clientConfigApiStatusState, (prev) => ({ + ...prev, + isLoading: false, + isErrored: true, + error, + })); + } + }, + [], + ); + + return { + data: clientConfigApiStatus.data, + loading: clientConfigApiStatus.isLoading || false, + error: clientConfigApiStatus.error, + fetchClientConfig, + refetch: fetchClientConfig, + }; +}; diff --git a/packages/twenty-front/src/modules/client-config/states/clientConfigApiStatusState.ts b/packages/twenty-front/src/modules/client-config/states/clientConfigApiStatusState.ts index 4e2c053f6..0f25510f0 100644 --- a/packages/twenty-front/src/modules/client-config/states/clientConfigApiStatusState.ts +++ b/packages/twenty-front/src/modules/client-config/states/clientConfigApiStatusState.ts @@ -1,11 +1,21 @@ import { createState } from 'twenty-ui/utilities'; +import { ClientConfig } from '~/generated/graphql'; + type ClientConfigApiStatus = { isLoaded: boolean; + isLoading: boolean; isErrored: boolean; error?: Error; + data?: { clientConfig: ClientConfig }; }; export const clientConfigApiStatusState = createState({ key: 'clientConfigApiStatus', - defaultValue: { isLoaded: false, isErrored: false, error: undefined }, + defaultValue: { + isLoaded: false, + isLoading: false, + isErrored: false, + error: undefined, + data: undefined, + }, }); diff --git a/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts b/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts new file mode 100644 index 000000000..edbd3ae71 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts @@ -0,0 +1,95 @@ +import { REACT_APP_SERVER_BASE_URL } from '~/config'; +import { getClientConfig } from '../getClientConfig'; + +global.fetch = jest.fn(); + +const mockClientConfig = { + billing: { + isBillingEnabled: true, + billingUrl: 'https://billing.example.com', + trialPeriods: [], + }, + authProviders: { + google: true, + magicLink: false, + password: true, + microsoft: false, + sso: [], + }, + signInPrefilled: false, + isMultiWorkspaceEnabled: true, + isEmailVerificationRequired: false, + defaultSubdomain: 'app', + frontDomain: 'localhost', + debugMode: true, + support: { + supportDriver: 'none', + supportFrontChatId: undefined, + }, + sentry: { + environment: 'development', + release: '1.0.0', + dsn: undefined, + }, + captcha: { + provider: undefined, + siteKey: undefined, + }, + chromeExtensionId: undefined, + api: { + mutationMaximumAffectedRecords: 100, + }, + isAttachmentPreviewEnabled: true, + analyticsEnabled: false, + canManageFeatureFlags: true, + publicFeatureFlags: [], + isMicrosoftMessagingEnabled: false, + isMicrosoftCalendarEnabled: false, + isGoogleMessagingEnabled: false, + isGoogleCalendarEnabled: false, + isConfigVariablesInDbEnabled: false, +}; + +describe('getClientConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch client config from API', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockClientConfig, + }); + + const result = await getClientConfig(); + + expect(fetch).toHaveBeenCalledWith( + `${REACT_APP_SERVER_BASE_URL}/client-config`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + expect(result).toEqual(mockClientConfig); + }); + + it('should handle fetch errors', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + await expect(getClientConfig()).rejects.toThrow( + 'Failed to fetch client config: 500 Internal Server Error', + ); + }); + + it('should handle network errors', async () => { + (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + await expect(getClientConfig()).rejects.toThrow('Network error'); + }); +}); diff --git a/packages/twenty-front/src/modules/client-config/utils/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/utils/getClientConfig.ts new file mode 100644 index 000000000..c061dee74 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/utils/getClientConfig.ts @@ -0,0 +1,21 @@ +import { REACT_APP_SERVER_BASE_URL } from '~/config'; +import { ClientConfig } from '~/generated/graphql'; + +export const getClientConfig = async (): Promise => { + const response = await fetch(`${REACT_APP_SERVER_BASE_URL}/client-config`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch client config: ${response.status} ${response.statusText}`, + ); + } + + const clientConfig: ClientConfig = await response.json(); + + return clientConfig; +}; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataByDomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataByDomain.ts index b543d3ebe..57c4f0f88 100644 --- a/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataByDomain.ts +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataByDomain.ts @@ -1,12 +1,13 @@ import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; +import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain'; import { useOrigin } from '@/domain-manager/hooks/useOrigin'; import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectToDefaultDomain'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { useRecoilValue, useSetRecoilState } from 'recoil'; -import { useGetPublicWorkspaceDataByDomainQuery } from '~/generated/graphql'; import { isDefined } from 'twenty-shared/utils'; +import { useGetPublicWorkspaceDataByDomainQuery } from '~/generated/graphql'; export const useGetPublicWorkspaceDataByDomain = () => { const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain(); @@ -20,12 +21,14 @@ export const useGetPublicWorkspaceDataByDomain = () => { const setWorkspacePublicDataState = useSetRecoilState( workspacePublicDataState, ); + const clientConfigApiStatus = useRecoilValue(clientConfigApiStatusState); const { loading, data, error } = useGetPublicWorkspaceDataByDomainQuery({ variables: { origin, }, skip: + !clientConfigApiStatus.isLoaded || (isMultiWorkspaceEnabled && isDefaultDomain) || isDefined(workspacePublicData), onCompleted: (data) => { diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useReadDefaultDomainFromConfiguration.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useReadDefaultDomainFromConfiguration.ts index f280213e9..7b46360c1 100644 --- a/packages/twenty-front/src/modules/domain-manager/hooks/useReadDefaultDomainFromConfiguration.ts +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useReadDefaultDomainFromConfiguration.ts @@ -1,5 +1,5 @@ -import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { useRecoilValue } from 'recoil'; export const useReadDefaultDomainFromConfiguration = () => { diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToDefaultDomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToDefaultDomain.ts index 6fb863033..30000345d 100644 --- a/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToDefaultDomain.ts +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToDefaultDomain.ts @@ -1,6 +1,6 @@ +import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain'; import { useReadDefaultDomainFromConfiguration } from '@/domain-manager/hooks/useReadDefaultDomainFromConfiguration'; import { useRedirect } from '@/domain-manager/hooks/useRedirect'; -import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain'; export const useRedirectToDefaultDomain = () => { const { defaultDomain } = useReadDefaultDomainFromConfiguration(); diff --git a/packages/twenty-front/src/modules/settings/admin-panel/config-variables/hooks/useConfigVariableActions.ts b/packages/twenty-front/src/modules/settings/admin-panel/config-variables/hooks/useConfigVariableActions.ts index bf17bd91e..87e1c92ed 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/config-variables/hooks/useConfigVariableActions.ts +++ b/packages/twenty-front/src/modules/settings/admin-panel/config-variables/hooks/useConfigVariableActions.ts @@ -1,6 +1,6 @@ import { useLingui } from '@lingui/react/macro'; -import { GET_CLIENT_CONFIG } from '@/client-config/graphql/queries/getClientConfig'; +import { useClientConfig } from '@/client-config/hooks/useClientConfig'; import { GET_DATABASE_CONFIG_VARIABLE } from '@/settings/admin-panel/config-variables/graphql/queries/getDatabaseConfigVariable'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -15,6 +15,7 @@ import { export const useConfigVariableActions = (variableName: string) => { const { t } = useLingui(); const { enqueueSnackBar } = useSnackBar(); + const { refetch: refetchClientConfig } = useClientConfig(); const [updateDatabaseConfigVariable] = useUpdateDatabaseConfigVariableMutation(); @@ -48,9 +49,6 @@ export const useConfigVariableActions = (variableName: string) => { query: GET_DATABASE_CONFIG_VARIABLE, variables: { key: variableName }, }, - { - query: GET_CLIENT_CONFIG, - }, ], }); } else { @@ -64,14 +62,13 @@ export const useConfigVariableActions = (variableName: string) => { query: GET_DATABASE_CONFIG_VARIABLE, variables: { key: variableName }, }, - { - query: GET_CLIENT_CONFIG, - }, ], }); } - enqueueSnackBar(t`Variable updated successfully`, { + await refetchClientConfig(); + + enqueueSnackBar(t`Variable updated successfully.`, { variant: SnackBarVariant.Success, }); } catch (error) { @@ -96,13 +93,16 @@ export const useConfigVariableActions = (variableName: string) => { query: GET_DATABASE_CONFIG_VARIABLE, variables: { key: variableName }, }, - { - query: GET_CLIENT_CONFIG, - }, ], }); + + await refetchClientConfig(); + + enqueueSnackBar(t`Variable deleted successfully.`, { + variant: SnackBarVariant.Success, + }); } catch (error) { - enqueueSnackBar(t`Failed to remove override`, { + enqueueSnackBar(t`Failed to remove override`, { variant: SnackBarVariant.Error, }); } diff --git a/packages/twenty-front/src/pages/auth/Authorize.tsx b/packages/twenty-front/src/pages/auth/Authorize.tsx index 1c358a326..8c696a920 100644 --- a/packages/twenty-front/src/pages/auth/Authorize.tsx +++ b/packages/twenty-front/src/pages/auth/Authorize.tsx @@ -5,11 +5,11 @@ import { useSearchParams } from 'react-router-dom'; import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useLingui } from '@lingui/react/macro'; -import { useAuthorizeAppMutation } from '~/generated/graphql'; -import { useNavigateApp } from '~/hooks/useNavigateApp'; import { isDefined } from 'twenty-shared/utils'; import { MainButton } from 'twenty-ui/input'; import { UndecoratedLink } from 'twenty-ui/navigation'; +import { useAuthorizeAppMutation } from '~/generated/graphql'; +import { useNavigateApp } from '~/hooks/useNavigateApp'; type App = { id: string; name: string; logo: string }; diff --git a/packages/twenty-front/src/testing/graphqlMocks.ts b/packages/twenty-front/src/testing/graphqlMocks.ts index 08af596ee..eb672713f 100644 --- a/packages/twenty-front/src/testing/graphqlMocks.ts +++ b/packages/twenty-front/src/testing/graphqlMocks.ts @@ -124,6 +124,9 @@ export const graphqlMocks = { }, }); }), + http.get(`${REACT_APP_SERVER_BASE_URL}/client-config`, () => { + return HttpResponse.json(mockedClientConfig); + }), metadataGraphql.query( getOperationName(FIND_MANY_OBJECT_METADATA_ITEMS) ?? '', () => { diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 585185f13..4df865abd 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -10,7 +10,6 @@ export const mockedClientConfig: ClientConfig = { password: true, microsoft: false, sso: [], - __typename: 'AuthProviders', }, frontDomain: 'localhost', defaultSubdomain: 'app', @@ -20,35 +19,29 @@ export const mockedClientConfig: ClientConfig = { support: { supportDriver: 'front', supportFrontChatId: null, - __typename: 'Support', }, sentry: { dsn: 'MOCKED_DSN', release: 'MOCKED_RELEASE', environment: 'MOCKED_ENVIRONMENT', - __typename: 'Sentry', }, billing: { isBillingEnabled: true, billingUrl: '', trialPeriods: [ { - __typename: 'BillingTrialPeriodDTO', duration: 30, isCreditCardRequired: true, }, { - __typename: 'BillingTrialPeriodDTO', duration: 7, isCreditCardRequired: false, }, ], - __typename: 'Billing', }, captcha: { provider: CaptchaDriverType.GoogleRecaptcha, siteKey: 'MOCKED_SITE_KEY', - __typename: 'Captcha', }, api: { mutationMaximumAffectedRecords: 100 }, canManageFeatureFlags: true, diff --git a/packages/twenty-server/src/engine/core-modules/captcha/interfaces/captcha.interface.ts b/packages/twenty-server/src/engine/core-modules/captcha/interfaces/captcha.interface.ts index f6f61b814..6688c084a 100644 --- a/packages/twenty-server/src/engine/core-modules/captcha/interfaces/captcha.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/captcha/interfaces/captcha.interface.ts @@ -36,5 +36,4 @@ export type CaptchaModuleAsyncOptions = { ) => CaptchaModuleOptions | Promise | undefined; } & Pick & Pick; - export type CaptchaValidateResult = { success: boolean; error?: string }; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts new file mode 100644 index 000000000..4686adf32 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -0,0 +1,96 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; + +import { ClientConfigController } from './client-config.controller'; + +describe('ClientConfigController', () => { + let controller: ClientConfigController; + let clientConfigService: ClientConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ClientConfigController], + providers: [ + { + provide: ClientConfigService, + useValue: { + getClientConfig: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(ClientConfigController); + clientConfigService = module.get(ClientConfigService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getClientConfig', () => { + it('should return client config from service', async () => { + const mockClientConfig = { + billing: { + isBillingEnabled: true, + billingUrl: 'https://billing.example.com', + trialPeriods: [ + { + duration: 7, + isCreditCardRequired: false, + }, + ], + }, + authProviders: { + google: true, + magicLink: false, + password: true, + microsoft: false, + sso: [], + }, + signInPrefilled: false, + isMultiWorkspaceEnabled: true, + isEmailVerificationRequired: false, + defaultSubdomain: 'app', + frontDomain: 'localhost', + debugMode: true, + support: { + supportDriver: 'none', + supportFrontChatId: undefined, + }, + sentry: { + environment: 'development', + release: '1.0.0', + dsn: undefined, + }, + captcha: { + provider: undefined, + siteKey: undefined, + }, + chromeExtensionId: undefined, + api: { + mutationMaximumAffectedRecords: 100, + }, + isAttachmentPreviewEnabled: true, + analyticsEnabled: false, + canManageFeatureFlags: true, + publicFeatureFlags: [], + isMicrosoftMessagingEnabled: false, + isMicrosoftCalendarEnabled: false, + isGoogleMessagingEnabled: false, + isGoogleCalendarEnabled: false, + isConfigVariablesInDbEnabled: false, + }; + + jest + .spyOn(clientConfigService, 'getClientConfig') + .mockResolvedValue(mockClientConfig); + + const result = await controller.getClientConfig(); + + expect(clientConfigService.getClientConfig).toHaveBeenCalled(); + expect(result).toEqual(mockClientConfig); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts new file mode 100644 index 000000000..fa619d512 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common'; + +import { ClientConfig } from 'src/engine/core-modules/client-config/client-config.entity'; +import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; + +@Controller('/client-config') +export class ClientConfigController { + constructor(private readonly clientConfigService: ClientConfigService) {} + + @Get() + async getClientConfig(): Promise { + return this.clientConfigService.getClientConfig(); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.module.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.module.ts index 70f57128f..63f7d0d8b 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.module.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.module.ts @@ -2,10 +2,14 @@ import { Module } from '@nestjs/common'; import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { ClientConfigController } from './client-config.controller'; import { ClientConfigResolver } from './client-config.resolver'; +import { ClientConfigService } from './services/client-config.service'; + @Module({ imports: [DomainManagerModule], - providers: [ClientConfigResolver], + controllers: [ClientConfigController], + providers: [ClientConfigResolver, ClientConfigService], }) export class ClientConfigModule {} diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.spec.ts index e67b141c0..ae1774cac 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.spec.ts @@ -1,7 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; import { ClientConfigResolver } from './client-config.resolver'; @@ -13,12 +12,10 @@ describe('ClientConfigResolver', () => { providers: [ ClientConfigResolver, { - provide: TwentyConfigService, - useValue: {}, - }, - { - provide: DomainManagerService, - useValue: {}, + provide: ClientConfigService, + useValue: { + getClientConfig: jest.fn(), + }, }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts index 91bc864e0..37ab9f7de 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts @@ -1,107 +1,15 @@ import { Query, Resolver } from '@nestjs/graphql'; -import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; - -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; -import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; import { ClientConfig } from './client-config.entity'; @Resolver() export class ClientConfigResolver { - constructor( - private twentyConfigService: TwentyConfigService, - private domainManagerService: DomainManagerService, - ) {} + constructor(private clientConfigService: ClientConfigService) {} @Query(() => ClientConfig) async clientConfig(): Promise { - const clientConfig: ClientConfig = { - billing: { - isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'), - billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'), - trialPeriods: [ - { - duration: this.twentyConfigService.get( - 'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS', - ), - isCreditCardRequired: true, - }, - { - duration: this.twentyConfigService.get( - 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS', - ), - isCreditCardRequired: false, - }, - ], - }, - authProviders: { - google: this.twentyConfigService.get('AUTH_GOOGLE_ENABLED'), - magicLink: false, - password: this.twentyConfigService.get('AUTH_PASSWORD_ENABLED'), - microsoft: this.twentyConfigService.get('AUTH_MICROSOFT_ENABLED'), - sso: [], - }, - signInPrefilled: this.twentyConfigService.get('SIGN_IN_PREFILLED'), - isMultiWorkspaceEnabled: this.twentyConfigService.get( - 'IS_MULTIWORKSPACE_ENABLED', - ), - isEmailVerificationRequired: this.twentyConfigService.get( - 'IS_EMAIL_VERIFICATION_REQUIRED', - ), - defaultSubdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), - frontDomain: this.domainManagerService.getFrontUrl().hostname, - debugMode: - this.twentyConfigService.get('NODE_ENV') === - NodeEnvironment.development, - support: { - supportDriver: this.twentyConfigService.get('SUPPORT_DRIVER'), - supportFrontChatId: this.twentyConfigService.get( - 'SUPPORT_FRONT_CHAT_ID', - ), - }, - sentry: { - environment: this.twentyConfigService.get('SENTRY_ENVIRONMENT'), - release: this.twentyConfigService.get('APP_VERSION'), - dsn: this.twentyConfigService.get('SENTRY_FRONT_DSN'), - }, - captcha: { - provider: this.twentyConfigService.get('CAPTCHA_DRIVER'), - siteKey: this.twentyConfigService.get('CAPTCHA_SITE_KEY'), - }, - chromeExtensionId: this.twentyConfigService.get('CHROME_EXTENSION_ID'), - api: { - mutationMaximumAffectedRecords: this.twentyConfigService.get( - 'MUTATION_MAXIMUM_AFFECTED_RECORDS', - ), - }, - isAttachmentPreviewEnabled: this.twentyConfigService.get( - 'IS_ATTACHMENT_PREVIEW_ENABLED', - ), - analyticsEnabled: this.twentyConfigService.get('ANALYTICS_ENABLED'), - canManageFeatureFlags: - this.twentyConfigService.get('NODE_ENV') === - NodeEnvironment.development || - this.twentyConfigService.get('IS_BILLING_ENABLED'), - publicFeatureFlags: PUBLIC_FEATURE_FLAGS, - isMicrosoftMessagingEnabled: this.twentyConfigService.get( - 'MESSAGING_PROVIDER_MICROSOFT_ENABLED', - ), - isMicrosoftCalendarEnabled: this.twentyConfigService.get( - 'CALENDAR_PROVIDER_MICROSOFT_ENABLED', - ), - isGoogleMessagingEnabled: this.twentyConfigService.get( - 'MESSAGING_PROVIDER_GMAIL_ENABLED', - ), - isGoogleCalendarEnabled: this.twentyConfigService.get( - 'CALENDAR_PROVIDER_GOOGLE_ENABLED', - ), - isConfigVariablesInDbEnabled: this.twentyConfigService.get( - 'IS_CONFIG_VARIABLES_IN_DB_ENABLED', - ), - }; - - return Promise.resolve(clientConfig); + return this.clientConfigService.getClientConfig(); } } diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts new file mode 100644 index 000000000..becdcadf5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -0,0 +1,252 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; +import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface'; + +import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; +import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +describe('ClientConfigService', () => { + let service: ClientConfigService; + let twentyConfigService: TwentyConfigService; + let domainManagerService: DomainManagerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClientConfigService, + { + provide: TwentyConfigService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: DomainManagerService, + useValue: { + getFrontUrl: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ClientConfigService); + twentyConfigService = module.get(TwentyConfigService); + domainManagerService = + module.get(DomainManagerService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getClientConfig', () => { + beforeEach(() => { + // Setup default mock values + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + const mockValues: Record = { + IS_BILLING_ENABLED: true, + BILLING_PLAN_REQUIRED_LINK: 'https://billing.example.com', + BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS: 30, + BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS: 7, + AUTH_GOOGLE_ENABLED: true, + AUTH_PASSWORD_ENABLED: true, + AUTH_MICROSOFT_ENABLED: false, + SIGN_IN_PREFILLED: false, + IS_MULTIWORKSPACE_ENABLED: true, + IS_EMAIL_VERIFICATION_REQUIRED: true, + DEFAULT_SUBDOMAIN: 'app', + NODE_ENV: NodeEnvironment.development, + SUPPORT_DRIVER: SupportDriver.Front, + SUPPORT_FRONT_CHAT_ID: 'chat-123', + SENTRY_ENVIRONMENT: 'development', + APP_VERSION: '1.0.0', + SENTRY_FRONT_DSN: 'https://sentry.example.com', + CAPTCHA_DRIVER: CaptchaDriverType.GoogleRecaptcha, + CAPTCHA_SITE_KEY: 'site-key-123', + CHROME_EXTENSION_ID: 'extension-123', + MUTATION_MAXIMUM_AFFECTED_RECORDS: 1000, + IS_ATTACHMENT_PREVIEW_ENABLED: true, + ANALYTICS_ENABLED: true, + MESSAGING_PROVIDER_MICROSOFT_ENABLED: false, + CALENDAR_PROVIDER_MICROSOFT_ENABLED: false, + MESSAGING_PROVIDER_GMAIL_ENABLED: true, + CALENDAR_PROVIDER_GOOGLE_ENABLED: true, + IS_CONFIG_VARIABLES_IN_DB_ENABLED: false, + }; + + return mockValues[key]; + }); + + jest.spyOn(domainManagerService, 'getFrontUrl').mockReturnValue({ + hostname: 'app.twenty.com', + } as URL); + }); + + it('should return complete client config with all properties', async () => { + const result = await service.getClientConfig(); + + expect(result).toEqual({ + billing: { + isBillingEnabled: true, + billingUrl: 'https://billing.example.com', + trialPeriods: [ + { + duration: 30, + isCreditCardRequired: true, + }, + { + duration: 7, + isCreditCardRequired: false, + }, + ], + }, + authProviders: { + google: true, + magicLink: false, + password: true, + microsoft: false, + sso: [], + }, + signInPrefilled: false, + isMultiWorkspaceEnabled: true, + isEmailVerificationRequired: true, + defaultSubdomain: 'app', + frontDomain: 'app.twenty.com', + debugMode: true, + support: { + supportDriver: 'Front', + supportFrontChatId: 'chat-123', + }, + sentry: { + environment: 'development', + release: '1.0.0', + dsn: 'https://sentry.example.com', + }, + captcha: { + provider: 'GoogleRecaptcha', + siteKey: 'site-key-123', + }, + chromeExtensionId: 'extension-123', + api: { + mutationMaximumAffectedRecords: 1000, + }, + isAttachmentPreviewEnabled: true, + analyticsEnabled: true, + canManageFeatureFlags: true, + publicFeatureFlags: PUBLIC_FEATURE_FLAGS, + isMicrosoftMessagingEnabled: false, + isMicrosoftCalendarEnabled: false, + isGoogleMessagingEnabled: true, + isGoogleCalendarEnabled: true, + isConfigVariablesInDbEnabled: false, + }); + }); + + it('should handle production environment correctly', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + if (key === 'NODE_ENV') return NodeEnvironment.production; + if (key === 'IS_BILLING_ENABLED') return false; + + return undefined; + }); + + const result = await service.getClientConfig(); + + expect(result.debugMode).toBe(false); + expect(result.canManageFeatureFlags).toBe(false); + }); + + it('should handle missing captcha driver', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + if (key === 'CAPTCHA_DRIVER') return undefined; + if (key === 'CAPTCHA_SITE_KEY') return 'site-key'; + + return undefined; + }); + + const result = await service.getClientConfig(); + + expect(result.captcha.provider).toBeUndefined(); + expect(result.captcha.siteKey).toBe('site-key'); + }); + + it('should handle missing support driver', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + if (key === 'SUPPORT_DRIVER') return undefined; + + return undefined; + }); + + const result = await service.getClientConfig(); + + expect(result.support.supportDriver).toBe(SupportDriver.None); + }); + + it('should handle billing enabled with feature flags', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + if (key === 'NODE_ENV') return NodeEnvironment.production; + if (key === 'IS_BILLING_ENABLED') return true; + + return undefined; + }); + + const result = await service.getClientConfig(); + + expect(result.canManageFeatureFlags).toBe(true); + }); + }); + + describe('transformEnum', () => { + it('should transform enum by direct key match', () => { + const result = (service as any).transformEnum( + 'GoogleRecaptcha', + CaptchaDriverType, + ); + + expect(result).toBe(CaptchaDriverType.GoogleRecaptcha); + }); + + it('should transform enum by value match', () => { + const result = (service as any).transformEnum( + 'google-recaptcha', + CaptchaDriverType, + ); + + expect(result).toBe('GoogleRecaptcha'); + }); + + it('should transform SupportDriver enum correctly', () => { + const result = (service as any).transformEnum('front', SupportDriver); + + expect(result).toBe('Front'); + }); + + it('should throw error for unknown enum value', () => { + expect(() => { + (service as any).transformEnum('unknown-value', CaptchaDriverType); + }).toThrow( + 'Unknown enum value: unknown-value. Available keys: GoogleRecaptcha, Turnstile. Available values: google-recaptcha, turnstile', + ); + }); + + it('should handle direct key match for SupportDriver', () => { + const result = (service as any).transformEnum('Front', SupportDriver); + + expect(result).toBe(SupportDriver.Front); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts new file mode 100644 index 000000000..c8e5a3e64 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -0,0 +1,144 @@ +import { Injectable } from '@nestjs/common'; + +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; +import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface'; + +import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; +import { ClientConfig } from 'src/engine/core-modules/client-config/client-config.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +@Injectable() +export class ClientConfigService { + constructor( + private twentyConfigService: TwentyConfigService, + private domainManagerService: DomainManagerService, + ) {} + + async getClientConfig(): Promise { + const captchaProvider = this.twentyConfigService.get('CAPTCHA_DRIVER'); + const supportDriver = this.twentyConfigService.get('SUPPORT_DRIVER'); + + const clientConfig: ClientConfig = { + billing: { + isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'), + billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'), + trialPeriods: [ + { + duration: this.twentyConfigService.get( + 'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS', + ), + isCreditCardRequired: true, + }, + { + duration: this.twentyConfigService.get( + 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS', + ), + isCreditCardRequired: false, + }, + ], + }, + authProviders: { + google: this.twentyConfigService.get('AUTH_GOOGLE_ENABLED'), + magicLink: false, + password: this.twentyConfigService.get('AUTH_PASSWORD_ENABLED'), + microsoft: this.twentyConfigService.get('AUTH_MICROSOFT_ENABLED'), + sso: [], + }, + signInPrefilled: this.twentyConfigService.get('SIGN_IN_PREFILLED'), + isMultiWorkspaceEnabled: this.twentyConfigService.get( + 'IS_MULTIWORKSPACE_ENABLED', + ), + isEmailVerificationRequired: this.twentyConfigService.get( + 'IS_EMAIL_VERIFICATION_REQUIRED', + ), + defaultSubdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), + frontDomain: this.domainManagerService.getFrontUrl().hostname, + debugMode: + this.twentyConfigService.get('NODE_ENV') === + NodeEnvironment.development, + support: { + supportDriver: supportDriver + ? this.transformEnum(supportDriver, SupportDriver) + : SupportDriver.None, + supportFrontChatId: this.twentyConfigService.get( + 'SUPPORT_FRONT_CHAT_ID', + ), + }, + sentry: { + environment: this.twentyConfigService.get('SENTRY_ENVIRONMENT'), + release: this.twentyConfigService.get('APP_VERSION'), + dsn: this.twentyConfigService.get('SENTRY_FRONT_DSN'), + }, + captcha: { + provider: captchaProvider + ? this.transformEnum(captchaProvider, CaptchaDriverType) + : undefined, + siteKey: this.twentyConfigService.get('CAPTCHA_SITE_KEY'), + }, + chromeExtensionId: this.twentyConfigService.get('CHROME_EXTENSION_ID'), + api: { + mutationMaximumAffectedRecords: this.twentyConfigService.get( + 'MUTATION_MAXIMUM_AFFECTED_RECORDS', + ), + }, + isAttachmentPreviewEnabled: this.twentyConfigService.get( + 'IS_ATTACHMENT_PREVIEW_ENABLED', + ), + analyticsEnabled: this.twentyConfigService.get('ANALYTICS_ENABLED'), + canManageFeatureFlags: + this.twentyConfigService.get('NODE_ENV') === + NodeEnvironment.development || + this.twentyConfigService.get('IS_BILLING_ENABLED'), + publicFeatureFlags: PUBLIC_FEATURE_FLAGS, + isMicrosoftMessagingEnabled: this.twentyConfigService.get( + 'MESSAGING_PROVIDER_MICROSOFT_ENABLED', + ), + isMicrosoftCalendarEnabled: this.twentyConfigService.get( + 'CALENDAR_PROVIDER_MICROSOFT_ENABLED', + ), + isGoogleMessagingEnabled: this.twentyConfigService.get( + 'MESSAGING_PROVIDER_GMAIL_ENABLED', + ), + isGoogleCalendarEnabled: this.twentyConfigService.get( + 'CALENDAR_PROVIDER_GOOGLE_ENABLED', + ), + isConfigVariablesInDbEnabled: this.twentyConfigService.get( + 'IS_CONFIG_VARIABLES_IN_DB_ENABLED', + ), + }; + + return clientConfig; + } + + // GraphQL enum values are in PascalCase, but the config values are in kebab-case + // This function transforms the config values, the same way GraphQL does + private transformEnum>( + value: string, + enumObject: T, + ): T[keyof T] { + const directMatch = Object.keys(enumObject).find( + (key) => key === value, + ) as keyof T; + + if (directMatch) { + return enumObject[directMatch]; + } + + const valueMatch = Object.entries(enumObject).find( + ([, enumValue]) => enumValue === value, + ); + + if (valueMatch) { + return valueMatch[0] as T[keyof T]; + } + + const availableKeys = Object.keys(enumObject); + const availableValues = Object.values(enumObject); + + throw new Error( + `Unknown enum value: ${value}. Available keys: ${availableKeys.join(', ')}. Available values: ${availableValues.join(', ')}`, + ); + } +}