review(front): refacto url-manager (#8861)

Replace https://github.com/twentyhq/twenty/pull/8855
This commit is contained in:
Antoine Moreaux 2024-12-05 11:47:51 +01:00 committed by GitHub
parent 7ab00a4c82
commit 081ecbcfaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 639 additions and 271 deletions

View File

@ -7,5 +7,6 @@ GENERATE_SOURCEMAP=false
# VITE_DISABLE_TYPESCRIPT_CHECKER=true # VITE_DISABLE_TYPESCRIPT_CHECKER=true
# VITE_DISABLE_ESLINT_CHECKER=true # VITE_DISABLE_ESLINT_CHECKER=true
# VITE_ENABLE_SSL=false # VITE_ENABLE_SSL=false
# VITE_HOST=localhost.com
# SSL_KEY_PATH="./certs/your-cert.key" # SSL_KEY_PATH="./certs/your-cert.key"
# SSL_CERT_PATH="./certs/your-cert.crt" # SSL_CERT_PATH="./certs/your-cert.crt"

View File

@ -14,6 +14,7 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i
import { supportChatState } from '@/client-config/states/supportChatState'; import { supportChatState } from '@/client-config/states/supportChatState';
import { email, mocks, password, results, token } from '../__mocks__/useAuth'; import { email, mocks, password, results, token } from '../__mocks__/useAuth';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
const Wrapper = ({ children }: { children: ReactNode }) => ( const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks} addTypename={false}> <MockedProvider mocks={mocks} addTypename={false}>
@ -83,6 +84,9 @@ describe('useAuth', () => {
); );
const supportChat = useRecoilValue(supportChatState); const supportChat = useRecoilValue(supportChatState);
const isDebugMode = useRecoilValue(isDebugModeState); const isDebugMode = useRecoilValue(isDebugModeState);
const isMultiWorkspaceEnabled = useRecoilValue(
isMultiWorkspaceEnabledState,
);
return { return {
...useAuth(), ...useAuth(),
client, client,
@ -93,6 +97,7 @@ describe('useAuth', () => {
isDeveloperDefaultSignInPrefilled, isDeveloperDefaultSignInPrefilled,
supportChat, supportChat,
isDebugMode, isDebugMode,
isMultiWorkspaceEnabled,
}, },
}; };
}, },

View File

