diff --git a/packages/twenty-front/.env.example b/packages/twenty-front/.env.example index 1f9a51b33..049be24db 100644 --- a/packages/twenty-front/.env.example +++ b/packages/twenty-front/.env.example @@ -7,5 +7,6 @@ GENERATE_SOURCEMAP=false # VITE_DISABLE_TYPESCRIPT_CHECKER=true # VITE_DISABLE_ESLINT_CHECKER=true # VITE_ENABLE_SSL=false +# VITE_HOST=localhost.com # SSL_KEY_PATH="./certs/your-cert.key" # SSL_CERT_PATH="./certs/your-cert.crt" \ No newline at end of file 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 b994ec0cb..c61dbc2f2 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 @@ -14,6 +14,7 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i import { supportChatState } from '@/client-config/states/supportChatState'; import { email, mocks, password, results, token } from '../__mocks__/useAuth'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; const Wrapper = ({ children }: { children: ReactNode }) => ( @@ -83,6 +84,9 @@ describe('useAuth', () => { ); const supportChat = useRecoilValue(supportChatState); const isDebugMode = useRecoilValue(isDebugModeState); + const isMultiWorkspaceEnabled = useRecoilValue( + isMultiWorkspaceEnabledState, + ); return { ...useAuth(), client, @@ -93,6 +97,7 @@ describe('useAuth', () => { isDeveloperDefaultSignInPrefilled, supportChat, isDebugMode, + isMultiWorkspaceEnabled, }, }; }, diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index b563c5698..8598fe638 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -4,7 +4,6 @@ import { snapshot_UNSTABLE, useGotoRecoilSnapshot, useRecoilCallback, - useRecoilValue, useSetRecoilState, } from 'recoil'; import { iconsState } from 'twenty-ui'; @@ -42,18 +41,15 @@ import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDa import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat'; import { currentUserState } from '../states/currentUserState'; import { tokenPairState } from '../states/tokenPairState'; -import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; -import { urlManagerState } from '@/url-manager/states/url-manager.state'; -import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; +import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain'; +import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation'; export const useAuth = () => { const setTokenPair = useSetRecoilState(tokenPairState); const setCurrentUser = useSetRecoilState(currentUserState); - const urlManager = useRecoilValue(urlManagerState); - const setLastAuthenticateWorkspaceState = useSetRecoilState( - lastAuthenticateWorkspaceState, - ); const setCurrentWorkspaceMember = useSetRecoilState( currentWorkspaceMemberState, ); @@ -68,7 +64,12 @@ export const useAuth = () => { const [challenge] = useChallengeMutation(); const [signUp] = useSignUpMutation(); const [verify] = useVerifyMutation(); - const { isTwentyWorkspaceSubdomain, getWorkspaceSubdomain } = useUrlManager(); + const { isOnAWorkspaceSubdomain } = + useIsCurrentLocationOnAWorkspaceSubdomain(); + const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation(); + + const { setLastAuthenticateWorkspaceDomain } = + useLastAuthenticatedWorkspaceDomain(); const [checkUserExistsQuery, { data: checkUserExistsData }] = useCheckUserExistsLazyQuery(); @@ -101,6 +102,9 @@ export const useAuth = () => { const isCurrentUserLoaded = snapshot .getLoadable(isCurrentUserLoadedState) .getValue(); + const isMultiWorkspaceEnabled = snapshot + .getLoadable(isMultiWorkspaceEnabledState) + .getValue(); const initialSnapshot = emptySnapshot.map(({ set }) => { set(iconsState, iconsValue); set(authProvidersState, authProvidersValue); @@ -114,6 +118,7 @@ export const useAuth = () => { set(captchaProviderState, captchaProvider); set(clientConfigApiStatusState, clientConfigApiStatus); set(isCurrentUserLoadedState, isCurrentUserLoaded); + set(isMultiWorkspaceEnabledState, isMultiWorkspaceEnabled); return undefined; }); goToRecoilSnapshot(initialSnapshot); @@ -212,13 +217,11 @@ export const useAuth = () => { const workspace = user.defaultWorkspace ?? null; setCurrentWorkspace(workspace); - if (isDefined(workspace) && isTwentyWorkspaceSubdomain) { - setLastAuthenticateWorkspaceState({ - id: workspace.id, + + if (isDefined(workspace) && isOnAWorkspaceSubdomain) { + setLastAuthenticateWorkspaceDomain({ + workspaceId: workspace.id, subdomain: workspace.subdomain, - cookieAttributes: { - domain: `.${urlManager.frontDomain}`, - }, }); } @@ -245,12 +248,11 @@ export const useAuth = () => { setTokenPair, setCurrentUser, setCurrentWorkspace, - isTwentyWorkspaceSubdomain, + isOnAWorkspaceSubdomain, setCurrentWorkspaceMembers, setCurrentWorkspaceMember, setDateTimeFormat, - setLastAuthenticateWorkspaceState, - urlManager.frontDomain, + setLastAuthenticateWorkspaceDomain, setWorkspaces, ], ); @@ -340,15 +342,13 @@ export const useAuth = () => { params.workspacePersonalInviteToken, ); } - const subdomain = getWorkspaceSubdomain; - - if (isDefined(subdomain)) { - url.searchParams.set('workspaceSubdomain', subdomain); + if (isDefined(workspaceSubdomain)) { + url.searchParams.set('workspaceSubdomain', workspaceSubdomain); } return url.toString(); }, - [getWorkspaceSubdomain], + [workspaceSubdomain], ); const handleGoogleLogin = useCallback( diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index ad934cd29..9bdfa73fc 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -30,8 +30,8 @@ import { useAuth } from '@/auth/hooks/useAuth'; import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; import { signInUpModeState } from '@/auth/states/signInUpModeState'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; -import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; import { SignInUpMode } from '@/auth/types/signInUpMode'; +import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; const StyledContentContainer = styled(motion.div)` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -53,8 +53,7 @@ export const SignInUpGlobalScopeForm = () => { const { signInWithMicrosoft } = useSignInWithMicrosoft(); const { checkUserExists } = useAuth(); const { readCaptchaToken } = useReadCaptchaToken(); - const { redirectToWorkspace } = useUrlManager(); - + const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); const setSignInUpStep = useSetRecoilState(signInUpStepState); const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); @@ -97,7 +96,7 @@ export const SignInUpGlobalScopeForm = () => { isDefined(data?.checkUserExists.availableWorkspaces) && data.checkUserExists.availableWorkspaces.length >= 1 ) { - return redirectToWorkspace( + return redirectToWorkspaceDomain( data?.checkUserExists.availableWorkspaces[0].subdomain, pathname, { diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts new file mode 100644 index 000000000..ff7ca2522 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts @@ -0,0 +1,72 @@ +import { act, renderHook } from '@testing-library/react'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useEmailPasswordResetLinkMutation } from '~/generated/graphql'; +import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; + +// Mocks +jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar'); +jest.mock('~/generated/graphql'); + +describe('useHandleResetPassword', () => { + const enqueueSnackBarMock = jest.fn(); + const emailPasswordResetLinkMock = jest.fn(); + + beforeEach(() => { + (useSnackBar as jest.Mock).mockReturnValue({ + enqueueSnackBar: enqueueSnackBarMock, + }); + (useEmailPasswordResetLinkMutation as jest.Mock).mockReturnValue([ + emailPasswordResetLinkMock, + ]); + jest.clearAllMocks(); + }); + + it('should show error message if email is invalid', async () => { + const { result } = renderHook(() => useHandleResetPassword()); + await act(() => result.current.handleResetPassword('')()); + + expect(enqueueSnackBarMock).toHaveBeenCalledWith('Invalid email', { + variant: SnackBarVariant.Error, + }); + }); + + it('should show success message if password reset link is sent', async () => { + emailPasswordResetLinkMock.mockResolvedValue({ + data: { emailPasswordResetLink: { success: true } }, + }); + + const { result } = renderHook(() => useHandleResetPassword()); + await act(() => result.current.handleResetPassword('test@example.com')()); + + expect(enqueueSnackBarMock).toHaveBeenCalledWith( + 'Password reset link has been sent to the email', + { variant: SnackBarVariant.Success }, + ); + }); + + it('should show error message if sending reset link fails', async () => { + emailPasswordResetLinkMock.mockResolvedValue({ + data: { emailPasswordResetLink: { success: false } }, + }); + + const { result } = renderHook(() => useHandleResetPassword()); + await act(() => result.current.handleResetPassword('test@example.com')()); + + expect(enqueueSnackBarMock).toHaveBeenCalledWith('There was some issue', { + variant: SnackBarVariant.Error, + }); + }); + + it('should show error message in case of request error', async () => { + const errorMessage = 'Network Error'; + emailPasswordResetLinkMock.mockRejectedValue(new Error(errorMessage)); + + const { result } = renderHook(() => useHandleResetPassword()); + await act(() => result.current.handleResetPassword('test@example.com')()); + + expect(enqueueSnackBarMock).toHaveBeenCalledWith(errorMessage, { + variant: SnackBarVariant.Error, + }); + }); +}); 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 new file mode 100644 index 000000000..a0b0c8d61 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSSO.test.ts @@ -0,0 +1,73 @@ +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__/useSignInWithGoogle.test.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithGoogle.test.ts new file mode 100644 index 000000000..6cb9f84ed --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithGoogle.test.ts @@ -0,0 +1,55 @@ +import { renderHook } from '@testing-library/react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useAuth } from '@/auth/hooks/useAuth'; +import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; + +jest.mock('react-router-dom', () => ({ + useParams: jest.fn(), + useSearchParams: jest.fn(), +})); + +jest.mock('@/auth/hooks/useAuth', () => ({ + useAuth: jest.fn(), +})); + +describe('useSignInWithGoogle', () => { + it('should call signInWithGoogle with correct params', () => { + const signInWithGoogleMock = jest.fn(); + const mockUseParams = { workspaceInviteHash: 'testHash' }; + const mockSearchParams = new URLSearchParams('inviteToken=testToken'); + + (useParams as jest.Mock).mockReturnValue(mockUseParams); + (useSearchParams as jest.Mock).mockReturnValue([mockSearchParams]); + (useAuth as jest.Mock).mockReturnValue({ + signInWithGoogle: signInWithGoogleMock, + }); + + const { result } = renderHook(() => useSignInWithGoogle()); + result.current.signInWithGoogle(); + + expect(signInWithGoogleMock).toHaveBeenCalledWith({ + workspaceInviteHash: 'testHash', + workspacePersonalInviteToken: 'testToken', + }); + }); + + it('should call signInWithGoogle with undefined invite token if not present', () => { + const signInWithGoogleMock = jest.fn(); + const mockUseParams = { workspaceInviteHash: 'testHash' }; + const mockSearchParams = new URLSearchParams(); + + (useParams as jest.Mock).mockReturnValue(mockUseParams); + (useSearchParams as jest.Mock).mockReturnValue([mockSearchParams]); + (useAuth as jest.Mock).mockReturnValue({ + signInWithGoogle: signInWithGoogleMock, + }); + + const { result } = renderHook(() => useSignInWithGoogle()); + result.current.signInWithGoogle(); + + expect(signInWithGoogleMock).toHaveBeenCalledWith({ + workspaceInviteHash: 'testHash', + workspacePersonalInviteToken: undefined, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts new file mode 100644 index 000000000..3200e8a54 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts @@ -0,0 +1,60 @@ +import { renderHook } from '@testing-library/react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useAuth } from '@/auth/hooks/useAuth'; +import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; + +jest.mock('react-router-dom', () => ({ + useParams: jest.fn(), + useSearchParams: jest.fn(), +})); + +jest.mock('@/auth/hooks/useAuth', () => ({ + useAuth: jest.fn(), +})); + +describe('useSignInWithMicrosoft', () => { + it('should call signInWithMicrosoft with the correct parameters', () => { + const workspaceInviteHashMock = 'testHash'; + const inviteTokenMock = 'testToken'; + const signInWithMicrosoftMock = jest.fn(); + + (useParams as jest.Mock).mockReturnValue({ + workspaceInviteHash: workspaceInviteHashMock, + }); + (useSearchParams as jest.Mock).mockReturnValue([ + new URLSearchParams(`inviteToken=${inviteTokenMock}`), + ]); + (useAuth as jest.Mock).mockReturnValue({ + signInWithMicrosoft: signInWithMicrosoftMock, + }); + + const { result } = renderHook(() => useSignInWithMicrosoft()); + result.current.signInWithMicrosoft(); + + expect(signInWithMicrosoftMock).toHaveBeenCalledWith({ + workspaceInviteHash: workspaceInviteHashMock, + workspacePersonalInviteToken: inviteTokenMock, + }); + }); + + it('should handle missing inviteToken gracefully', () => { + const workspaceInviteHashMock = 'testHash'; + const signInWithMicrosoftMock = jest.fn(); + + (useParams as jest.Mock).mockReturnValue({ + workspaceInviteHash: workspaceInviteHashMock, + }); + (useSearchParams as jest.Mock).mockReturnValue([new URLSearchParams('')]); + (useAuth as jest.Mock).mockReturnValue({ + signInWithMicrosoft: signInWithMicrosoftMock, + }); + + const { result } = renderHook(() => useSignInWithMicrosoft()); + result.current.signInWithMicrosoft(); + + expect(signInWithMicrosoftMock).toHaveBeenCalledWith({ + workspaceInviteHash: workspaceInviteHashMock, + workspacePersonalInviteToken: undefined, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts similarity index 100% rename from packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx rename to packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts diff --git a/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts deleted file mode 100644 index 53f55ef4b..000000000 --- a/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { cookieStorageEffect } from '~/utils/recoil-effects'; -import { Workspace } from '~/generated/graphql'; -import { createState } from 'twenty-ui'; - -export const lastAuthenticateWorkspaceState = createState< - | (Pick & { - cookieAttributes?: Cookies.CookieAttributes; - }) - | null ->({ - key: 'lastAuthenticateWorkspaceState', - defaultValue: null, - effects: [ - cookieStorageEffect('lastAuthenticateWorkspace', { - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year - }), - ], -}); diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index b706ba972..560974fe3 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -13,13 +13,13 @@ import { useEffect } from 'react'; import { useRecoilState, useSetRecoilState } from 'recoil'; import { useGetClientConfigQuery } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; -import { urlManagerState } from '@/url-manager/states/url-manager.state'; +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { isSSOEnabledState } from '@/client-config/states/isSSOEnabledState'; export const ClientConfigProviderEffect = () => { const setIsDebugMode = useSetRecoilState(isDebugModeState); const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState); - const setUrlManager = useSetRecoilState(urlManagerState); + const setDomainConfiguration = useSetRecoilState(domainConfigurationState); const setIsDeveloperDefaultSignInPrefilled = useSetRecoilState( isDeveloperDefaultSignInPrefilledState, @@ -77,7 +77,6 @@ export const ClientConfigProviderEffect = () => { setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled); setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled); setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled); - setBilling(data?.clientConfig.billing); setSupportChat(data?.clientConfig.support); @@ -95,7 +94,7 @@ export const ClientConfigProviderEffect = () => { setChromeExtensionId(data?.clientConfig?.chromeExtensionId); setApiConfig(data?.clientConfig?.api); setIsSSOEnabledState(data?.clientConfig?.isSSOEnabled); - setUrlManager({ + setDomainConfiguration({ defaultSubdomain: data?.clientConfig?.defaultSubdomain, frontDomain: data?.clientConfig?.frontDomain, }); @@ -114,7 +113,7 @@ export const ClientConfigProviderEffect = () => { setApiConfig, setIsAnalyticsEnabled, error, - setUrlManager, + setDomainConfiguration, setIsSSOEnabledState, ]); diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useBuildWorkspaceUrl.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useBuildWorkspaceUrl.ts new file mode 100644 index 000000000..ae0c66a08 --- /dev/null +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useBuildWorkspaceUrl.ts @@ -0,0 +1,34 @@ +import { isDefined } from '~/utils/isDefined'; +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; +import { useRecoilValue } from 'recoil'; + +export const useBuildWorkspaceUrl = () => { + const domainConfiguration = useRecoilValue(domainConfigurationState); + + const buildWorkspaceUrl = ( + subdomain?: string, + pathname?: string, + searchParams?: Record, + ) => { + const url = new URL(window.location.href); + + if (isDefined(subdomain) && subdomain.length !== 0) { + url.hostname = `${subdomain}.${domainConfiguration.frontDomain}`; + } + + if (isDefined(pathname)) { + url.pathname = pathname; + } + + if (isDefined(searchParams)) { + Object.entries(searchParams).forEach(([key, value]) => + url.searchParams.set(key, value), + ); + } + return url.toString(); + }; + + return { + buildWorkspaceUrl, + }; +}; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataBySubdomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataBySubdomain.ts new file mode 100644 index 000000000..e9bba69ae --- /dev/null +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataBySubdomain.ts @@ -0,0 +1,42 @@ +import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; +import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectToDefaultDomain'; +import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain'; + +export const useGetPublicWorkspaceDataBySubdomain = () => { + const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain(); + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const setAuthProviders = useSetRecoilState(authProvidersState); + const workspacePublicData = useRecoilValue(workspacePublicDataState); + const { redirectToDefaultDomain } = useRedirectToDefaultDomain(); + const setWorkspacePublicDataState = useSetRecoilState( + workspacePublicDataState, + ); + const { setLastAuthenticateWorkspaceDomain } = + useLastAuthenticatedWorkspaceDomain(); + + const { loading } = useGetPublicWorkspaceDataBySubdomainQuery({ + skip: + (isMultiWorkspaceEnabled && isDefaultDomain) || + isDefined(workspacePublicData), + onCompleted: (data) => { + setAuthProviders(data.getPublicWorkspaceDataBySubdomain.authProviders); + setWorkspacePublicDataState(data.getPublicWorkspaceDataBySubdomain); + }, + onError: (error) => { + // eslint-disable-next-line no-console + console.error(error); + setLastAuthenticateWorkspaceDomain(null); + redirectToDefaultDomain(); + }, + }); + + return { + loading, + }; +}; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain.ts new file mode 100644 index 000000000..27eb9a9ea --- /dev/null +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain.ts @@ -0,0 +1,27 @@ +import { isDefined } from '~/utils/isDefined'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useRecoilValue } from 'recoil'; +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; +import { useReadDefaultDomainFromConfiguration } from '@/domain-manager/hooks/useReadDefaultDomainFromConfiguration'; + +export const useIsCurrentLocationOnAWorkspaceSubdomain = () => { + const { defaultDomain } = useReadDefaultDomainFromConfiguration(); + + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const domainConfiguration = useRecoilValue(domainConfigurationState); + + if ( + isMultiWorkspaceEnabled && + (!isDefined(domainConfiguration.frontDomain) || + !isDefined(domainConfiguration.defaultSubdomain)) + ) { + throw new Error('frontDomain and defaultSubdomain are required'); + } + + const isOnAWorkspaceSubdomain = + isMultiWorkspaceEnabled && window.location.hostname !== defaultDomain; + + return { + isOnAWorkspaceSubdomain, + }; +}; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain.ts new file mode 100644 index 000000000..43ccc460f --- /dev/null +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain.ts @@ -0,0 +1,15 @@ +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useRecoilValue } from 'recoil'; +import { useReadDefaultDomainFromConfiguration } from '@/domain-manager/hooks/useReadDefaultDomainFromConfiguration'; + +export const useIsCurrentLocationOnDefaultDomain = () => { + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const { defaultDomain } = useReadDefaultDomainFromConfiguration(); + const isDefaultDomain = isMultiWorkspaceEnabled + ? window.location.hostname === defaultDomain + : true; + + return { + isDefaultDomain, + }; +}; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain.ts new file mode 100644 index 000000000..4085de30b --- /dev/null +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain.ts @@ -0,0 +1,29 @@ +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { lastAuthenticatedWorkspaceDomainState } from '@/domain-manager/states/lastAuthenticatedWorkspaceDomainState'; +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; + +export const useLastAuthenticatedWorkspaceDomain = () => { + const domainConfiguration = useRecoilValue(domainConfigurationState); + const setLastAuthenticatedWorkspaceDomain = useSetRecoilState( + lastAuthenticatedWorkspaceDomainState, + ); + const setLastAuthenticateWorkspaceDomainWithCookieAttributes = ( + params: { workspaceId: string; subdomain: string } | null, + ) => { + setLastAuthenticatedWorkspaceDomain( + params + ? { + ...params, + cookieAttributes: { + domain: `.${domainConfiguration.frontDomain}`, + }, + } + : null, + ); + }; + + return { + setLastAuthenticateWorkspaceDomain: + setLastAuthenticateWorkspaceDomainWithCookieAttributes, + }; +}; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useReadDefaultDomainFromConfiguration.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useReadDefaultDomainFromConfiguration.ts new file mode 100644 index 000000000..f280213e9 --- /dev/null +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useReadDefaultDomainFromConfiguration.ts @@ -0,0 +1,16 @@ +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useRecoilValue } from 'recoil'; + +export const useReadDefaultDomainFromConfiguration = () => { + const domainConfiguration = useRecoilValue(domainConfigurationState); + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + + const defaultDomain = isMultiWorkspaceEnabled + ? `${domainConfiguration.defaultSubdomain}.${domainConfiguration.frontDomain}` + : domainConfiguration.frontDomain; + + return { + defaultDomain, + }; +}; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation.ts new file mode 100644 index 000000000..7fb3eda88 --- /dev/null +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation.ts @@ -0,0 +1,24 @@ +import { isDefined } from '~/utils/isDefined'; +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; +import { useRecoilValue } from 'recoil'; +import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain'; + +export const useReadWorkspaceSubdomainFromCurrentLocation = () => { + const domainConfiguration = useRecoilValue(domainConfigurationState); + const { isOnAWorkspaceSubdomain } = + useIsCurrentLocationOnAWorkspaceSubdomain(); + if (!isDefined(domainConfiguration.frontDomain)) { + throw new Error('frontDomain is not defined'); + } + + const workspaceSubdomain = isOnAWorkspaceSubdomain + ? window.location.hostname.replace( + `.${domainConfiguration.frontDomain}`, + '', + ) + : null; + + return { + workspaceSubdomain, + }; +}; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToDefaultDomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToDefaultDomain.ts new file mode 100644 index 000000000..e5070db28 --- /dev/null +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToDefaultDomain.ts @@ -0,0 +1,16 @@ +import { useReadDefaultDomainFromConfiguration } from '@/domain-manager/hooks/useReadDefaultDomainFromConfiguration'; + +export const useRedirectToDefaultDomain = () => { + const { defaultDomain } = useReadDefaultDomainFromConfiguration(); + const redirectToDefaultDomain = () => { + const url = new URL(window.location.href); + if (url.hostname !== defaultDomain) { + url.hostname = defaultDomain; + window.location.href = url.toString(); + } + }; + + return { + redirectToDefaultDomain, + }; +}; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToWorkspaceDomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToWorkspaceDomain.ts new file mode 100644 index 000000000..2d95e809d --- /dev/null +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToWorkspaceDomain.ts @@ -0,0 +1,21 @@ +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useRecoilValue } from 'recoil'; +import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl'; + +export const useRedirectToWorkspaceDomain = () => { + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); + + const redirectToWorkspaceDomain = ( + subdomain: string, + pathname?: string, + searchParams?: Record, + ) => { + if (!isMultiWorkspaceEnabled) return; + window.location.href = buildWorkspaceUrl(subdomain, pathname, searchParams); + }; + + return { + redirectToWorkspaceDomain, + }; +}; diff --git a/packages/twenty-front/src/modules/url-manager/states/url-manager.state.ts b/packages/twenty-front/src/modules/domain-manager/states/domainConfigurationState.ts similarity index 73% rename from packages/twenty-front/src/modules/url-manager/states/url-manager.state.ts rename to packages/twenty-front/src/modules/domain-manager/states/domainConfigurationState.ts index 460a0b0ce..89dc12404 100644 --- a/packages/twenty-front/src/modules/url-manager/states/url-manager.state.ts +++ b/packages/twenty-front/src/modules/domain-manager/states/domainConfigurationState.ts @@ -1,10 +1,10 @@ import { createState } from 'twenty-ui'; import { ClientConfig } from '~/generated/graphql'; -export const urlManagerState = createState< +export const domainConfigurationState = createState< Pick >({ - key: 'urlManager', + key: 'domainConfiguration', defaultValue: { frontDomain: '', defaultSubdomain: undefined, diff --git a/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts b/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts new file mode 100644 index 000000000..4706032b7 --- /dev/null +++ b/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts @@ -0,0 +1,16 @@ +import { cookieStorageEffect } from '~/utils/recoil-effects'; +import { createState } from 'twenty-ui'; + +export const lastAuthenticatedWorkspaceDomainState = createState<{ + subdomain: string; + workspaceId: string; + cookieAttributes?: Cookies.CookieAttributes; +} | null>({ + key: 'lastAuthenticateWorkspaceDomain', + defaultValue: null, + effects: [ + cookieStorageEffect('lastAuthenticateWorkspaceDomain', { + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year + }), + ], +}); diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx index 396cb7f72..4e61caad8 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx @@ -61,7 +61,8 @@ export const SettingsSecurityOptionsList = () => { if ( currentWorkspace[key] === true && - allAuthProvidersEnabled.filter((isAuthEnable) => isAuthEnable).length <= 1 + allAuthProvidersEnabled.filter((isAuthEnabled) => isAuthEnabled).length <= + 1 ) { return enqueueSnackBar( 'At least one authentication method must be enabled', diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx index d329e4548..a789b3c10 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx @@ -13,15 +13,13 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useState } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { IconChevronDown, MenuItemSelectAvatar } from 'twenty-ui'; +import { + IconChevronDown, + MenuItemSelectAvatar, + UndecoratedLink, +} from 'twenty-ui'; import { getImageAbsoluteURI } from '~/utils/image/getImageAbsoluteURI'; -import { Link } from 'react-router-dom'; -import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; - -const StyledLink = styled(Link)` - text-decoration: none; - width: 100%; -`; +import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl'; const StyledLogo = styled.div<{ logo: string }>` background: url(${({ logo }) => logo}); @@ -79,7 +77,7 @@ export const MultiWorkspaceDropdownButton = ({ useState(false); const { switchWorkspace } = useWorkspaceSwitching(); - const { buildWorkspaceUrl } = useUrlManager(); + const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID); @@ -122,9 +120,13 @@ export const MultiWorkspaceDropdownButton = ({ dropdownComponents={ {workspaces.map((workspace) => ( - { + event?.preventDefault(); + handleChange(workspace.id); + }} > } selected={currentWorkspace?.id === workspace.id} - onClick={(event) => { - event?.preventDefault(); - handleChange(workspace.id); - }} /> - + ))} } diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts index b9e064600..3066cde0c 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts @@ -7,14 +7,16 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSwitchWorkspaceMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; -import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; +import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectToDefaultDomain'; +import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; export const useWorkspaceSwitching = () => { const [switchWorkspaceMutation] = useSwitchWorkspaceMutation(); const currentWorkspace = useRecoilValue(currentWorkspaceState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const { enqueueSnackBar } = useSnackBar(); - const { redirectToHome, redirectToWorkspace } = useUrlManager(); + const { redirectToDefaultDomain } = useRedirectToDefaultDomain(); + const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); const switchWorkspace = async (workspaceId: string) => { if (currentWorkspace?.id === workspaceId) return; @@ -35,10 +37,10 @@ export const useWorkspaceSwitching = () => { }); if (isDefined(errors) || !isDefined(data?.switchWorkspace.subdomain)) { - return redirectToHome(); + return redirectToDefaultDomain(); } - redirectToWorkspace(data.switchWorkspace.subdomain); + redirectToWorkspaceDomain(data.switchWorkspace.subdomain); }; return { switchWorkspace }; diff --git a/packages/twenty-front/src/modules/url-manager/hooks/useUrlManager.ts b/packages/twenty-front/src/modules/url-manager/hooks/useUrlManager.ts deleted file mode 100644 index 8fabcedbd..000000000 --- a/packages/twenty-front/src/modules/url-manager/hooks/useUrlManager.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useMemo, useCallback } from 'react'; - -import { isDefined } from '~/utils/isDefined'; -import { urlManagerState } from '@/url-manager/states/url-manager.state'; -import { useRecoilValue } from 'recoil'; -import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; - -export const useUrlManager = () => { - const urlManager = useRecoilValue(urlManagerState); - const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); - - const homePageDomain = useMemo(() => { - return isMultiWorkspaceEnabled - ? `${urlManager.defaultSubdomain}.${urlManager.frontDomain}` - : urlManager.frontDomain; - }, [ - isMultiWorkspaceEnabled, - urlManager.defaultSubdomain, - urlManager.frontDomain, - ]); - - const isTwentyHomePage = useMemo(() => { - if (!isMultiWorkspaceEnabled) return true; - return window.location.hostname === homePageDomain; - }, [homePageDomain, isMultiWorkspaceEnabled]); - - const isTwentyWorkspaceSubdomain = useMemo(() => { - if (!isMultiWorkspaceEnabled) return false; - - if ( - !isDefined(urlManager.frontDomain) || - !isDefined(urlManager.defaultSubdomain) - ) { - throw new Error('frontDomain and defaultSubdomain are required'); - } - - return window.location.hostname !== homePageDomain; - }, [ - homePageDomain, - isMultiWorkspaceEnabled, - urlManager.defaultSubdomain, - urlManager.frontDomain, - ]); - - const getWorkspaceSubdomain = useMemo(() => { - if (!isDefined(urlManager.frontDomain)) { - throw new Error('frontDomain is not defined'); - } - - return isTwentyWorkspaceSubdomain - ? window.location.hostname.replace(`.${urlManager.frontDomain}`, '') - : null; - }, [isTwentyWorkspaceSubdomain, urlManager.frontDomain]); - - const buildWorkspaceUrl = useCallback( - ( - subdomain?: string, - onPage?: string, - searchParams?: Record, - ) => { - const url = new URL(window.location.href); - - if (isDefined(subdomain) && subdomain.length !== 0) { - url.hostname = `${subdomain}.${urlManager.frontDomain}`; - } - - if (isDefined(onPage)) { - url.pathname = onPage; - } - - if (isDefined(searchParams)) { - Object.entries(searchParams).forEach(([key, value]) => - url.searchParams.set(key, value), - ); - } - return url.toString(); - }, - [urlManager.frontDomain], - ); - - const redirectToWorkspace = useCallback( - ( - subdomain: string, - onPage?: string, - searchParams?: Record, - ) => { - if (!isMultiWorkspaceEnabled) return; - window.location.href = buildWorkspaceUrl(subdomain, onPage, searchParams); - }, - [buildWorkspaceUrl, isMultiWorkspaceEnabled], - ); - - const redirectToHome = useCallback(() => { - const url = new URL(window.location.href); - if (url.hostname !== homePageDomain) { - url.hostname = homePageDomain; - window.location.href = url.toString(); - } - }, [homePageDomain]); - - return { - redirectToHome, - redirectToWorkspace, - homePageDomain, - isTwentyHomePage, - buildWorkspaceUrl, - isTwentyWorkspaceSubdomain, - getWorkspaceSubdomain, - }; -}; diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx index 271c68a66..eb5d10319 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx @@ -1,79 +1,52 @@ -import { useRecoilValue, useSetRecoilState, useRecoilState } from 'recoil'; - -import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql'; +import { useRecoilValue } from 'recoil'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; -import { authProvidersState } from '@/client-config/states/authProvidersState'; import { useEffect } from 'react'; import { isDefined } from '~/utils/isDefined'; -import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; -import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; +import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; +import { lastAuthenticatedWorkspaceDomainState } from '@/domain-manager/states/lastAuthenticatedWorkspaceDomainState'; +import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation'; +import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain'; export const WorkspaceProviderEffect = () => { const workspacePublicData = useRecoilValue(workspacePublicDataState); - const setAuthProviders = useSetRecoilState(authProvidersState); - const setWorkspacePublicDataState = useSetRecoilState( - workspacePublicDataState, + const lastAuthenticatedWorkspaceDomain = useRecoilValue( + lastAuthenticatedWorkspaceDomainState, ); - const [lastAuthenticateWorkspace, setLastAuthenticateWorkspace] = - useRecoilState(lastAuthenticateWorkspaceState); + const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); + const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain(); - const { - redirectToHome, - getWorkspaceSubdomain, - redirectToWorkspace, - isTwentyHomePage, - } = useUrlManager(); + const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation(); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); - useGetPublicWorkspaceDataBySubdomainQuery({ - skip: - (isMultiWorkspaceEnabled && isTwentyHomePage) || - isDefined(workspacePublicData), - onCompleted: (data) => { - setAuthProviders(data.getPublicWorkspaceDataBySubdomain.authProviders); - setWorkspacePublicDataState(data.getPublicWorkspaceDataBySubdomain); - }, - onError: (error) => { - // eslint-disable-next-line no-console - console.error(error); - setLastAuthenticateWorkspace(null); - redirectToHome(); - }, - }); - useEffect(() => { - if ( - isMultiWorkspaceEnabled && - isDefined(workspacePublicData?.subdomain) && - workspacePublicData.subdomain !== getWorkspaceSubdomain - ) { - redirectToWorkspace(workspacePublicData.subdomain); + if (isMultiWorkspaceEnabled && isDefined(workspacePublicData?.subdomain)) { + redirectToWorkspaceDomain(workspacePublicData.subdomain); } }, [ - getWorkspaceSubdomain, + workspaceSubdomain, isMultiWorkspaceEnabled, - redirectToWorkspace, + redirectToWorkspaceDomain, workspacePublicData, ]); useEffect(() => { if ( isMultiWorkspaceEnabled && - isDefined(lastAuthenticateWorkspace?.subdomain) && - isTwentyHomePage + isDefined(lastAuthenticatedWorkspaceDomain?.subdomain) && + isDefaultDomain ) { - redirectToWorkspace(lastAuthenticateWorkspace.subdomain); + redirectToWorkspaceDomain(lastAuthenticatedWorkspaceDomain.subdomain); } }, [ isMultiWorkspaceEnabled, - isTwentyHomePage, - lastAuthenticateWorkspace, - redirectToWorkspace, + isDefaultDomain, + lastAuthenticatedWorkspaceDomain, + redirectToWorkspaceDomain, ]); return <>; diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 106c5425b..20b76a3ee 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -14,27 +14,33 @@ import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInU import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; -import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; import { useMemo } from 'react'; import { isDefined } from '~/utils/isDefined'; import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect'; +import { useGetPublicWorkspaceDataBySubdomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataBySubdomain'; +import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain'; +import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain'; export const SignInUp = () => { const { form } = useSignInUpForm(); const { signInUpStep } = useSignInUp(form); - const { isTwentyHomePage, isTwentyWorkspaceSubdomain } = useUrlManager(); - + const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain(); + const { isOnAWorkspaceSubdomain } = + useIsCurrentLocationOnAWorkspaceSubdomain(); const workspacePublicData = useRecoilValue(workspacePublicDataState); + const { loading } = useGetPublicWorkspaceDataBySubdomain(); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const signInUpForm = useMemo(() => { - if (isTwentyHomePage && isMultiWorkspaceEnabled) { + if (loading) return null; + + if (isDefaultDomain && isMultiWorkspaceEnabled) { return ; } if ( (!isMultiWorkspaceEnabled || - (isMultiWorkspaceEnabled && isTwentyWorkspaceSubdomain)) && + (isMultiWorkspaceEnabled && isOnAWorkspaceSubdomain)) && signInUpStep === SignInUpStep.SSOIdentityProviderSelection ) { return ; @@ -42,7 +48,7 @@ export const SignInUp = () => { if ( isDefined(workspacePublicData) && - (!isMultiWorkspaceEnabled || isTwentyWorkspaceSubdomain) + (!isMultiWorkspaceEnabled || isOnAWorkspaceSubdomain) ) { return ( <> @@ -54,9 +60,10 @@ export const SignInUp = () => { return ; }, [ - isTwentyHomePage, + isDefaultDomain, isMultiWorkspaceEnabled, - isTwentyWorkspaceSubdomain, + isOnAWorkspaceSubdomain, + loading, signInUpStep, workspacePublicData, ]); diff --git a/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx b/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx index 4f9b144f3..92eb85a66 100644 --- a/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx +++ b/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx @@ -24,7 +24,7 @@ import { import { isDefined } from '~/utils/isDefined'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { AppPath } from '@/types/AppPath'; -import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; +import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; const StyledContentContainer = styled.div` width: 100%; @@ -51,7 +51,7 @@ export const CreateWorkspace = () => { const { enqueueSnackBar } = useSnackBar(); const onboardingStatus = useOnboardingStatus(); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); - const { redirectToWorkspace } = useUrlManager(); + const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); const [activateWorkspace] = useActivateWorkspaceMutation(); const apolloMetadataClient = useApolloMetadataClient(); @@ -84,7 +84,7 @@ export const CreateWorkspace = () => { setIsCurrentUserLoaded(false); if (isDefined(result.data) && isMultiWorkspaceEnabled) { - return redirectToWorkspace( + return redirectToWorkspaceDomain( result.data.activateWorkspace.workspace.subdomain, AppPath.Verify, { @@ -111,7 +111,7 @@ export const CreateWorkspace = () => { setIsCurrentUserLoaded, isMultiWorkspaceEnabled, apolloMetadataClient, - redirectToWorkspace, + redirectToWorkspaceDomain, enqueueSnackBar, ], ); diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx index 18e7eaf60..d32c096a6 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx @@ -1,9 +1,12 @@ -import { GithubVersionLink, H2Title, Section, IconWorld } from 'twenty-ui'; -import { Link } from 'react-router-dom'; +import { + GithubVersionLink, + H2Title, + Section, + IconWorld, + UndecoratedLink, +} from 'twenty-ui'; import { useRecoilValue } from 'recoil'; -import styled from '@emotion/styled'; - import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; @@ -16,10 +19,6 @@ import packageJson from '../../../package.json'; import { SettingsCard } from '@/settings/components/SettingsCard'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; -const StyledLink = styled(Link)` - text-decoration: none; -`; - export const SettingsWorkspace = () => { const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); return ( @@ -48,9 +47,9 @@ export const SettingsWorkspace = () => { title="Domain" description="Edit your subdomain name or set a custom domain." /> - + } /> - + )} diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index 89f68f716..057c6f360 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -15,9 +15,9 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac import { useNavigate } from 'react-router-dom'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useUpdateWorkspaceMutation } from '~/generated/graphql'; -import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; -import { urlManagerState } from '@/url-manager/states/url-manager.state'; +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { isDefined } from '~/utils/isDefined'; +import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl'; const validationSchema = z .object({ @@ -39,17 +39,17 @@ const StyledDomain = styled.h2` color: ${({ theme }) => theme.font.color.secondary}; font-size: ${({ theme }) => theme.font.size.md}; font-weight: ${({ theme }) => theme.font.weight.medium}; - margin-left: 8px; + margin-left: ${({ theme }) => theme.spacing(2)}; `; export const SettingsDomain = () => { const navigate = useNavigate(); - const urlManager = useRecoilValue(urlManagerState); + const domainConfiguration = useRecoilValue(domainConfigurationState); const { enqueueSnackBar } = useSnackBar(); const [updateWorkspace] = useUpdateWorkspaceMutation(); - const { buildWorkspaceUrl } = useUrlManager(); + const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); const [currentWorkspace, setCurrentWorkspace] = useRecoilState( currentWorkspaceState, @@ -142,8 +142,8 @@ export const SettingsDomain = () => { /> )} /> - {isDefined(urlManager) && isDefined(urlManager.frontDomain) && ( - .{urlManager.frontDomain} + {isDefined(domainConfiguration.frontDomain) && ( + .{domainConfiguration.frontDomain} )} )} diff --git a/packages/twenty-front/src/utils/recoil-effects.ts b/packages/twenty-front/src/utils/recoil-effects.ts index 7f6f58657..e9e8b19ef 100644 --- a/packages/twenty-front/src/utils/recoil-effects.ts +++ b/packages/twenty-front/src/utils/recoil-effects.ts @@ -1,5 +1,6 @@ import { AtomEffect } from 'recoil'; import omit from 'lodash.omit'; +import { z } from 'zod'; import { cookieStorage } from '~/utils/cookie-storage'; @@ -20,6 +21,20 @@ export const localStorageEffect = }); }; +const customCookieAttributeZodSchema = z.object({ + cookieAttributes: z.object({ + expires: z.union([z.number(), z.instanceof(Date)]).optional(), + path: z.string().optional(), + domain: z.string().optional(), + secure: z.boolean().optional(), + }), +}); + +export const isCustomCookiesAttributesValue = ( + value: unknown, +): value is { cookieAttributes: Cookies.CookieAttributes } => + customCookieAttributeZodSchema.safeParse(value).success; + export const cookieStorageEffect = ( key: string, @@ -52,9 +67,7 @@ export const cookieStorageEffect = const cookieAttributes = { ...defaultAttributes, - ...(typeof newValue === 'object' && - 'cookieAttributes' in newValue && - typeof newValue.cookieAttributes === 'object' + ...(isCustomCookiesAttributesValue(newValue) ? newValue.cookieAttributes : {}), }; diff --git a/packages/twenty-front/vite.config.ts b/packages/twenty-front/vite.config.ts index 25d84582f..1cb1698d2 100644 --- a/packages/twenty-front/vite.config.ts +++ b/packages/twenty-front/vite.config.ts @@ -19,7 +19,9 @@ export default defineConfig(({ command, mode }) => { VITE_BUILD_SOURCEMAP, VITE_DISABLE_TYPESCRIPT_CHECKER, VITE_DISABLE_ESLINT_CHECKER, - VITE_ENABLE_SSL, + VITE_HOST, + SSL_CERT_PATH, + SSL_KEY_PATH, REACT_APP_PORT, } = env; @@ -64,27 +66,24 @@ export default defineConfig(({ command, mode }) => { }; } - if (VITE_ENABLE_SSL && (!env.SSL_KEY_PATH || !env.SSL_CERT_PATH)) { - throw new Error( - 'to use https SSL_KEY_PATH and SSL_CERT_PATH must be both defined', - ); - } - return { root: __dirname, cacheDir: '../../node_modules/.vite/packages/twenty-front', server: { port: port, - protocol: VITE_ENABLE_SSL ? 'https' : 'http', - ...(VITE_ENABLE_SSL + ...(VITE_HOST ? { host: VITE_HOST } : {}), + ...(SSL_KEY_PATH && SSL_CERT_PATH ? { + protocol: 'https', https: { key: fs.readFileSync(env.SSL_KEY_PATH), cert: fs.readFileSync(env.SSL_CERT_PATH), }, } - : {}), + : { + protocol: 'http', + }), fs: { allow: [ searchForWorkspaceRoot(process.cwd()),