diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx index ded3ba538..f428132f9 100644 --- a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx +++ b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx @@ -124,13 +124,7 @@ describe('useAuth', () => { const { state } = result.current; expect(state.icons).toEqual({}); - expect(state.workspaceAuthProviders).toEqual({ - google: true, - microsoft: false, - magicLink: false, - password: true, - sso: [], - }); + expect(state.workspaceAuthProviders).toEqual(null); expect(state.billing).toBeNull(); expect(state.isDeveloperDefaultSignInPrefilled).toBe(false); expect(state.supportChat).toEqual({ diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx index 62e5229df..8b4a53ba4 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx @@ -7,6 +7,7 @@ import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthPro import { useTheme } from '@emotion/react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { HorizontalSeparator, IconLock, MainButton } from 'twenty-ui'; +import { isDefined } from '~/utils/isDefined'; export const SignInUpWithSSO = () => { const theme = useTheme(); @@ -18,7 +19,10 @@ export const SignInUpWithSSO = () => { const { redirectToSSOLoginPage } = useSSO(); const signInWithSSO = () => { - if (workspaceAuthProviders.sso.length === 1) { + if ( + isDefined(workspaceAuthProviders) && + workspaceAuthProviders.sso.length === 1 + ) { return redirectToSSOLoginPage(workspaceAuthProviders.sso[0].id); } diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx index a5ee130b9..ff0be7742 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx @@ -26,6 +26,10 @@ export const SignInUpWorkspaceScopeForm = () => { const { signInUpStep } = useSignInUp(form); + if (!workspaceAuthProviders) { + return null; + } + return ( <> diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect.tsx index 894b4eacc..8a600b197 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect.tsx @@ -1,11 +1,15 @@ +import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; -import { SignInUpStep } from '@/auth/states/signInUpStepState'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; import { captchaState } from '@/client-config/states/captchaState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { useEffect, useState } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { isDefined } from '~/utils/isDefined'; const searchParams = new URLSearchParams(window.location.search); @@ -31,10 +35,32 @@ export const SignInUpWorkspaceScopeFormEffect = () => { ); const { form } = useSignInUpForm(); + const { redirectToSSOLoginPage } = useSSO(); const { signInUpStep, continueWithEmail, continueWithCredentials } = useSignInUp(form); + const setSignInUpStep = useSetRecoilState(signInUpStepState); + + useEffect(() => { + if (!workspaceAuthProviders) { + return; + } + + if (workspaceAuthProviders.sso.length > 1) { + return setSignInUpStep(SignInUpStep.SSOIdentityProviderSelection); + } + + const hasOnlySSOProvidersEnabled = + !workspaceAuthProviders.google && + !workspaceAuthProviders.microsoft && + !workspaceAuthProviders.password; + + if (hasOnlySSOProvidersEnabled && workspaceAuthProviders.sso.length === 1) { + redirectToSSOLoginPage(workspaceAuthProviders.sso[0].id); + } + }, [redirectToSSOLoginPage, setSignInUpStep, workspaceAuthProviders]); + useEffect(() => { if (loadingStatus === LoadingStatus.Done) { return; @@ -58,6 +84,8 @@ export const SignInUpWorkspaceScopeFormEffect = () => { }, [captcha?.provider, isRequestingCaptchaToken, loadingStatus]); useEffect(() => { + if (!workspaceAuthProviders) return; + if ( signInUpStep === SignInUpStep.Init && !workspaceAuthProviders.google && @@ -77,10 +105,7 @@ export const SignInUpWorkspaceScopeFormEffect = () => { } }, [ signInUpStep, - workspaceAuthProviders.google, - workspaceAuthProviders.microsoft, - workspaceAuthProviders.sso, - workspaceAuthProviders.password, + workspaceAuthProviders, continueWithEmail, continueWithCredentials, loadingStatus, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSSO.test.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSSO.test.ts deleted file mode 100644 index a0b0c8d61..000000000 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSSO.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { useGetAuthorizationUrlMutation } from '~/generated/graphql'; -import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; - -// Mock dependencies -jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar'); -jest.mock('~/generated/graphql'); - -// Helpers -const mockEnqueueSnackBar = jest.fn(); -const mockGetAuthorizationUrlMutation = jest.fn(); - -// Mock return values -(useSnackBar as jest.Mock).mockReturnValue({ - enqueueSnackBar: mockEnqueueSnackBar, -}); -(useGetAuthorizationUrlMutation as jest.Mock).mockReturnValue([ - mockGetAuthorizationUrlMutation, -]); - -describe('useSSO', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should call getAuthorizationUrlForSSO with correct parameters', async () => { - const { result } = renderHook(() => useSSO()); - const identityProviderId = 'test-id'; - - mockGetAuthorizationUrlMutation.mockResolvedValueOnce({ - data: { - getAuthorizationUrl: { - authorizationURL: 'http://example.com', - }, - }, - }); - - await result.current.getAuthorizationUrlForSSO({ identityProviderId }); - - expect(mockGetAuthorizationUrlMutation).toHaveBeenCalledWith({ - variables: { input: { identityProviderId } }, - }); - }); - - it('should enqueue error snackbar when URL retrieval fails', async () => { - const { result } = renderHook(() => useSSO()); - const identityProviderId = 'test-id'; - - mockGetAuthorizationUrlMutation.mockResolvedValueOnce({ - errors: [{ message: 'Error message' }], - }); - - await result.current.redirectToSSOLoginPage(identityProviderId); - - expect(mockEnqueueSnackBar).toHaveBeenCalledWith('Error message', { - variant: 'error', - }); - }); - - it('should enqueue default error snackbar when error message is not provided', async () => { - const { result } = renderHook(() => useSSO()); - const identityProviderId = 'test-id'; - - mockGetAuthorizationUrlMutation.mockResolvedValueOnce({ errors: [{}] }); - - await result.current.redirectToSSOLoginPage(identityProviderId); - - expect(mockEnqueueSnackBar).toHaveBeenCalledWith('Unknown error', { - variant: 'error', - }); - }); -}); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSSO.test.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSSO.test.tsx new file mode 100644 index 000000000..3b0189bc1 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSSO.test.tsx @@ -0,0 +1,88 @@ +import { GET_AUTHORIZATION_URL } from '@/auth/graphql/mutations/getAuthorizationUrl'; +import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; +import { useRedirect } from '@/domain-manager/hooks/useRedirect'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { MockedProvider } from '@apollo/client/testing'; +import { renderHook } from '@testing-library/react'; + +jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar'); +jest.mock('@/domain-manager/hooks/useRedirect'); +jest.mock('~/generated/graphql'); + +const mockEnqueueSnackBar = jest.fn(); +const mockRedirect = jest.fn(); + +(useSnackBar as jest.Mock).mockReturnValue({ + enqueueSnackBar: mockEnqueueSnackBar, +}); +(useRedirect as jest.Mock).mockReturnValue({ + redirect: mockRedirect, +}); + +const apolloMocks = [ + { + request: { + query: GET_AUTHORIZATION_URL, + variables: { + input: { + identityProviderId: 'success-id', + }, + }, + }, + result: { + data: { + getAuthorizationUrl: { authorizationURL: 'http://example.com' }, + }, + }, + }, + { + request: { + query: GET_AUTHORIZATION_URL, + variables: { + input: { + identityProviderId: 'error-id', + }, + }, + }, + result: { + data: null, + errors: [{ message: 'Error message' }], + }, + }, +]; + +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe('useSSO', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call getAuthorizationUrlForSSO with correct parameters', async () => { + const { result } = renderHook(() => useSSO(), { + wrapper: Wrapper, + }); + const identityProviderId = 'success-id'; + + await result.current.redirectToSSOLoginPage(identityProviderId); + + expect(mockRedirect).toHaveBeenCalledWith('http://example.com'); + }); + + it('should enqueue error snackbar when URL retrieval fails', async () => { + const { result } = renderHook(() => useSSO(), { + wrapper: Wrapper, + }); + const identityProviderId = 'error-id'; + + await result.current.redirectToSSOLoginPage(identityProviderId); + + expect(mockEnqueueSnackBar).toHaveBeenCalledWith('Error message', { + variant: 'error', + }); + }); +}); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts index f8228e4d1..3b3a3405c 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts @@ -1,53 +1,38 @@ /* @license Enterprise */ +import { GET_AUTHORIZATION_URL } from '@/auth/graphql/mutations/getAuthorizationUrl'; +import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { - GetAuthorizationUrlMutationVariables, - useGetAuthorizationUrlMutation, -} from '~/generated/graphql'; -import { isDefined } from '~/utils/isDefined'; +import { useApolloClient } from '@apollo/client'; export const useSSO = () => { + const apolloClient = useApolloClient(); const { enqueueSnackBar } = useSnackBar(); - const [getAuthorizationUrlMutation] = useGetAuthorizationUrlMutation(); - - const getAuthorizationUrlForSSO = async ({ - identityProviderId, - }: GetAuthorizationUrlMutationVariables['input']) => { - return await getAuthorizationUrlMutation({ - variables: { - input: { identityProviderId }, - }, - }); - }; + const { redirect } = useRedirect(); const redirectToSSOLoginPage = async (identityProviderId: string) => { - const authorizationUrlForSSOResult = await getAuthorizationUrlForSSO({ - identityProviderId, - }); - - if ( - isDefined(authorizationUrlForSSOResult.errors) || - !authorizationUrlForSSOResult.data || - !authorizationUrlForSSOResult.data?.getAuthorizationUrl.authorizationURL - ) { - return enqueueSnackBar( - authorizationUrlForSSOResult.errors?.[0]?.message ?? 'Unknown error', - { - variant: SnackBarVariant.Error, + let authorizationUrlForSSOResult; + try { + authorizationUrlForSSOResult = await apolloClient.mutate({ + mutation: GET_AUTHORIZATION_URL, + variables: { + input: { identityProviderId }, }, - ); + }); + } catch (error: any) { + return enqueueSnackBar(error?.message ?? 'Unknown error', { + variant: SnackBarVariant.Error, + }); } - window.location.href = - authorizationUrlForSSOResult.data?.getAuthorizationUrl.authorizationURL; - return; + redirect( + authorizationUrlForSSOResult.data?.getAuthorizationUrl.authorizationURL, + ); }; return { redirectToSSOLoginPage, - getAuthorizationUrlForSSO, }; }; diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx index 464153b60..25baa6fac 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx @@ -1,14 +1,14 @@ import { useRecoilValue } from 'recoil'; -import { useEffect } from 'react'; -import { isDefined } from '~/utils/isDefined'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { lastAuthenticatedWorkspaceDomainState } from '@/domain-manager/states/lastAuthenticatedWorkspaceDomainState'; -import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation'; +import { useEffect } from 'react'; +import { isDefined } from '~/utils/isDefined'; -import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain'; import { useGetPublicWorkspaceDataBySubdomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataBySubdomain'; +import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain'; export const WorkspaceProviderEffect = () => { const { data: getPublicWorkspaceData } = useGetPublicWorkspaceDataBySubdomain(); diff --git a/packages/twenty-front/src/modules/workspace/states/workspaceAuthProvidersState.ts b/packages/twenty-front/src/modules/workspace/states/workspaceAuthProvidersState.ts index 69384bbb5..a7a8b0f64 100644 --- a/packages/twenty-front/src/modules/workspace/states/workspaceAuthProvidersState.ts +++ b/packages/twenty-front/src/modules/workspace/states/workspaceAuthProvidersState.ts @@ -2,13 +2,7 @@ import { createState } from '@ui/utilities/state/utils/createState'; import { AuthProviders } from '~/generated/graphql'; -export const workspaceAuthProvidersState = createState({ +export const workspaceAuthProvidersState = createState({ key: 'workspaceAuthProvidersState', - defaultValue: { - google: true, - magicLink: false, - password: true, - microsoft: false, - sso: [], - }, + defaultValue: null, }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/utils/__tests__/get-auth-providers-by-workspace.util.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/utils/__tests__/get-auth-providers-by-workspace.util.spec.ts index c8da86b85..e259719a6 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/utils/__tests__/get-auth-providers-by-workspace.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/utils/__tests__/get-auth-providers-by-workspace.util.spec.ts @@ -1,5 +1,10 @@ import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { + IdentityProviderType, + SSOIdentityProviderStatus, + WorkspaceSSOIdentityProvider, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; describe('getAuthProvidersByWorkspace', () => { const mockWorkspace = { @@ -10,8 +15,9 @@ describe('getAuthProvidersByWorkspace', () => { { id: 'sso1', name: 'SSO Provider 1', - type: 'SAML', - status: 'active', + type: IdentityProviderType.SAML, + + status: SSOIdentityProviderStatus.Active, issuer: 'sso1.example.com', }, ], @@ -38,8 +44,9 @@ describe('getAuthProvidersByWorkspace', () => { { id: 'sso1', name: 'SSO Provider 1', - type: 'SAML', - status: 'active', + type: IdentityProviderType.SAML, + + status: SSOIdentityProviderStatus.Active, issuer: 'sso1.example.com', }, ], @@ -66,6 +73,37 @@ describe('getAuthProvidersByWorkspace', () => { sso: [], }); }); + it('should handle workspace with SSO providers inactive', () => { + const result = getAuthProvidersByWorkspace({ + workspace: { + ...mockWorkspace, + workspaceSSOIdentityProviders: [ + { + id: 'sso1', + name: 'SSO Provider 1', + type: IdentityProviderType.SAML, + status: SSOIdentityProviderStatus.Inactive, + issuer: 'sso1.example.com', + } as WorkspaceSSOIdentityProvider, + ], + }, + systemEnabledProviders: { + google: true, + magicLink: false, + password: true, + microsoft: true, + sso: [], + }, + }); + + expect(result).toEqual({ + google: true, + magicLink: false, + password: true, + microsoft: false, + sso: [], + }); + }); it('should disable Microsoft auth if isMicrosoftAuthEnabled is false', () => { const result = getAuthProvidersByWorkspace({ @@ -88,8 +126,9 @@ describe('getAuthProvidersByWorkspace', () => { { id: 'sso1', name: 'SSO Provider 1', - type: 'SAML', - status: 'active', + type: IdentityProviderType.SAML, + + status: SSOIdentityProviderStatus.Active, issuer: 'sso1.example.com', }, ], diff --git a/packages/twenty-server/src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util.ts b/packages/twenty-server/src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util.ts index 87a0be4a8..72e4253b6 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util.ts @@ -1,5 +1,7 @@ +import { SSOIdentityProviderStatus } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { isDefined } from 'src/utils/is-defined'; export const getAuthProvidersByWorkspace = ({ workspace, @@ -21,12 +23,18 @@ export const getAuthProvidersByWorkspace = ({ workspace.isPasswordAuthEnabled && systemEnabledProviders.password, microsoft: workspace.isMicrosoftAuthEnabled && systemEnabledProviders.microsoft, - sso: workspace.workspaceSSOIdentityProviders.map((identityProvider) => ({ - id: identityProvider.id, - name: identityProvider.name, - type: identityProvider.type, - status: identityProvider.status, - issuer: identityProvider.issuer, - })), + sso: workspace.workspaceSSOIdentityProviders + .map((identityProvider) => + identityProvider.status === SSOIdentityProviderStatus.Active + ? { + id: identityProvider.id, + name: identityProvider.name, + type: identityProvider.type, + status: identityProvider.status, + issuer: identityProvider.issuer, + } + : undefined, + ) + .filter(isDefined), }; }; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 9aaee5ba9..83cc2401f 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -148,11 +148,9 @@ export class WorkspaceResolver { workspace.id, ); - const filteredFeatureFlags = featureFlags.filter((flag) => + return featureFlags.filter((flag) => Object.values(FeatureFlagKey).includes(flag.key), ); - - return filteredFeatureFlags; } @Mutation(() => Workspace)