@ -4,7 +4,6 @@ import {
snapshot_UNSTABLE, snapshot_UNSTABLE,
useGotoRecoilSnapshot, useGotoRecoilSnapshot,
useRecoilCallback, useRecoilCallback,
useRecoilValue,
useSetRecoilState, useSetRecoilState,
} from 'recoil'; } from 'recoil';
import { iconsState } from 'twenty-ui'; import { iconsState } from 'twenty-ui';
@ -42,18 +41,15 @@ import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDa
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat'; import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat';
import { currentUserState } from '../states/currentUserState'; import { currentUserState } from '../states/currentUserState';
import { tokenPairState } from '../states/tokenPairState'; import { tokenPairState } from '../states/tokenPairState';
import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState';
import { urlManagerState } from '@/url-manager/states/url-manager.state'; import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain';
import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation';
export const useAuth = () => { export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState); const setTokenPair = useSetRecoilState(tokenPairState);
const setCurrentUser = useSetRecoilState(currentUserState); const setCurrentUser = useSetRecoilState(currentUserState);
const urlManager = useRecoilValue(urlManagerState);
const setLastAuthenticateWorkspaceState = useSetRecoilState(
lastAuthenticateWorkspaceState,
);
const setCurrentWorkspaceMember = useSetRecoilState( const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState, currentWorkspaceMemberState,
); );
@ -68,7 +64,12 @@ export const useAuth = () => {
const [challenge] = useChallengeMutation(); const [challenge] = useChallengeMutation();
const [signUp] = useSignUpMutation(); const [signUp] = useSignUpMutation();
const [verify] = useVerifyMutation(); const [verify] = useVerifyMutation();
const { isTwentyWorkspaceSubdomain, getWorkspaceSubdomain } = useUrlManager(); const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain();
const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation();
const { setLastAuthenticateWorkspaceDomain } =
useLastAuthenticatedWorkspaceDomain();
const [checkUserExistsQuery, { data: checkUserExistsData }] = const [checkUserExistsQuery, { data: checkUserExistsData }] =
useCheckUserExistsLazyQuery(); useCheckUserExistsLazyQuery();
@ -101,6 +102,9 @@ export const useAuth = () => {
const isCurrentUserLoaded = snapshot const isCurrentUserLoaded = snapshot
.getLoadable(isCurrentUserLoadedState) .getLoadable(isCurrentUserLoadedState)
.getValue(); .getValue();
const isMultiWorkspaceEnabled = snapshot
.getLoadable(isMultiWorkspaceEnabledState)
.getValue();
const initialSnapshot = emptySnapshot.map(({ set }) => { const initialSnapshot = emptySnapshot.map(({ set }) => {
set(iconsState, iconsValue); set(iconsState, iconsValue);
set(authProvidersState, authProvidersValue); set(authProvidersState, authProvidersValue);
@ -114,6 +118,7 @@ export const useAuth = () => {
set(captchaProviderState, captchaProvider); set(captchaProviderState, captchaProvider);
set(clientConfigApiStatusState, clientConfigApiStatus); set(clientConfigApiStatusState, clientConfigApiStatus);
set(isCurrentUserLoadedState, isCurrentUserLoaded); set(isCurrentUserLoadedState, isCurrentUserLoaded);
set(isMultiWorkspaceEnabledState, isMultiWorkspaceEnabled);
return undefined; return undefined;
}); });
goToRecoilSnapshot(initialSnapshot); goToRecoilSnapshot(initialSnapshot);
@ -212,13 +217,11 @@ export const useAuth = () => {
const workspace = user.defaultWorkspace ?? null; const workspace = user.defaultWorkspace ?? null;
setCurrentWorkspace(workspace); setCurrentWorkspace(workspace);
if (isDefined(workspace) && isTwentyWorkspaceSubdomain) {
setLastAuthenticateWorkspaceState({ if (isDefined(workspace) && isOnAWorkspaceSubdomain) {
id: workspace.id, setLastAuthenticateWorkspaceDomain({
workspaceId: workspace.id,
subdomain: workspace.subdomain, subdomain: workspace.subdomain,
cookieAttributes: {
domain: `.${urlManager.frontDomain}`,
},
}); });
} }
@ -245,12 +248,11 @@ export const useAuth = () => {
setTokenPair, setTokenPair,
setCurrentUser, setCurrentUser,
setCurrentWorkspace, setCurrentWorkspace,
isTwentyWorkspaceSubdomain, isOnAWorkspaceSubdomain,
setCurrentWorkspaceMembers, setCurrentWorkspaceMembers,
setCurrentWorkspaceMember, setCurrentWorkspaceMember,
setDateTimeFormat, setDateTimeFormat,
setLastAuthenticateWorkspaceState, setLastAuthenticateWorkspaceDomain,
urlManager.frontDomain,
setWorkspaces, setWorkspaces,
], ],
); );
@ -340,15 +342,13 @@ export const useAuth = () => {
params.workspacePersonalInviteToken, params.workspacePersonalInviteToken,
); );
} }
const subdomain = getWorkspaceSubdomain; if (isDefined(workspaceSubdomain)) {
url.searchParams.set('workspaceSubdomain', workspaceSubdomain);
if (isDefined(subdomain)) {
url.searchParams.set('workspaceSubdomain', subdomain);
} }
return url.toString(); return url.toString();
}, },
[getWorkspaceSubdomain], [workspaceSubdomain],
); );
const handleGoogleLogin = useCallback( const handleGoogleLogin = useCallback(

View File

@ -30,8 +30,8 @@ import { useAuth } from '@/auth/hooks/useAuth';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { signInUpModeState } from '@/auth/states/signInUpModeState'; import { signInUpModeState } from '@/auth/states/signInUpModeState';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
import { SignInUpMode } from '@/auth/types/signInUpMode'; import { SignInUpMode } from '@/auth/types/signInUpMode';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
const StyledContentContainer = styled(motion.div)` const StyledContentContainer = styled(motion.div)`
margin-bottom: ${({ theme }) => theme.spacing(8)}; margin-bottom: ${({ theme }) => theme.spacing(8)};
@ -53,8 +53,7 @@ export const SignInUpGlobalScopeForm = () => {
const { signInWithMicrosoft } = useSignInWithMicrosoft(); const { signInWithMicrosoft } = useSignInWithMicrosoft();
const { checkUserExists } = useAuth(); const { checkUserExists } = useAuth();
const { readCaptchaToken } = useReadCaptchaToken(); const { readCaptchaToken } = useReadCaptchaToken();
const { redirectToWorkspace } = useUrlManager(); const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const setSignInUpStep = useSetRecoilState(signInUpStepState); const setSignInUpStep = useSetRecoilState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
@ -97,7 +96,7 @@ export const SignInUpGlobalScopeForm = () => {
isDefined(data?.checkUserExists.availableWorkspaces) && isDefined(data?.checkUserExists.availableWorkspaces) &&
data.checkUserExists.availableWorkspaces.length >= 1 data.checkUserExists.availableWorkspaces.length >= 1
) { ) {
return redirectToWorkspace( return redirectToWorkspaceDomain(
data?.checkUserExists.availableWorkspaces[0].subdomain, data?.checkUserExists.availableWorkspaces[0].subdomain,
pathname, pathname,
{ {

View File

@ -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,
});
});
});

View File

@ -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',
});
});
});

