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:
@@ -72,7 +72,9 @@ export const VerifyEmailEffect = () => {
|
||||
email,
|
||||
);
|
||||
|
||||
return enqueueSuccessSnackBar(successSnackbarParams);
|
||||
enqueueSuccessSnackBar(successSnackbarParams);
|
||||
|
||||
return navigate(AppPath.SignInUp);
|
||||
}
|
||||
|
||||
const { loginToken, workspaceUrls } = await verifyEmailAndGetLoginToken(
|
||||
|
||||
+158
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user