fix(front): unblock email verification on the central domain (blank modal) (#21980)

## 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)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21980?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-06-23 08:19:33 +02:00
committed by GitHub
parent 24533b510c
commit 46642c81c9
2 changed files with 161 additions and 1 deletions
@@ -72,7 +72,9 @@ export const VerifyEmailEffect = () => {
email,
);
return enqueueSuccessSnackBar(successSnackbarParams);
enqueueSuccessSnackBar(successSnackbarParams);
return navigate(AppPath.SignInUp);
}
const { loginToken, workspaceUrls } = await verifyEmailAndGetLoginToken(
@@ -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(
<JotaiProvider store={jotaiStore}>
<ThemeProvider colorScheme="light">
<I18nProvider i18n={i18n}>
<MemoryRouter initialEntries={[initialEntry]}>
<VerifyEmailEffect />
</MemoryRouter>
</I18nProvider>
</ThemeProvider>
</JotaiProvider>,
);
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);
});
});