View File

@ -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,
});
});
});

View File

@ -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,
});
});
});

View File

@ -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<Workspace, 'id' | 'subdomain'> & {
cookieAttributes?: Cookies.CookieAttributes;
})
| null
>({
key: 'lastAuthenticateWorkspaceState',
defaultValue: null,
effects: [
cookieStorageEffect('lastAuthenticateWorkspace', {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year
}),
],
});

View File

@ -13,13 +13,13 @@ import { useEffect } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil'; import { useRecoilState, useSetRecoilState } from 'recoil';
import { useGetClientConfigQuery } from '~/generated/graphql'; import { useGetClientConfigQuery } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined'; 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'; import { isSSOEnabledState } from '@/client-config/states/isSSOEnabledState';
export const ClientConfigProviderEffect = () => { export const ClientConfigProviderEffect = () => {
const setIsDebugMode = useSetRecoilState(isDebugModeState); const setIsDebugMode = useSetRecoilState(isDebugModeState);
const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState); const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState);
const setUrlManager = useSetRecoilState(urlManagerState); const setDomainConfiguration = useSetRecoilState(domainConfigurationState);
const setIsDeveloperDefaultSignInPrefilled = useSetRecoilState( const setIsDeveloperDefaultSignInPrefilled = useSetRecoilState(
isDeveloperDefaultSignInPrefilledState, isDeveloperDefaultSignInPrefilledState,
@ -77,7 +77,6 @@ export const ClientConfigProviderEffect = () => {
setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled); setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled);
setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled); setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled);
setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled); setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled);
setBilling(data?.clientConfig.billing); setBilling(data?.clientConfig.billing);
setSupportChat(data?.clientConfig.support); setSupportChat(data?.clientConfig.support);
@ -95,7 +94,7 @@ export const ClientConfigProviderEffect = () => {
setChromeExtensionId(data?.clientConfig?.chromeExtensionId); setChromeExtensionId(data?.clientConfig?.chromeExtensionId);
setApiConfig(data?.clientConfig?.api); setApiConfig(data?.clientConfig?.api);
setIsSSOEnabledState(data?.clientConfig?.isSSOEnabled); setIsSSOEnabledState(data?.clientConfig?.isSSOEnabled);
setUrlManager({ setDomainConfiguration({
defaultSubdomain: data?.clientConfig?.defaultSubdomain, defaultSubdomain: data?.clientConfig?.defaultSubdomain,
frontDomain: data?.clientConfig?.frontDomain, frontDomain: data?.clientConfig?.frontDomain,
}); });
@ -114,7 +113,7 @@ export const ClientConfigProviderEffect = () => {
setApiConfig, setApiConfig,
setIsAnalyticsEnabled, setIsAnalyticsEnabled,
error, error,
setUrlManager, setDomainConfiguration,
setIsSSOEnabledState, setIsSSOEnabledState,
]); ]);

