From 46642c81c988b235f6539b84c6af2d26007d2590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 23 Jun 2026 08:19:33 +0200 Subject: [PATCH] fix(front): unblock email verification on the central domain (blank modal) (#21980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem After clicking the email-verification link on the central domain (e.g. `app.twenty.com/verify-email?...`), a new user is left staring at a **blank white auth modal** and onboarding never continues. The email is actually verified — the user is just never moved off the verify-email page. ## Root cause `VerifyEmailEffect` (mounted on `/verify-email`) handles the central/workspace‑agnostic domain like this: ```tsx if (!isOnAWorkspace) { await verifyEmailAndGetWorkspaceAgnosticToken(emailVerificationToken, email); return enqueueSuccessSnackBar(successSnackbarParams); } ``` It renders nothing of its own in this branch (`return <>`) and relies entirely on the auth hook to navigate. The onboarding workspace-creation refactor (**#21641** "Let users pick their workspace subdomain during sign-up", refined by **#21723**) changed `navigateAfterMultiWorkspaceSignInUp`: - **Before:** a user with `0` workspaces was sent through `createWorkspace()`, which created the workspace and **redirected to the workspace subdomain** — navigating away from `/verify-email`. - **After:** for multi-workspace it now only does `setSignInUpStep(SignInUpStep.WorkspaceCreation)` (the new name/subdomain/logo form) — **no navigation**. `signInUpStepState` is read **only by the `SignInUp` page** (`/sign-in-up`), which renders `SignInUpWorkspaceCreationForm` for that step. But the user is on `/verify-email`, whose route renders only `VerifyEmailEffect` — which knows nothing about the step state and returns an empty fragment. Nothing bridges the gap (`usePageChangeEffectNavigateLocation` also won't redirect, because `/verify-email` is whitelisted in `ONGOING_USER_CREATION_PATHS`), so the user is stuck on an empty modal. ### Scope of the breakage - **Broken:** new user, multi-workspace instance (Twenty Cloud central domain), email verification enabled, signing up to create a workspace (`0` workspaces). The `2+`-workspaces case (`WorkspaceSelection`) is the same. - **Not affected:** the single existing-workspace case (still does a real `redirectToWorkspaceDomain`), the workspace-subdomain verification path (`verifyEmailAndGetLoginToken` → `verifyLoginToken`), and single-workspace self-host. ## Fix After a successful workspace-agnostic verification, hand off to the `SignInUp` page so it mounts and renders whatever step the hook just set: ```tsx if (!isOnAWorkspace) { await verifyEmailAndGetWorkspaceAgnosticToken(emailVerificationToken, email); enqueueSuccessSnackBar(successSnackbarParams); return navigate(AppPath.SignInUp); } ``` This is intentionally scoped to `VerifyEmailEffect` (the only entry point that lives on a route which doesn't host the sign-in-up step UI). The in-app sign-in/sign-up callers of `navigateAfterMultiWorkspaceSignInUp` are already on `/sign-in-up`, so they're untouched — keeping their query params (invite tokens, billing checkout, returnToPath) intact. For the single existing-workspace edge case, the hook's redirect still wins. ## Testing - New `VerifyEmailEffect.test.tsx`: - central-domain success → navigates to `AppPath.SignInUp` + shows the success snackbar; - failure → does **not** hand off to `SignInUp` (error state is shown); - workspace subdomain → workspace-scoped path is untouched (no workspace-agnostic call, no `SignInUp` hand-off). - `nx typecheck twenty-front` ✅, `oxlint --type-aware` + `oxfmt` on changed files ✅. https://claude.ai/code/session_017oVwW12hC42RdCgSKK8dFP --- _Generated by [Claude Code](https://claude.ai/code/session_017oVwW12hC42RdCgSKK8dFP)_ Review in cubic --- .../auth/components/VerifyEmailEffect.tsx | 4 +- .../__tests__/VerifyEmailEffect.test.tsx | 158 ++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-front/src/modules/auth/components/__tests__/VerifyEmailEffect.test.tsx diff --git a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx index 58656698c4..00260f061a 100644 --- a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx +++ b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx @@ -72,7 +72,9 @@ export const VerifyEmailEffect = () => { email, ); - return enqueueSuccessSnackBar(successSnackbarParams); + enqueueSuccessSnackBar(successSnackbarParams); + + return navigate(AppPath.SignInUp); } const { loginToken, workspaceUrls } = await verifyEmailAndGetLoginToken( diff --git a/packages/twenty-front/src/modules/auth/components/__tests__/VerifyEmailEffect.test.tsx b/packages/twenty-front/src/modules/auth/components/__tests__/VerifyEmailEffect.test.tsx new file mode 100644 index 0000000000..677982459a --- /dev/null +++ b/packages/twenty-front/src/modules/auth/components/__tests__/VerifyEmailEffect.test.tsx @@ -0,0 +1,158 @@ +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { render, waitFor } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; +import { MemoryRouter } from 'react-router-dom'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { AppPath } from 'twenty-shared/types'; +import { ThemeProvider } from 'twenty-ui/theme-constants'; + +import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect'; +import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; +import { + jotaiStore, + resetJotaiStore, +} from '@/ui/utilities/state/jotai/jotaiStore'; +import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; + +const navigateMock = jest.fn(); +const verifyEmailAndGetWorkspaceAgnosticTokenMock = jest.fn(); +const verifyEmailAndGetLoginTokenMock = jest.fn(); +const verifyLoginTokenMock = jest.fn(); +const redirectToWorkspaceDomainMock = jest.fn(); +const enqueueSuccessSnackBarMock = jest.fn(); +const enqueueErrorSnackBarMock = jest.fn(); + +let isOnAWorkspaceValue = false; + +jest.mock('@/auth/hooks/useAuth', () => ({ + useAuth: () => ({ + verifyEmailAndGetWorkspaceAgnosticToken: + verifyEmailAndGetWorkspaceAgnosticTokenMock, + verifyEmailAndGetLoginToken: verifyEmailAndGetLoginTokenMock, + }), +})); + +jest.mock('@/auth/hooks/useVerifyLogin', () => ({ + useVerifyLogin: () => ({ verifyLoginToken: verifyLoginTokenMock }), +})); + +jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace', () => ({ + useIsCurrentLocationOnAWorkspace: () => ({ + isOnAWorkspace: isOnAWorkspaceValue, + }), +})); + +jest.mock('@/domain-manager/hooks/useRedirectToWorkspaceDomain', () => ({ + useRedirectToWorkspaceDomain: () => ({ + redirectToWorkspaceDomain: redirectToWorkspaceDomainMock, + }), +})); + +jest.mock('~/hooks/useNavigateApp', () => ({ + useNavigateApp: () => navigateMock, +})); + +jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({ + useSnackBar: () => ({ + enqueueSuccessSnackBar: enqueueSuccessSnackBarMock, + enqueueErrorSnackBar: enqueueErrorSnackBarMock, + }), +})); + +// Rendered by VerifyEmailEffect in the error state; isolate it from Apollo. +jest.mock( + '@/auth/sign-in-up/hooks/useHandleResendEmailVerificationToken', + () => ({ + useHandleResendEmailVerificationToken: () => ({ + handleResendEmailVerificationToken: () => () => {}, + loading: false, + }), + }), +); + +dynamicActivate(SOURCE_LOCALE); + +const VERIFY_EMAIL_URL = + '/verify-email?email=user%40example.com&emailVerificationToken=valid-token'; + +const renderEffect = (initialEntry: string) => + render( + + + + + + + + + , + ); + +describe('VerifyEmailEffect', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetJotaiStore(); + isOnAWorkspaceValue = false; + // The verification effect is gated on the client config having loaded. + jotaiStore.set(clientConfigApiStatusState.atom, { + isLoadedOnce: true, + isLoading: false, + isErrored: false, + isSaved: false, + }); + }); + + it('navigates to the SignInUp page after a successful workspace-agnostic verification on the central domain', async () => { + verifyEmailAndGetWorkspaceAgnosticTokenMock.mockResolvedValue(undefined); + + renderEffect(VERIFY_EMAIL_URL); + + await waitFor(() => { + expect(verifyEmailAndGetWorkspaceAgnosticTokenMock).toHaveBeenCalledWith( + 'valid-token', + 'user@example.com', + ); + }); + + // The workspace-agnostic flow only sets the next sign-in-up step, so the + // effect must hand off to the SignInUp page for that step to render. + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith(AppPath.SignInUp); + }); + expect(enqueueSuccessSnackBarMock).toHaveBeenCalled(); + }); + + it('does not hand off to the SignInUp page when the verification fails', async () => { + verifyEmailAndGetWorkspaceAgnosticTokenMock.mockRejectedValue( + new Error('verification failed'), + ); + + renderEffect(VERIFY_EMAIL_URL); + + await waitFor(() => { + expect(enqueueErrorSnackBarMock).toHaveBeenCalled(); + }); + expect(navigateMock).not.toHaveBeenCalledWith(AppPath.SignInUp); + }); + + it('keeps the workspace-scoped verification path untouched when already on a workspace', async () => { + isOnAWorkspaceValue = true; + verifyEmailAndGetLoginTokenMock.mockResolvedValue({ + loginToken: { token: 'login-token' }, + workspaceUrls: { subdomainUrl: 'https://foo.twenty.com/' }, + }); + + renderEffect(VERIFY_EMAIL_URL); + + await waitFor(() => { + expect(verifyEmailAndGetLoginTokenMock).toHaveBeenCalledWith( + 'valid-token', + 'user@example.com', + ); + }); + + expect(verifyEmailAndGetWorkspaceAgnosticTokenMock).not.toHaveBeenCalled(); + expect(navigateMock).not.toHaveBeenCalledWith(AppPath.SignInUp); + }); +});