Files
twenty/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
T
Ranjeet Baraik 0cbd1e8ec5 fix: login redirection to active objects (#15366)
Fixes - https://github.com/twentyhq/twenty/issues/15364

- Removed `activeNonSystemObjectMetadataItems` from the hook as it was
not necessary. Hook use `alphaSortedActiveNonSystemObjectMetadataItems`
now.
- Correctly check for read permissions.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2025-10-30 17:31:26 +01:00

702 lines
23 KiB
TypeScript

import { ApolloError, useApolloClient } from '@apollo/client';
import { useCallback } from 'react';
import {
snapshot_UNSTABLE,
useGotoRecoilSnapshot,
useRecoilCallback,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
import { AppPath } from 'twenty-shared/types';
import { billingState } from '@/client-config/states/billingState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
useCheckUserExistsLazyQuery,
useGetAuthTokensFromLoginTokenMutation,
useGetAuthTokensFromOtpMutation,
useGetLoginTokenFromCredentialsMutation,
useGetLoginTokenFromEmailVerificationTokenMutation,
useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation,
useSignInMutation,
useSignUpInWorkspaceMutation,
useSignUpMutation,
type AuthTokenPair,
} from '~/generated-metadata/graphql';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { tokenPairState } from '../states/tokenPairState';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { type BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
import {
countAvailableWorkspaces,
getFirstAvailableWorkspaces,
} from '@/auth/utils/availableWorkspacesUtils';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { captchaState } from '@/client-config/states/captchaState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useOrigin } from '@/domain-manager/hooks/useOrigin';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { i18n } from '@lingui/core';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { iconsState } from 'twenty-ui/display';
import { type AuthToken } from '~/generated/graphql';
import { cookieStorage } from '~/utils/cookie-storage';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { loginTokenState } from '../states/loginTokenState';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
const setLoginToken = useSetRecoilState(loginTokenState);
const setIsAppEffectRedirectEnabled = useSetRecoilState(
isAppEffectRedirectEnabledState,
);
const { origin } = useOrigin();
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const isCaptchaScriptLoaded = useRecoilValue(isCaptchaScriptLoadedState);
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const isEmailVerificationRequired = useRecoilValue(
isEmailVerificationRequiredState,
);
const { loadCurrentUser } = useLoadCurrentUser();
const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const { redirect } = useRedirect();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [getLoginTokenFromCredentials] =
useGetLoginTokenFromCredentialsMutation();
const [signIn] = useSignInMutation();
const [signUp] = useSignUpMutation();
const [signUpInWorkspace] = useSignUpInWorkspaceMutation();
const [getAuthTokensFromLoginToken] =
useGetAuthTokensFromLoginTokenMutation();
const [getLoginTokenFromEmailVerificationToken] =
useGetLoginTokenFromEmailVerificationTokenMutation();
const [getWorkspaceAgnosticTokenFromEmailVerificationToken] =
useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation();
const [getAuthTokensFromOtp] = useGetAuthTokensFromOtpMutation();
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const { setLastAuthenticateWorkspaceDomain } =
useLastAuthenticatedWorkspaceDomain();
const [checkUserExistsQuery, { data: checkUserExistsData }] =
useCheckUserExistsLazyQuery();
const client = useApolloClient();
const goToRecoilSnapshot = useGotoRecoilSnapshot();
const [, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const { loadMockedObjectMetadataItems } = useLoadMockedObjectMetadataItems();
const clearSession = useRecoilCallback(
({ snapshot }) =>
async () => {
const emptySnapshot = snapshot_UNSTABLE();
const iconsValue = snapshot.getLoadable(iconsState).getValue();
const authProvidersValue = snapshot
.getLoadable(workspaceAuthProvidersState)
.getValue();
const billing = snapshot.getLoadable(billingState).getValue();
const isDeveloperDefaultSignInPrefilled = snapshot
.getLoadable(isDeveloperDefaultSignInPrefilledState)
.getValue();
const supportChat = snapshot.getLoadable(supportChatState).getValue();
const captcha = snapshot.getLoadable(captchaState).getValue();
const clientConfigApiStatus = snapshot
.getLoadable(clientConfigApiStatusState)
.getValue();
const isCurrentUserLoaded = snapshot
.getLoadable(isCurrentUserLoadedState)
.getValue();
const isMultiWorkspaceEnabled = snapshot
.getLoadable(isMultiWorkspaceEnabledState)
.getValue();
const domainConfiguration = snapshot
.getLoadable(domainConfigurationState)
.getValue();
const apiConfig = snapshot.getLoadable(apiConfigState).getValue();
const sentryConfig = snapshot.getLoadable(sentryConfigState).getValue();
const workspacePublicData = snapshot
.getLoadable(workspacePublicDataState)
.getValue();
const initialSnapshot = emptySnapshot.map(({ set }) => {
set(iconsState, iconsValue);
set(workspaceAuthProvidersState, authProvidersValue);
set(billingState, billing);
set(
isDeveloperDefaultSignInPrefilledState,
isDeveloperDefaultSignInPrefilled,
);
set(supportChatState, supportChat);
set(captchaState, captcha);
set(apiConfigState, apiConfig);
set(sentryConfigState, sentryConfig);
set(workspacePublicDataState, workspacePublicData);
set(clientConfigApiStatusState, clientConfigApiStatus);
set(isCurrentUserLoadedState, isCurrentUserLoaded);
set(isMultiWorkspaceEnabledState, isMultiWorkspaceEnabled);
set(domainConfigurationState, domainConfiguration);
set(isCaptchaScriptLoadedState, isCaptchaScriptLoaded);
return undefined;
});
goToRecoilSnapshot(initialSnapshot);
sessionStorage.clear();
localStorage.clear();
await client.clearStore();
// We need to explicitly clear the state to trigger the cookie deletion which include the parent domain
setLastAuthenticateWorkspaceDomain(null);
await loadMockedObjectMetadataItems();
navigate(AppPath.SignInUp);
},
[
goToRecoilSnapshot,
client,
setLastAuthenticateWorkspaceDomain,
loadMockedObjectMetadataItems,
navigate,
isCaptchaScriptLoaded,
],
);
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 {
const getLoginTokenResult = await getLoginTokenFromCredentials({
variables: {
email,
password,
captchaToken,
origin,
},
});
if (isDefined(getLoginTokenResult.errors)) {
throw getLoginTokenResult.errors;
}
if (!getLoginTokenResult.data?.getLoginTokenFromCredentials) {
throw new Error('No login token');
}
return getLoginTokenResult.data.getLoginTokenFromCredentials;
} catch (error) {
// TODO: Get intellisense for graphql error extensions code (codegen?)
if (
error instanceof ApolloError &&
error.graphQLErrors[0]?.extensions?.subCode === 'EMAIL_NOT_VERIFIED'
) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
throw error;
}
throw error;
}
},
[getLoginTokenFromCredentials, setSearchParams, setSignInUpStep, origin],
);
const handleGetLoginTokenFromEmailVerificationToken = useCallback(
async (
emailVerificationToken: string,
email: string,
captchaToken?: string,
) => {
const loginTokenResult = await getLoginTokenFromEmailVerificationToken({
variables: {
email,
emailVerificationToken,
captchaToken,
origin,
},
});
if (isDefined(loginTokenResult.errors)) {
throw loginTokenResult.errors;
}
if (!loginTokenResult.data?.getLoginTokenFromEmailVerificationToken) {
throw new Error('No login token');
}
return loginTokenResult.data.getLoginTokenFromEmailVerificationToken;
},
[getLoginTokenFromEmailVerificationToken, origin],
);
const handleGetWorkspaceAgnosticTokenFromEmailVerificationToken = useCallback(
async (
emailVerificationToken: string,
email: string,
captchaToken?: string,
) => {
const { data, errors } =
await getWorkspaceAgnosticTokenFromEmailVerificationToken({
variables: {
email,
emailVerificationToken,
captchaToken,
},
});
if (isDefined(errors)) {
throw errors;
}
if (!data?.getWorkspaceAgnosticTokenFromEmailVerificationToken) {
throw new Error('No workspace agnostic token in result');
}
handleSetAuthTokens(
data.getWorkspaceAgnosticTokenFromEmailVerificationToken.tokens,
);
const { user } = await loadCurrentUser();
if (countAvailableWorkspaces(user.availableWorkspaces) === 0) {
return await createWorkspace({ newTab: false });
}
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
[
createWorkspace,
getWorkspaceAgnosticTokenFromEmailVerificationToken,
handleSetAuthTokens,
loadCurrentUser,
setSignInUpStep,
],
);
const handleSetLoginToken = useCallback(
(token: AuthToken['token']) => {
setLoginToken(token);
},
[setLoginToken],
);
const handleLoadWorkspaceAfterAuthentication = useCallback(
async (authTokens: AuthTokenPair) => {
handleSetAuthTokens(authTokens);
setIsAppEffectRedirectEnabled(false);
// TODO: We can't parallelize this yet because when loadCurrentUSer is loaded
// then UserProvider updates its children and PrefetchDataProvider is then triggered
// which requires the correct metadata to be loaded (not the mocks)
await loadCurrentUser();
await refreshObjectMetadataItems();
},
[
loadCurrentUser,
handleSetAuthTokens,
refreshObjectMetadataItems,
setIsAppEffectRedirectEnabled,
],
);
const handleGetAuthTokensFromLoginToken = useCallback(
async (loginToken: string) => {
try {
const getAuthTokensResult = await getAuthTokensFromLoginToken({
variables: {
loginToken: loginToken,
origin,
},
});
if (isDefined(getAuthTokensResult.errors)) {
throw getAuthTokensResult.errors;
}
if (!getAuthTokensResult.data?.getAuthTokensFromLoginToken) {
throw new Error('No getAuthTokensFromLoginToken result');
}
await handleLoadWorkspaceAfterAuthentication(
getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens,
);
} catch (error) {
if (
error instanceof ApolloError &&
error.graphQLErrors[0]?.extensions?.subCode ===
'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED'
) {
handleSetLoginToken(loginToken);
navigate(AppPath.SignInUp);
setSignInUpStep(SignInUpStep.TwoFactorAuthenticationProvision);
}
if (
error instanceof ApolloError &&
error.graphQLErrors[0]?.extensions?.subCode ===
'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED'
) {
handleSetLoginToken(loginToken);
navigate(AppPath.SignInUp);
setSignInUpStep(SignInUpStep.TwoFactorAuthenticationVerification);
}
}
},
[
handleSetLoginToken,
getAuthTokensFromLoginToken,
origin,
handleLoadWorkspaceAfterAuthentication,
setSignInUpStep,
navigate,
],
);
const handleCredentialsSignIn = useCallback(
async (email: string, password: string, captchaToken?: string) => {
signIn({
variables: { email, password, captchaToken },
onCompleted: async (data) => {
handleSetAuthTokens(data.signIn.tokens);
const { user } = await loadCurrentUser();
const availableWorkspacesCount = countAvailableWorkspaces(
user.availableWorkspaces,
);
if (availableWorkspacesCount === 0) {
return createWorkspace();
}
if (availableWorkspacesCount === 1) {
const targetWorkspace = getFirstAvailableWorkspaces(
user.availableWorkspaces,
);
return await redirectToWorkspaceDomain(
getWorkspaceUrl(targetWorkspace.workspaceUrls),
targetWorkspace.loginToken ? AppPath.Verify : AppPath.SignInUp,
{
...(targetWorkspace.loginToken && {
loginToken: targetWorkspace.loginToken,
}),
email: user.email,
},
);
}
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
onError: (error) => {
if (
error instanceof ApolloError &&
error.graphQLErrors[0]?.extensions?.subCode === 'EMAIL_NOT_VERIFIED'
) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
throw error;
}
throw error;
},
});
},
[
handleSetAuthTokens,
redirectToWorkspaceDomain,
signIn,
loadCurrentUser,
setSearchParams,
setSignInUpStep,
createWorkspace,
],
);
const handleCredentialsSignUp = useCallback(
async (email: string, password: string, captchaToken?: string) => {
const signUpResult = await signUp({
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');
}
handleSetAuthTokens(signUpResult.data.signUp.tokens);
const { user } = await loadCurrentUser();
if (countAvailableWorkspaces(user.availableWorkspaces) === 0) {
return await createWorkspace({ newTab: false });
}
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
[
isEmailVerificationRequired,
setSearchParams,
handleSetAuthTokens,
signUp,
loadCurrentUser,
setSignInUpStep,
createWorkspace,
],
);
const handleCredentialsSignInInWorkspace = useCallback(
async (email: string, password: string, captchaToken?: string) => {
const { loginToken } = await handleGetLoginTokenFromCredentials(
email,
password,
captchaToken,
);
await handleGetAuthTokensFromLoginToken(loginToken.token);
},
[handleGetLoginTokenFromCredentials, handleGetAuthTokensFromLoginToken],
);
const handleSignOut = useCallback(async () => {
await clearSession();
if (isCaptchaScriptLoaded) await requestFreshCaptchaToken();
}, [clearSession, isCaptchaScriptLoaded, requestFreshCaptchaToken]);
const handleCredentialsSignUpInWorkspace = useCallback(
async ({
email,
password,
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken,
verifyEmailRedirectPath,
}: {
email: string;
password: string;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
captchaToken?: string;
verifyEmailRedirectPath?: string;
}) => {
const signUpInWorkspaceResult = await signUpInWorkspace({
variables: {
email,
password,
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken,
locale: i18n.locale ?? SOURCE_LOCALE,
...(workspacePublicData?.id
? { workspaceId: workspacePublicData.id }
: {}),
verifyEmailRedirectPath,
},
});
if (isDefined(signUpInWorkspaceResult.errors)) {
throw signUpInWorkspaceResult.errors;
}
if (!signUpInWorkspaceResult.data?.signUpInWorkspace) {
throw new Error('No login token');
}
if (isEmailVerificationRequired) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
return null;
}
if (isMultiWorkspaceEnabled) {
return await redirectToWorkspaceDomain(
getWorkspaceUrl(
signUpInWorkspaceResult.data.signUpInWorkspace.workspace
.workspaceUrls,
),
isEmailVerificationRequired ? AppPath.SignInUp : AppPath.Verify,
{
...(!isEmailVerificationRequired && {
loginToken:
signUpInWorkspaceResult.data.signUpInWorkspace.loginToken.token,
}),
email,
},
);
}
await handleGetAuthTokensFromLoginToken(
signUpInWorkspaceResult.data?.signUpInWorkspace.loginToken.token,
);
},
[
signUpInWorkspace,
workspacePublicData,
isMultiWorkspaceEnabled,
handleGetAuthTokensFromLoginToken,
setSignInUpStep,
setSearchParams,
isEmailVerificationRequired,
redirectToWorkspaceDomain,
],
);
const buildRedirectUrl = useCallback(
(
path: string,
params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
billingCheckoutSession?: BillingCheckoutSession;
action?: string;
},
) => {
const url = new URL(`${REACT_APP_SERVER_BASE_URL}${path}`);
if (isDefined(params.workspaceInviteHash)) {
url.searchParams.set('workspaceInviteHash', params.workspaceInviteHash);
}
if (isDefined(params.workspacePersonalInviteToken)) {
url.searchParams.set(
'inviteToken',
params.workspacePersonalInviteToken,
);
}
if (isDefined(params.billingCheckoutSession)) {
url.searchParams.set(
'billingCheckoutSessionState',
JSON.stringify(params.billingCheckoutSession),
);
}
if (isDefined(params.action)) {
url.searchParams.set('action', params.action);
}
if (isDefined(workspacePublicData)) {
url.searchParams.set('workspaceId', workspacePublicData.id);
}
return url.toString();
},
[workspacePublicData],
);
const handleGoogleLogin = useCallback(
(params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
billingCheckoutSession?: BillingCheckoutSession;
action: string;
}) => {
redirect(buildRedirectUrl('/auth/google', params));
},
[buildRedirectUrl, redirect],
);
const handleMicrosoftLogin = useCallback(
(params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
billingCheckoutSession?: BillingCheckoutSession;
action: string;
}) => {
redirect(buildRedirectUrl('/auth/microsoft', params));
},
[buildRedirectUrl, redirect],
);
const handleGetAuthTokensFromOTP = useCallback(
async (otp: string, loginToken: string, captchaToken?: string) => {
const getAuthTokensFromOtpResult = await getAuthTokensFromOtp({
variables: {
captchaToken,
origin,
otp,
loginToken,
},
});
if (isDefined(getAuthTokensFromOtpResult.errors)) {
throw getAuthTokensFromOtpResult.errors;
}
if (!getAuthTokensFromOtpResult.data?.getAuthTokensFromOTP) {
throw new Error('No getAuthTokensFromOTP result');
}
await handleLoadWorkspaceAfterAuthentication(
getAuthTokensFromOtpResult.data.getAuthTokensFromOTP.tokens,
);
},
[getAuthTokensFromOtp, origin, handleLoadWorkspaceAfterAuthentication],
);
return {
getLoginTokenFromCredentials: handleGetLoginTokenFromCredentials,
getWorkspaceAgnosticTokenFromEmailVerificationToken:
handleGetWorkspaceAgnosticTokenFromEmailVerificationToken,
getLoginTokenFromEmailVerificationToken:
handleGetLoginTokenFromEmailVerificationToken,
getAuthTokensFromLoginToken: handleGetAuthTokensFromLoginToken,
checkUserExists: { checkUserExistsData, checkUserExistsQuery },
clearSession,
signOut: handleSignOut,
signUpWithCredentials: handleCredentialsSignUp,
signUpWithCredentialsInWorkspace: handleCredentialsSignUpInWorkspace,
signInWithCredentialsInWorkspace: handleCredentialsSignInInWorkspace,
signInWithCredentials: handleCredentialsSignIn,
signInWithGoogle: handleGoogleLogin,
signInWithMicrosoft: handleMicrosoftLogin,
setAuthTokens: handleSetAuthTokens,
getAuthTokensFromOTP: handleGetAuthTokensFromOTP,
};
};