BREAKING - feat(auth): refactor tokens logic & enhance email verification flow (#13487)

- Replaced `getAuthTokensFromLoginToken` with
`getAccessTokensFromLoginToken` for clarity.
- Introduced `getWorkspaceAgnosticTokenFromEmailVerificationToken`.
- Extended mutation inputs to include `locale` and
`verifyEmailNextPath`.
- Added email verification check and sending to various handlers.
- Updated GraphQL types and hooks to reflect these changes.


Fix #13412

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Antoine Moreaux
2025-07-31 12:00:59 +02:00
committed by GitHub
parent 463e2e89c8
commit 00d12e854a
53 changed files with 594 additions and 306 deletions
@@ -3,7 +3,7 @@ import { AppPath } from '@/types/AppPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { verifyEmailNextPathState } from '@/app/states/verifyEmailNextPathState';
import { verifyEmailRedirectPathState } from '@/app/states/verifyEmailRedirectPathState';
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { Modal } from '@/ui/layout/modal/components/Modal';
@@ -15,24 +15,31 @@ import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificationSent';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
export const VerifyEmailEffect = () => {
const { getLoginTokenFromEmailVerificationToken } = useAuth();
const {
getLoginTokenFromEmailVerificationToken,
getWorkspaceAgnosticTokenFromEmailVerificationToken,
} = useAuth();
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [searchParams] = useSearchParams();
const [isError, setIsError] = useState(false);
const setVerifyEmailNextPath = useSetRecoilState(verifyEmailNextPathState);
const setVerifyEmailRedirectPath = useSetRecoilState(
verifyEmailRedirectPathState,
);
const email = searchParams.get('email');
const emailVerificationToken = searchParams.get('emailVerificationToken');
const verifyEmailNextPath = searchParams.get('nextPath');
const verifyEmailRedirectPath = searchParams.get('nextPath');
const navigate = useNavigateApp();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { verifyLoginToken } = useVerifyLogin();
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const { t } = useLingui();
useEffect(() => {
@@ -47,19 +54,30 @@ export const VerifyEmailEffect = () => {
return navigate(AppPath.SignInUp);
}
const successSnackbarParams = {
message: t`Email verified.`,
options: {
dedupeKey: 'email-verification-dedupe-key',
},
};
try {
if (!isOnAWorkspace) {
await getWorkspaceAgnosticTokenFromEmailVerificationToken(
emailVerificationToken,
email,
);
return enqueueSuccessSnackBar(successSnackbarParams);
}
const { loginToken, workspaceUrls } =
await getLoginTokenFromEmailVerificationToken(
emailVerificationToken,
email,
);
enqueueSuccessSnackBar({
message: t`Email verified.`,
options: {
dedupeKey: 'email-verification-dedupe-key',
},
});
enqueueSuccessSnackBar(successSnackbarParams);
const workspaceUrl = getWorkspaceUrl(workspaceUrls);
if (workspaceUrl.slice(0, -1) !== window.location.origin) {
@@ -68,11 +86,11 @@ export const VerifyEmailEffect = () => {
});
}
if (isDefined(verifyEmailNextPath)) {
setVerifyEmailNextPath(verifyEmailNextPath);
if (isDefined(verifyEmailRedirectPath)) {
setVerifyEmailRedirectPath(verifyEmailRedirectPath);
}
verifyLoginToken(loginToken.token);
return verifyLoginToken(loginToken.token);
} catch (error) {
enqueueErrorSnackBar({
...(error instanceof ApolloError
@@ -95,7 +113,6 @@ export const VerifyEmailEffect = () => {
};
verifyEmailToken();
// Verify email only needs to run once at mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -7,9 +7,9 @@ export const AUTH_TOKEN = gql`
}
`;
export const AUTH_TOKENS = gql`
fragment AuthTokensFragment on AuthTokenPair {
accessToken {
export const AUTH_TOKEN_PAIR = gql`
fragment AuthTokenPairFragment on AuthTokenPair {
accessOrWorkspaceAgnosticToken {
...AuthTokenFragment
}
refreshToken {
@@ -1,10 +1,10 @@
import { gql } from '@apollo/client';
export const GET_AUTH_TOKENS_FROM_LOGIN_TOKEN = gql`
mutation GetAuthTokensFromLoginToken($loginToken: String!, $origin: String!) {
mutation getAuthTokensFromLoginToken($loginToken: String!, $origin: String!) {
getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) {
tokens {
...AuthTokensFragment
...AuthTokenPairFragment
}
}
}
@@ -14,7 +14,7 @@ export const GET_AUTH_TOKENS_FROM_OTP = gql`
origin: $origin
) {
tokens {
...AuthTokensFragment
...AuthTokenPairFragment
}
}
}
@@ -0,0 +1,22 @@
import { gql } from '@apollo/client';
export const GET_WORKSPACE_AGNOSTIC_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
mutation GetWorkspaceAgnosticTokenFromEmailVerificationToken(
$emailVerificationToken: String!
$email: String!
$captchaToken: String
) {
getWorkspaceAgnosticTokenFromEmailVerificationToken(
emailVerificationToken: $emailVerificationToken
email: $email
captchaToken: $captchaToken
) {
availableWorkspaces {
...AvailableWorkspacesFragment
}
tokens {
...AuthTokenPairFragment
}
}
}
`;
@@ -4,7 +4,7 @@ export const RENEW_TOKEN = gql`
mutation RenewToken($appToken: String!) {
renewToken(appToken: $appToken) {
tokens {
...AuthTokensFragment
...AuthTokenPairFragment
}
}
}
@@ -7,7 +7,7 @@ export const SIGN_IN = gql`
...AvailableWorkspacesFragment
}
tokens {
...AuthTokensFragment
...AuthTokenPairFragment
}
}
}
@@ -1,13 +1,25 @@
import { gql } from '@apollo/client';
export const SIGN_UP = gql`
mutation SignUp($email: String!, $password: String!, $captchaToken: String) {
signUp(email: $email, password: $password, captchaToken: $captchaToken) {
mutation SignUp(
$email: String!
$password: String!
$captchaToken: String
$locale: String
$verifyEmailRedirectPath: String
) {
signUp(
email: $email
password: $password
captchaToken: $captchaToken
locale: $locale
verifyEmailRedirectPath: $verifyEmailRedirectPath
) {
availableWorkspaces {
...AvailableWorkspacesFragment
}
tokens {
...AuthTokensFragment
...AuthTokenPairFragment
}
}
}
@@ -9,7 +9,7 @@ export const SIGN_UP_IN_WORKSPACE = gql`
$captchaToken: String
$workspaceId: String
$locale: String
$verifyEmailNextPath: String
$verifyEmailRedirectPath: String
) {
signUpInWorkspace(
email: $email
@@ -19,7 +19,7 @@ export const SIGN_UP_IN_WORKSPACE = gql`
captchaToken: $captchaToken
workspaceId: $workspaceId
locale: $locale
verifyEmailNextPath: $verifyEmailNextPath
verifyEmailRedirectPath: $verifyEmailRedirectPath
) {
loginToken {
...AuthTokenFragment
@@ -1,9 +1,9 @@
import {
GetAuthTokensFromLoginTokenDocument,
GetCurrentUserDocument,
GetLoginTokenFromCredentialsDocument,
SignUpDocument,
SignUpInWorkspaceDocument,
GetAuthTokensFromLoginTokenDocument,
GetCurrentUserDocument,
GetLoginTokenFromCredentialsDocument,
SignUpDocument,
SignUpInWorkspaceDocument,
} from '~/generated-metadata/graphql';
export const queries = {
@@ -51,7 +51,7 @@ export const results = {
},
getAuthTokensFromLoginToken: {
tokens: {
accessToken: { token, expiresAt: 'expiresAt' },
accessOrWorkspaceAgnosticToken: { token, expiresAt: 'expiresAt' },
refreshToken: { token, expiresAt: 'expiresAt' },
},
},
@@ -30,7 +30,7 @@ describe('useIsLogged', () => {
await act(async () => {
result.current.setTokenPair({
accessToken: {
accessOrWorkspaceAgnosticToken: {
expiresAt: '',
token: 'testToken',
},
@@ -24,6 +24,7 @@ import {
useGetCurrentUserLazyQuery,
useGetLoginTokenFromCredentialsMutation,
useGetLoginTokenFromEmailVerificationTokenMutation,
useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation,
useSignInMutation,
useSignUpInWorkspaceMutation,
useSignUpMutation,
@@ -72,7 +73,7 @@ import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefres
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { i18n } from '@lingui/core';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { APP_LOCALES } from 'twenty-shared/translations';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { iconsState } from 'twenty-ui/display';
import { AuthToken } from '~/generated/graphql';
@@ -117,6 +118,8 @@ export const useAuth = () => {
useGetAuthTokensFromLoginTokenMutation();
const [getLoginTokenFromEmailVerificationToken] =
useGetLoginTokenFromEmailVerificationTokenMutation();
const [getWorkspaceAgnosticTokenFromEmailVerificationToken] =
useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery();
const [getAuthTokensFromOtp] = useGetAuthTokensFromOtpMutation();
@@ -204,6 +207,111 @@ export const useAuth = () => {
[navigate, client, goToRecoilSnapshot, setLastAuthenticateWorkspaceDomain],
);
const loadCurrentUser = useCallback(async () => {
const currentUserResult = await getCurrentUser({
fetchPolicy: 'network-only',
});
if (isDefined(currentUserResult.error)) {
throw new Error(currentUserResult.error.message);
}
const user = currentUserResult.data?.currentUser;
if (!user) {
throw new Error('No current user result');
}
let workspaceMember = null;
setCurrentUser(user);
if (isDefined(user.workspaceMembers)) {
const workspaceMembers = user.workspaceMembers.map((workspaceMember) => ({
...workspaceMember,
colorScheme: workspaceMember.colorScheme as ColorScheme,
locale: workspaceMember.locale ?? SOURCE_LOCALE,
}));
setCurrentWorkspaceMembers(workspaceMembers);
}
if (isDefined(user.availableWorkspaces)) {
setAvailableWorkspaces(user.availableWorkspaces);
}
if (isDefined(user.currentUserWorkspace)) {
setCurrentUserWorkspace(user.currentUserWorkspace);
}
if (isDefined(user.workspaceMember)) {
workspaceMember = {
...user.workspaceMember,
colorScheme: user.workspaceMember?.colorScheme as ColorScheme,
locale: user.workspaceMember?.locale ?? SOURCE_LOCALE,
};
setCurrentWorkspaceMember(workspaceMember);
// TODO: factorize with UserProviderEffect
setDateTimeFormat({
timeZone:
workspaceMember.timeZone && workspaceMember.timeZone !== 'system'
? workspaceMember.timeZone
: detectTimeZone(),
dateFormat: isDefined(user.workspaceMember.dateFormat)
? getDateFormatFromWorkspaceDateFormat(
user.workspaceMember.dateFormat,
)
: DateFormat[detectDateFormat()],
timeFormat: isDefined(user.workspaceMember.timeFormat)
? getTimeFormatFromWorkspaceTimeFormat(
user.workspaceMember.timeFormat,
)
: TimeFormat[detectTimeFormat()],
});
dynamicActivate(
(workspaceMember.locale as keyof typeof APP_LOCALES) ?? SOURCE_LOCALE,
);
}
const workspace = user.currentWorkspace ?? null;
setCurrentWorkspace(workspace);
if (isDefined(workspace) && isOnAWorkspace) {
setLastAuthenticateWorkspaceDomain({
workspaceId: workspace.id,
workspaceUrl: getWorkspaceUrl(workspace.workspaceUrls),
});
}
return {
user,
workspaceMember,
workspace,
};
}, [
getCurrentUser,
isOnAWorkspace,
setCurrentUser,
setCurrentUserWorkspace,
setCurrentWorkspace,
setCurrentWorkspaceMember,
setCurrentWorkspaceMembers,
setDateTimeFormat,
setLastAuthenticateWorkspaceDomain,
setAvailableWorkspaces,
]);
const handleSetAuthTokens = useCallback(
(tokens: AuthTokenPair) => {
setTokenPair(tokens);
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
},
[setTokenPair],
);
const handleGetLoginTokenFromCredentials = useCallback(
async (email: string, password: string, captchaToken?: string) => {
try {
@@ -268,109 +376,48 @@ export const useAuth = () => {
[getLoginTokenFromEmailVerificationToken, origin],
);
const loadCurrentUser = useCallback(async () => {
const currentUserResult = await getCurrentUser({
fetchPolicy: 'network-only',
});
const handleGetWorkspaceAgnosticTokenFromEmailVerificationToken = useCallback(
async (
emailVerificationToken: string,
email: string,
captchaToken?: string,
) => {
const { data, errors } =
await getWorkspaceAgnosticTokenFromEmailVerificationToken({
variables: {
email,
emailVerificationToken,
captchaToken,
},
});
if (isDefined(currentUserResult.error)) {
throw new Error(currentUserResult.error.message);
}
if (isDefined(errors)) {
throw errors;
}
const user = currentUserResult.data?.currentUser;
if (!data?.getWorkspaceAgnosticTokenFromEmailVerificationToken) {
throw new Error('No workspace agnostic token in result');
}
if (!user) {
throw new Error('No current user result');
}
let workspaceMember = null;
setCurrentUser(user);
if (isDefined(user.workspaceMembers)) {
const workspaceMembers = user.workspaceMembers.map((workspaceMember) => ({
...workspaceMember,
colorScheme: workspaceMember.colorScheme as ColorScheme,
locale: workspaceMember.locale ?? 'en',
}));
setCurrentWorkspaceMembers(workspaceMembers);
}
if (isDefined(user.availableWorkspaces)) {
setAvailableWorkspaces(user.availableWorkspaces);
}
if (isDefined(user.currentUserWorkspace)) {
setCurrentUserWorkspace(user.currentUserWorkspace);
}
if (isDefined(user.workspaceMember)) {
workspaceMember = {
...user.workspaceMember,
colorScheme: user.workspaceMember?.colorScheme as ColorScheme,
locale: user.workspaceMember?.locale ?? 'en',
};
setCurrentWorkspaceMember(workspaceMember);
// TODO: factorize with UserProviderEffect
setDateTimeFormat({
timeZone:
workspaceMember.timeZone && workspaceMember.timeZone !== 'system'
? workspaceMember.timeZone
: detectTimeZone(),
dateFormat: isDefined(user.workspaceMember.dateFormat)
? getDateFormatFromWorkspaceDateFormat(
user.workspaceMember.dateFormat,
)
: DateFormat[detectDateFormat()],
timeFormat: isDefined(user.workspaceMember.timeFormat)
? getTimeFormatFromWorkspaceTimeFormat(
user.workspaceMember.timeFormat,
)
: TimeFormat[detectTimeFormat()],
});
dynamicActivate(
(workspaceMember.locale as keyof typeof APP_LOCALES) ?? 'en',
handleSetAuthTokens(
data.getWorkspaceAgnosticTokenFromEmailVerificationToken.tokens,
);
}
const workspace = user.currentWorkspace ?? null;
const { user } = await loadCurrentUser();
setCurrentWorkspace(workspace);
if (countAvailableWorkspaces(user.availableWorkspaces) === 0) {
return await createWorkspace({ newTab: false });
}
if (isDefined(workspace) && isOnAWorkspace) {
setLastAuthenticateWorkspaceDomain({
workspaceId: workspace.id,
workspaceUrl: getWorkspaceUrl(workspace.workspaceUrls),
});
}
return {
user,
workspaceMember,
workspace,
};
}, [
getCurrentUser,
isOnAWorkspace,
setCurrentUser,
setCurrentUserWorkspace,
setCurrentWorkspace,
setCurrentWorkspaceMember,
setCurrentWorkspaceMembers,
setDateTimeFormat,
setLastAuthenticateWorkspaceDomain,
setAvailableWorkspaces,
]);
const handleSetAuthTokens = useCallback(
(tokens: AuthTokenPair) => {
setTokenPair(tokens);
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
[setTokenPair],
[
createWorkspace,
getWorkspaceAgnosticTokenFromEmailVerificationToken,
handleSetAuthTokens,
loadCurrentUser,
setSignInUpStep,
],
);
const handleSetLoginToken = useCallback(
@@ -507,13 +554,24 @@ export const useAuth = () => {
const handleCredentialsSignUp = useCallback(
async (email: string, password: string, captchaToken?: string) => {
const signUpResult = await signUp({
variables: { email, password, captchaToken },
variables: {
email,
password,
captchaToken,
locale: i18n.locale ?? SOURCE_LOCALE,
},
});
if (isDefined(signUpResult.errors)) {
throw signUpResult.errors;
}
if (isEmailVerificationRequired) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
return null;
}
if (!signUpResult.data?.signUp) {
throw new Error('No signUp result');
}
@@ -529,6 +587,8 @@ export const useAuth = () => {
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
[
isEmailVerificationRequired,
setSearchParams,
handleSetAuthTokens,
signUp,
loadCurrentUser,
@@ -561,14 +621,14 @@ export const useAuth = () => {
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken,
verifyEmailNextPath,
verifyEmailRedirectPath,
}: {
email: string;
password: string;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
captchaToken?: string;
verifyEmailNextPath?: string;
verifyEmailRedirectPath?: string;
}) => {
const signUpInWorkspaceResult = await signUpInWorkspace({
variables: {
@@ -577,11 +637,11 @@ export const useAuth = () => {
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken,
locale: i18n.locale ?? 'en',
locale: i18n.locale ?? SOURCE_LOCALE,
...(workspacePublicData?.id
? { workspaceId: workspacePublicData.id }
: {}),
verifyEmailNextPath,
verifyEmailRedirectPath,
},
});
@@ -712,7 +772,7 @@ export const useAuth = () => {
}
if (!getAuthTokensFromOtpResult.data?.getAuthTokensFromOTP) {
throw new Error('No getAuthTokensFromLoginToken result');
throw new Error('No getAuthTokensFromOTP result');
}
await handleLoadWorkspaceAfterAuthentication(
@@ -724,6 +784,8 @@ export const useAuth = () => {
return {
getLoginTokenFromCredentials: handleGetLoginTokenFromCredentials,
getWorkspaceAgnosticTokenFromEmailVerificationToken:
handleGetWorkspaceAgnosticTokenFromEmailVerificationToken,
getLoginTokenFromEmailVerificationToken:
handleGetLoginTokenFromEmailVerificationToken,
getAuthTokensFromLoginToken: handleGetAuthTokensFromLoginToken,
@@ -6,7 +6,7 @@ import { renewToken } from '@/auth/services/AuthService';
enableFetchMocks();
const tokens = {
accessToken: {
accessOrWorkspaceAgnosticToken: {
token: 'accessToken',
expiresAt: 'expiresAt',
},
@@ -130,7 +130,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
);
}
const verifyEmailNextPath = buildAppPathWithQueryParams(
const verifyEmailRedirectPath = buildAppPathWithQueryParams(
AppPath.PlanRequired,
await buildSearchParamsFromUrlSyncedStates(),
);
@@ -141,7 +141,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken: token,
verifyEmailNextPath,
verifyEmailRedirectPath,
});
} catch (error: any) {
enqueueErrorSnackBar({
@@ -1,6 +1,6 @@
import { createState } from 'twenty-ui/utilities';
import { AuthTokenPair } from '~/generated/graphql';
import { cookieStorageEffect } from '~/utils/recoil-effects';
import { createState } from 'twenty-ui/utilities';
export const tokenPairState = createState<AuthTokenPair | null>({
key: 'tokenPairState',
@@ -11,7 +11,7 @@ export const tokenPairState = createState<AuthTokenPair | null>({
{},
{
validateInitFn: (payload: AuthTokenPair) =>
Boolean(payload['accessToken']),
Boolean(payload['accessOrWorkspaceAgnosticToken']),
},
),
],