View File

@ -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<string, string>,
) => {
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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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<string, string>,
) => {
if (!isMultiWorkspaceEnabled) return;
window.location.href = buildWorkspaceUrl(subdomain, pathname, searchParams);
};
return {
redirectToWorkspaceDomain,
};
};

View File

@ -1,10 +1,10 @@
import { createState } from 'twenty-ui'; import { createState } from 'twenty-ui';
import { ClientConfig } from '~/generated/graphql'; import { ClientConfig } from '~/generated/graphql';
export const urlManagerState = createState< export const domainConfigurationState = createState<
Pick<ClientConfig, 'frontDomain' | 'defaultSubdomain'> Pick<ClientConfig, 'frontDomain' | 'defaultSubdomain'>
>({ >({
key: 'urlManager', key: 'domainConfiguration',
defaultValue: { defaultValue: {
frontDomain: '', frontDomain: '',
defaultSubdomain: undefined, defaultSubdomain: undefined,

View File

@ -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
}),
],
});

View File

@ -61,7 +61,8 @@ export const SettingsSecurityOptionsList = () => {
if ( if (
currentWorkspace[key] === true && currentWorkspace[key] === true &&
allAuthProvidersEnabled.filter((isAuthEnable) => isAuthEnable).length <= 1 allAuthProvidersEnabled.filter((isAuthEnabled) => isAuthEnabled).length <=
1
) { ) {
return enqueueSnackBar( return enqueueSnackBar(
'At least one authentication method must be enabled', 'At least one authentication method must be enabled',

View File

@ -13,15 +13,13 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useState } from 'react'; import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil'; 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 { getImageAbsoluteURI } from '~/utils/image/getImageAbsoluteURI';
import { Link } from 'react-router-dom'; import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
const StyledLink = styled(Link)`
text-decoration: none;
width: 100%;
`;
const StyledLogo = styled.div<{ logo: string }>` const StyledLogo = styled.div<{ logo: string }>`
background: url(${({ logo }) => logo}); background: url(${({ logo }) => logo});
@ -79,7 +77,7 @@ export const MultiWorkspaceDropdownButton = ({
useState(false); useState(false);
const { switchWorkspace } = useWorkspaceSwitching(); const { switchWorkspace } = useWorkspaceSwitching();
const { buildWorkspaceUrl } = useUrlManager(); const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID); const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
@ -122,9 +120,13 @@ export const MultiWorkspaceDropdownButton = ({
dropdownComponents={ dropdownComponents={
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer>
{workspaces.map((workspace) => ( {workspaces.map((workspace) => (
<StyledLink <UndecoratedLink
key={workspace.id} key={workspace.id}
to={buildWorkspaceUrl(workspace.subdomain)} to={buildWorkspaceUrl(workspace.subdomain)}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace.id);
}}
> >
<MenuItemSelectAvatar <MenuItemSelectAvatar
text={workspace.displayName ?? ''} text={workspace.displayName ?? ''}
@ -136,12 +138,8 @@ export const MultiWorkspaceDropdownButton = ({
/> />
} }
selected={currentWorkspace?.id === workspace.id} selected={currentWorkspace?.id === workspace.id}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace.id);
}}
/> />
</StyledLink> </UndecoratedLink>
))} ))}
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
} }

View File

@ -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 { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSwitchWorkspaceMutation } from '~/generated/graphql'; import { useSwitchWorkspaceMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined'; 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 = () => { export const useWorkspaceSwitching = () => {
const [switchWorkspaceMutation] = useSwitchWorkspaceMutation(); const [switchWorkspaceMutation] = useSwitchWorkspaceMutation();
const currentWorkspace = useRecoilValue(currentWorkspaceState); const currentWorkspace = useRecoilValue(currentWorkspaceState);
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const { redirectToHome, redirectToWorkspace } = useUrlManager(); const { redirectToDefaultDomain } = useRedirectToDefaultDomain();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const switchWorkspace = async (workspaceId: string) => { const switchWorkspace = async (workspaceId: string) => {
if (currentWorkspace?.id === workspaceId) return; if (currentWorkspace?.id === workspaceId) return;
@ -35,10 +37,10 @@ export const useWorkspaceSwitching = () => {
}); });
if (isDefined(errors) || !isDefined(data?.switchWorkspace.subdomain)) { if (isDefined(errors) || !isDefined(data?.switchWorkspace.subdomain)) {
return redirectToHome(); return redirectToDefaultDomain();
} }
redirectToWorkspace(data.switchWorkspace.subdomain); redirectToWorkspaceDomain(data.switchWorkspace.subdomain);
}; };
return { switchWorkspace }; return { switchWorkspace };

