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
33 changed files with 639 additions and 271 deletions

View File

@ -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 }) => (
<MockedProvider mocks={mocks} addTypename={false}>
@ -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,
},
};
},

View File

@ -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(

View File

@ -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,
{

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