View File

@ -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<string, string>,
) => {
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<string, string>,
) => {
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,
};
};

View File

@ -1,79 +1,52 @@
import { useRecoilValue, useSetRecoilState, useRecoilState } from 'recoil'; import { useRecoilValue } from 'recoil';
import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; 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 = () => { export const WorkspaceProviderEffect = () => {
const workspacePublicData = useRecoilValue(workspacePublicDataState); const workspacePublicData = useRecoilValue(workspacePublicDataState);
const setAuthProviders = useSetRecoilState(authProvidersState); const lastAuthenticatedWorkspaceDomain = useRecoilValue(
const setWorkspacePublicDataState = useSetRecoilState( lastAuthenticatedWorkspaceDomainState,
workspacePublicDataState,
); );
const [lastAuthenticateWorkspace, setLastAuthenticateWorkspace] = const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
useRecoilState(lastAuthenticateWorkspaceState); const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain();
const { const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation();
redirectToHome,
getWorkspaceSubdomain,
redirectToWorkspace,
isTwentyHomePage,
} = useUrlManager();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); 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(() => { useEffect(() => {
if ( if (isMultiWorkspaceEnabled && isDefined(workspacePublicData?.subdomain)) {
isMultiWorkspaceEnabled && redirectToWorkspaceDomain(workspacePublicData.subdomain);
isDefined(workspacePublicData?.subdomain) &&
workspacePublicData.subdomain !== getWorkspaceSubdomain
) {
redirectToWorkspace(workspacePublicData.subdomain);
} }
}, [ }, [
getWorkspaceSubdomain, workspaceSubdomain,
isMultiWorkspaceEnabled, isMultiWorkspaceEnabled,
redirectToWorkspace, redirectToWorkspaceDomain,
workspacePublicData, workspacePublicData,
]); ]);
useEffect(() => { useEffect(() => {
if ( if (
isMultiWorkspaceEnabled && isMultiWorkspaceEnabled &&
isDefined(lastAuthenticateWorkspace?.subdomain) && isDefined(lastAuthenticatedWorkspaceDomain?.subdomain) &&
isTwentyHomePage isDefaultDomain
) { ) {
redirectToWorkspace(lastAuthenticateWorkspace.subdomain); redirectToWorkspaceDomain(lastAuthenticatedWorkspaceDomain.subdomain);
} }
}, [ }, [
isMultiWorkspaceEnabled, isMultiWorkspaceEnabled,
isTwentyHomePage, isDefaultDomain,
lastAuthenticateWorkspace, lastAuthenticatedWorkspaceDomain,
redirectToWorkspace, redirectToWorkspaceDomain,
]); ]);
return <></>; return <></>;

View File

@ -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 { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection'; import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect'; 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 = () => { export const SignInUp = () => {
const { form } = useSignInUpForm(); const { form } = useSignInUpForm();
const { signInUpStep } = useSignInUp(form); const { signInUpStep } = useSignInUp(form);
const { isTwentyHomePage, isTwentyWorkspaceSubdomain } = useUrlManager(); const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain();
const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain();
const workspacePublicData = useRecoilValue(workspacePublicDataState); const workspacePublicData = useRecoilValue(workspacePublicDataState);
const { loading } = useGetPublicWorkspaceDataBySubdomain();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const signInUpForm = useMemo(() => { const signInUpForm = useMemo(() => {
if (isTwentyHomePage && isMultiWorkspaceEnabled) { if (loading) return null;
if (isDefaultDomain && isMultiWorkspaceEnabled) {
return <SignInUpGlobalScopeForm />; return <SignInUpGlobalScopeForm />;
} }
if ( if (
(!isMultiWorkspaceEnabled || (!isMultiWorkspaceEnabled ||
(isMultiWorkspaceEnabled && isTwentyWorkspaceSubdomain)) && (isMultiWorkspaceEnabled && isOnAWorkspaceSubdomain)) &&
signInUpStep === SignInUpStep.SSOIdentityProviderSelection signInUpStep === SignInUpStep.SSOIdentityProviderSelection
) { ) {
return <SignInUpSSOIdentityProviderSelection />; return <SignInUpSSOIdentityProviderSelection />;
@ -42,7 +48,7 @@ export const SignInUp = () => {
if ( if (
isDefined(workspacePublicData) && isDefined(workspacePublicData) &&
(!isMultiWorkspaceEnabled || isTwentyWorkspaceSubdomain) (!isMultiWorkspaceEnabled || isOnAWorkspaceSubdomain)
) { ) {
return ( return (
<> <>
@ -54,9 +60,10 @@ export const SignInUp = () => {
return <SignInUpGlobalScopeForm />; return <SignInUpGlobalScopeForm />;
}, [ }, [
isTwentyHomePage, isDefaultDomain,
isMultiWorkspaceEnabled, isMultiWorkspaceEnabled,
isTwentyWorkspaceSubdomain, isOnAWorkspaceSubdomain,
loading,
signInUpStep, signInUpStep,
workspacePublicData, workspacePublicData,
]); ]);

View File

@ -24,7 +24,7 @@ import {
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
const StyledContentContainer = styled.div` const StyledContentContainer = styled.div`
width: 100%; width: 100%;
@ -51,7 +51,7 @@ export const CreateWorkspace = () => {
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const onboardingStatus = useOnboardingStatus(); const onboardingStatus = useOnboardingStatus();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { redirectToWorkspace } = useUrlManager(); const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [activateWorkspace] = useActivateWorkspaceMutation(); const [activateWorkspace] = useActivateWorkspaceMutation();
const apolloMetadataClient = useApolloMetadataClient(); const apolloMetadataClient = useApolloMetadataClient();
@ -84,7 +84,7 @@ export const CreateWorkspace = () => {
setIsCurrentUserLoaded(false); setIsCurrentUserLoaded(false);
if (isDefined(result.data) && isMultiWorkspaceEnabled) { if (isDefined(result.data) && isMultiWorkspaceEnabled) {
return redirectToWorkspace( return redirectToWorkspaceDomain(
result.data.activateWorkspace.workspace.subdomain, result.data.activateWorkspace.workspace.subdomain,
AppPath.Verify, AppPath.Verify,
{ {
@ -111,7 +111,7 @@ export const CreateWorkspace = () => {
setIsCurrentUserLoaded, setIsCurrentUserLoaded,
isMultiWorkspaceEnabled, isMultiWorkspaceEnabled,
apolloMetadataClient, apolloMetadataClient,
redirectToWorkspace, redirectToWorkspaceDomain,
enqueueSnackBar, enqueueSnackBar,
], ],
); );

View File

@ -1,9 +1,12 @@
import { GithubVersionLink, H2Title, Section, IconWorld } from 'twenty-ui'; import {
import { Link } from 'react-router-dom'; GithubVersionLink,
H2Title,
Section,
IconWorld,
UndecoratedLink,
} from 'twenty-ui';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import styled from '@emotion/styled';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace'; import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
@ -16,10 +19,6 @@ import packageJson from '../../../package.json';
import { SettingsCard } from '@/settings/components/SettingsCard'; import { SettingsCard } from '@/settings/components/SettingsCard';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const SettingsWorkspace = () => { export const SettingsWorkspace = () => {
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
return ( return (
@ -48,9 +47,9 @@ export const SettingsWorkspace = () => {
title="Domain" title="Domain"
description="Edit your subdomain name or set a custom domain." description="Edit your subdomain name or set a custom domain."
/> />
<StyledLink to={getSettingsPagePath(SettingsPath.Domain)}> <UndecoratedLink to={getSettingsPagePath(SettingsPath.Domain)}>
<SettingsCard title="Customize Domain" Icon={<IconWorld />} /> <SettingsCard title="Customize Domain" Icon={<IconWorld />} />
</StyledLink> </UndecoratedLink>
</Section> </Section>
)} )}

View File

@ -15,9 +15,9 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useUpdateWorkspaceMutation } from '~/generated/graphql'; import { useUpdateWorkspaceMutation } from '~/generated/graphql';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { urlManagerState } from '@/url-manager/states/url-manager.state';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
const validationSchema = z const validationSchema = z
.object({ .object({
@ -39,17 +39,17 @@ const StyledDomain = styled.h2`
color: ${({ theme }) => theme.font.color.secondary}; color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md}; font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium}; font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: 8px; margin-left: ${({ theme }) => theme.spacing(2)};
`; `;
export const SettingsDomain = () => { export const SettingsDomain = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const urlManager = useRecoilValue(urlManagerState); const domainConfiguration = useRecoilValue(domainConfigurationState);
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const [updateWorkspace] = useUpdateWorkspaceMutation(); const [updateWorkspace] = useUpdateWorkspaceMutation();
const { buildWorkspaceUrl } = useUrlManager(); const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState( const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState, currentWorkspaceState,
@ -142,8 +142,8 @@ export const SettingsDomain = () => {
/> />
)} )}
/> />
{isDefined(urlManager) && isDefined(urlManager.frontDomain) && ( {isDefined(domainConfiguration.frontDomain) && (
<StyledDomain>.{urlManager.frontDomain}</StyledDomain> <StyledDomain>.{domainConfiguration.frontDomain}</StyledDomain>
)} )}
</StyledDomainFromWrapper> </StyledDomainFromWrapper>
)} )}

View File

@ -1,5 +1,6 @@
import { AtomEffect } from 'recoil'; import { AtomEffect } from 'recoil';
import omit from 'lodash.omit'; import omit from 'lodash.omit';
import { z } from 'zod';
import { cookieStorage } from '~/utils/cookie-storage'; 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 = export const cookieStorageEffect =
<T>( <T>(
key: string, key: string,
@ -52,9 +67,7 @@ export const cookieStorageEffect =
const cookieAttributes = { const cookieAttributes = {
...defaultAttributes, ...defaultAttributes,
...(typeof newValue === 'object' && ...(isCustomCookiesAttributesValue(newValue)
'cookieAttributes' in newValue &&
typeof newValue.cookieAttributes === 'object'
? newValue.cookieAttributes ? newValue.cookieAttributes
: {}), : {}),
}; };

View File

@ -19,7 +19,9 @@ export default defineConfig(({ command, mode }) => {
VITE_BUILD_SOURCEMAP, VITE_BUILD_SOURCEMAP,
VITE_DISABLE_TYPESCRIPT_CHECKER, VITE_DISABLE_TYPESCRIPT_CHECKER,
VITE_DISABLE_ESLINT_CHECKER, VITE_DISABLE_ESLINT_CHECKER,
VITE_ENABLE_SSL, VITE_HOST,
SSL_CERT_PATH,
SSL_KEY_PATH,
REACT_APP_PORT, REACT_APP_PORT,
} = env; } = 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 { return {
root: __dirname, root: __dirname,
cacheDir: '../../node_modules/.vite/packages/twenty-front', cacheDir: '../../node_modules/.vite/packages/twenty-front',
server: { server: {
port: port, port: port,
protocol: VITE_ENABLE_SSL ? 'https' : 'http', ...(VITE_HOST ? { host: VITE_HOST } : {}),
...(VITE_ENABLE_SSL ...(SSL_KEY_PATH && SSL_CERT_PATH
? { ? {
protocol: 'https',
https: { https: {
key: fs.readFileSync(env.SSL_KEY_PATH), key: fs.readFileSync(env.SSL_KEY_PATH),
cert: fs.readFileSync(env.SSL_CERT_PATH), cert: fs.readFileSync(env.SSL_CERT_PATH),
}, },
} }
: {}), : {
protocol: 'http',
}),
fs: { fs: {
allow: [ allow: [
searchForWorkspaceRoot(process.cwd()), searchForWorkspaceRoot(process.cwd()),