Add Email Verification for non-Microsoft/Google Emails (#9288)
Closes twentyhq/twenty#8240 This PR introduces email verification for non-Microsoft/Google Emails: ## Email Verification SignInUp Flow: https://github.com/user-attachments/assets/740e9714-5413-4fd8-b02e-ace728ea47ef The email verification link is sent as part of the `SignInUpStep.EmailVerification`. The email verification token validation is handled on a separate page (`AppPath.VerifyEmail`). A verification email resend can be triggered from both pages. ## Email Verification Flow Screenshots (In Order):    ## Sent Email Details (Subject & Template):   ### Successful Email Verification Redirect:  ### Unsuccessful Email Verification (invalid token, invalid email, token expired, user does not exist, etc.):  ### Force Sign In When Email Not Verified:  # TODOs: ## Sign Up Process - [x] Introduce server-level environment variable IS_EMAIL_VERIFICATION_REQUIRED (defaults to false) - [x] Ensure users joining an existing workspace through an invite are not required to validate their email - [x] Generate an email verification token - [x] Store the token in appToken - [x] Send email containing the verification link - [x] Create new email template for email verification - [x] Create a frontend page to handle verification requests ## Sign In Process - [x] After verifying user credentials, check if user's email is verified and prompt to to verify - [x] Show an option to resend the verification email ## Database - [x] Rename the `emailVerified` colum on `user` to to `isEmailVerified` for consistency ## During Deployment - [x] Run a script/sql query to set `isEmailVerified` to `true` for all users with a Google/Microsoft email and all users that show an indication of a valid subscription (e.g. linked credit card) - I have created a draft migration file below that shows one possible approach to implementing this change: ```typescript import { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateEmailVerifiedForActiveUsers1733318043628 implements MigrationInterface { name = 'UpdateEmailVerifiedForActiveUsers1733318043628'; public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(` CREATE TABLE core."user_email_verified_backup" AS SELECT id, email, "isEmailVerified" FROM core."user" WHERE "deletedAt" IS NULL; `); await queryRunner.query(` -- Update isEmailVerified for users who have been part of workspaces with active subscriptions UPDATE core."user" u SET "isEmailVerified" = true WHERE EXISTS ( -- Check if user has been part of a workspace through userWorkspace table SELECT 1 FROM core."userWorkspace" uw JOIN core."workspace" w ON uw."workspaceId" = w.id WHERE uw."userId" = u.id -- Check for valid subscription indicators AND ( w."activationStatus" = 'ACTIVE' -- Add any other subscription-related conditions here ) ) AND u."deletedAt" IS NULL; `); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(` UPDATE core."user" u SET "isEmailVerified" = b."isEmailVerified" FROM core."user_email_verified_backup" b WHERE u.id = b.id; `); await queryRunner.query(`DROP TABLE core."user_email_verified_backup";`); } } ``` --------- Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@ -1,6 +1,8 @@
|
||||
import { AppRouterProviders } from '@/app/components/AppRouterProviders';
|
||||
import { SettingsRoutes } from '@/app/components/SettingsRoutes';
|
||||
|
||||
import { VerifyEffect } from '@/auth/components/VerifyEffect';
|
||||
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
|
||||
import indexAppPath from '@/navigation/utils/indexAppPath';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
|
||||
@ -40,6 +42,7 @@ export const useCreateAppRouter = (
|
||||
>
|
||||
<Route element={<DefaultLayout />}>
|
||||
<Route path={AppPath.Verify} element={<VerifyEffect />} />
|
||||
<Route path={AppPath.VerifyEmail} element={<VerifyEmailEffect />} />
|
||||
<Route path={AppPath.SignInUp} element={<SignInUp />} />
|
||||
<Route path={AppPath.Invite} element={<Invite />} />
|
||||
<Route path={AppPath.ResetPassword} element={<PasswordReset />} />
|
||||
|
||||
@ -5,9 +5,9 @@ import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { useIsLogged } from '@/auth/hooks/useIsLogged';
|
||||
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const VerifyEffect = () => {
|
||||
@ -29,6 +29,7 @@ export const VerifyEffect = () => {
|
||||
useEffect(() => {
|
||||
if (isDefined(errorMessage)) {
|
||||
enqueueSnackBar(errorMessage, {
|
||||
dedupeKey: 'verify-failed-dedupe-key',
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificationSent';
|
||||
|
||||
export const VerifyEmailEffect = () => {
|
||||
const { getLoginTokenFromEmailVerificationToken } = useAuth();
|
||||
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isError, setIsError] = useState(false);
|
||||
const email = searchParams.get('email');
|
||||
const emailVerificationToken = searchParams.get('emailVerificationToken');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { readCaptchaToken } = useReadCaptchaToken();
|
||||
|
||||
useEffect(() => {
|
||||
const verifyEmailToken = async () => {
|
||||
if (!email || !emailVerificationToken) {
|
||||
enqueueSnackBar(`Invalid email verification link.`, {
|
||||
dedupeKey: 'email-verification-link-dedupe-key',
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
return navigate(AppPath.SignInUp);
|
||||
}
|
||||
|
||||
const captchaToken = await readCaptchaToken();
|
||||
|
||||
try {
|
||||
const { loginToken } = await getLoginTokenFromEmailVerificationToken(
|
||||
emailVerificationToken,
|
||||
captchaToken,
|
||||
);
|
||||
|
||||
enqueueSnackBar('Email verified.', {
|
||||
dedupeKey: 'email-verification-dedupe-key',
|
||||
variant: SnackBarVariant.Success,
|
||||
});
|
||||
|
||||
navigate(`${AppPath.Verify}?loginToken=${loginToken.token}`);
|
||||
} catch (error) {
|
||||
enqueueSnackBar('Email verification failed.', {
|
||||
dedupeKey: 'email-verification-dedupe-key',
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
setIsError(true);
|
||||
}
|
||||
};
|
||||
|
||||
verifyEmailToken();
|
||||
|
||||
// Verify email only needs to run once at mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (isError) {
|
||||
return <EmailVerificationSent email={email} isError={true} />;
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_LOGIN_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
|
||||
mutation GetLoginTokenFromEmailVerificationToken(
|
||||
$emailVerificationToken: String!
|
||||
$captchaToken: String
|
||||
) {
|
||||
getLoginTokenFromEmailVerificationToken(
|
||||
emailVerificationToken: $emailVerificationToken
|
||||
captchaToken: $captchaToken
|
||||
) {
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const RESEND_EMAIL_VERIFICATION_TOKEN = gql`
|
||||
mutation ResendEmailVerificationToken($email: String!) {
|
||||
resendEmailVerificationToken(email: $email) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -19,6 +19,7 @@ export const CHECK_USER_EXISTS = gql`
|
||||
status
|
||||
}
|
||||
}
|
||||
isEmailVerified
|
||||
}
|
||||
... on UserNotExists {
|
||||
exists
|
||||
|
||||
@ -2,6 +2,7 @@ import { useApolloClient } from '@apollo/client';
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { expect } from '@storybook/test';
|
||||
import { ReactNode, act } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
import { iconsState } from 'twenty-ui';
|
||||
|
||||
@ -13,12 +14,14 @@ import { supportChatState } from '@/client-config/states/supportChatState';
|
||||
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
|
||||
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { email, mocks, password, results, token } from '../__mocks__/useAuth';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { email, mocks, password, results, token } from '../__mocks__/useAuth';
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MockedProvider mocks={mocks} addTypename={false}>
|
||||
<RecoilRoot>{children}</RecoilRoot>
|
||||
<RecoilRoot>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</RecoilRoot>
|
||||
</MockedProvider>
|
||||
);
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { ApolloError, useApolloClient } from '@apollo/client';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
snapshot_UNSTABLE,
|
||||
@ -26,6 +26,7 @@ import {
|
||||
useChallengeMutation,
|
||||
useCheckUserExistsLazyQuery,
|
||||
useGetCurrentUserLazyQuery,
|
||||
useGetLoginTokenFromEmailVerificationTokenMutation,
|
||||
useSignUpMutation,
|
||||
useVerifyMutation,
|
||||
} from '~/generated/graphql';
|
||||
@ -44,7 +45,12 @@ import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTi
|
||||
import { currentUserState } from '../states/currentUserState';
|
||||
import { tokenPairState } from '../states/tokenPairState';
|
||||
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
|
||||
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain';
|
||||
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
|
||||
@ -54,6 +60,7 @@ import { domainConfigurationState } from '@/domain-manager/states/domainConfigur
|
||||
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
|
||||
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
|
||||
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
export const useAuth = () => {
|
||||
const setTokenPair = useSetRecoilState(tokenPairState);
|
||||
@ -68,7 +75,11 @@ export const useAuth = () => {
|
||||
currentWorkspaceMembersState,
|
||||
);
|
||||
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
|
||||
const isEmailVerificationRequired = useRecoilValue(
|
||||
isEmailVerificationRequiredState,
|
||||
);
|
||||
|
||||
const setSignInUpStep = useSetRecoilState(signInUpStepState);
|
||||
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
|
||||
const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState);
|
||||
const setWorkspaces = useSetRecoilState(workspacesState);
|
||||
@ -78,6 +89,8 @@ export const useAuth = () => {
|
||||
const [challenge] = useChallengeMutation();
|
||||
const [signUp] = useSignUpMutation();
|
||||
const [verify] = useVerifyMutation();
|
||||
const [getLoginTokenFromEmailVerificationToken] =
|
||||
useGetLoginTokenFromEmailVerificationTokenMutation();
|
||||
const [getCurrentUser] = useGetCurrentUserLazyQuery();
|
||||
|
||||
const { isOnAWorkspaceSubdomain } =
|
||||
@ -96,6 +109,8 @@ export const useAuth = () => {
|
||||
|
||||
const setDateTimeFormat = useSetRecoilState(dateTimeFormatState);
|
||||
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
const clearSession = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
async () => {
|
||||
@ -154,25 +169,59 @@ export const useAuth = () => {
|
||||
|
||||
const handleChallenge = useCallback(
|
||||
async (email: string, password: string, captchaToken?: string) => {
|
||||
const challengeResult = await challenge({
|
||||
try {
|
||||
const challengeResult = await challenge({
|
||||
variables: {
|
||||
email,
|
||||
password,
|
||||
captchaToken,
|
||||
},
|
||||
});
|
||||
if (isDefined(challengeResult.errors)) {
|
||||
throw challengeResult.errors;
|
||||
}
|
||||
|
||||
if (!challengeResult.data?.challenge) {
|
||||
throw new Error('No login token');
|
||||
}
|
||||
|
||||
return challengeResult.data.challenge;
|
||||
} catch (error) {
|
||||
// TODO: Get intellisense for graphql error extensions code (codegen?)
|
||||
if (
|
||||
error instanceof ApolloError &&
|
||||
error.graphQLErrors[0]?.extensions?.code === 'EMAIL_NOT_VERIFIED'
|
||||
) {
|
||||
setSearchParams({ email });
|
||||
setSignInUpStep(SignInUpStep.EmailVerification);
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[challenge, setSearchParams, setSignInUpStep],
|
||||
);
|
||||
|
||||
const handleGetLoginTokenFromEmailVerificationToken = useCallback(
|
||||
async (emailVerificationToken: string, captchaToken?: string) => {
|
||||
const loginTokenResult = await getLoginTokenFromEmailVerificationToken({
|
||||
variables: {
|
||||
email,
|
||||
password,
|
||||
emailVerificationToken,
|
||||
captchaToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(challengeResult.errors)) {
|
||||
throw challengeResult.errors;
|
||||
if (isDefined(loginTokenResult.errors)) {
|
||||
throw loginTokenResult.errors;
|
||||
}
|
||||
|
||||
if (!challengeResult.data?.challenge) {
|
||||
if (!loginTokenResult.data?.getLoginTokenFromEmailVerificationToken) {
|
||||
throw new Error('No login token');
|
||||
}
|
||||
|
||||
return challengeResult.data.challenge;
|
||||
return loginTokenResult.data.getLoginTokenFromEmailVerificationToken;
|
||||
},
|
||||
[challenge],
|
||||
[getLoginTokenFromEmailVerificationToken],
|
||||
);
|
||||
|
||||
const loadCurrentUser = useCallback(async () => {
|
||||
@ -343,12 +392,21 @@ export const useAuth = () => {
|
||||
throw new Error('No login token');
|
||||
}
|
||||
|
||||
if (isEmailVerificationRequired) {
|
||||
setSearchParams({ email });
|
||||
setSignInUpStep(SignInUpStep.EmailVerification);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMultiWorkspaceEnabled) {
|
||||
return redirectToWorkspaceDomain(
|
||||
signUpResult.data.signUp.workspace.subdomain,
|
||||
AppPath.Verify,
|
||||
isEmailVerificationRequired ? AppPath.SignInUp : AppPath.Verify,
|
||||
{
|
||||
loginToken: signUpResult.data.signUp.loginToken.token,
|
||||
...(!isEmailVerificationRequired && {
|
||||
loginToken: signUpResult.data.signUp.loginToken.token,
|
||||
}),
|
||||
email,
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -361,6 +419,9 @@ export const useAuth = () => {
|
||||
workspacePublicData,
|
||||
isMultiWorkspaceEnabled,
|
||||
handleVerify,
|
||||
setSignInUpStep,
|
||||
setSearchParams,
|
||||
isEmailVerificationRequired,
|
||||
redirectToWorkspaceDomain,
|
||||
],
|
||||
);
|
||||
@ -424,6 +485,8 @@ export const useAuth = () => {
|
||||
|
||||
return {
|
||||
challenge: handleChallenge,
|
||||
getLoginTokenFromEmailVerificationToken:
|
||||
handleGetLoginTokenFromEmailVerificationToken,
|
||||
verify: handleVerify,
|
||||
|
||||
loadCurrentUser,
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { SubTitle } from '@/auth/components/SubTitle';
|
||||
import { Title } from '@/auth/components/Title';
|
||||
import { useHandleResendEmailVerificationToken } from '@/auth/sign-in-up/hooks/useHandleResendEmailVerificationToken';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { AnimatedEaseIn, IconMail, Loader, MainButton, RGBA } from 'twenty-ui';
|
||||
|
||||
const StyledMailContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border: 2px solid ${(props) => props.color};
|
||||
border-radius: ${({ theme }) => theme.border.radius.rounded};
|
||||
box-shadow: ${(props) =>
|
||||
props.color && `-4px 4px 0 -2px ${RGBA(props.color, 1)}`};
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledEmail = styled.span`
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
margin-top: ${({ theme }) => theme.spacing(8)};
|
||||
`;
|
||||
|
||||
export const EmailVerificationSent = ({
|
||||
email,
|
||||
isError = false,
|
||||
}: {
|
||||
email: string | null;
|
||||
isError?: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const color =
|
||||
theme.name === 'light' ? theme.grayScale.gray90 : theme.grayScale.gray10;
|
||||
|
||||
const { handleResendEmailVerificationToken, loading: isLoading } =
|
||||
useHandleResendEmailVerificationToken();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatedEaseIn>
|
||||
<StyledMailContainer color={color}>
|
||||
<IconMail color={color} size={24} stroke={3} />
|
||||
</StyledMailContainer>
|
||||
</AnimatedEaseIn>
|
||||
<Title animate>
|
||||
{isError ? 'Email Verification Failed' : 'Confirm Your Email Address'}
|
||||
</Title>
|
||||
<SubTitle>
|
||||
{isError ? (
|
||||
<>
|
||||
Oops! We encountered an issue verifying{' '}
|
||||
<StyledEmail>{email}</StyledEmail>. Please request a new
|
||||
verification email and try again.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
A verification email has been sent to{' '}
|
||||
<StyledEmail>{email}</StyledEmail>. Please check your inbox and
|
||||
click the link in the email to activate your account.
|
||||
</>
|
||||
)}
|
||||
</SubTitle>
|
||||
<StyledButtonContainer>
|
||||
<MainButton
|
||||
title="Click to resend"
|
||||
onClick={handleResendEmailVerificationToken(email)}
|
||||
Icon={() => (isLoading ? <Loader /> : undefined)}
|
||||
width={200}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useResendEmailVerificationTokenMutation } from '~/generated/graphql';
|
||||
|
||||
export const useHandleResendEmailVerificationToken = () => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const [resendEmailVerificationToken, { loading }] =
|
||||
useResendEmailVerificationTokenMutation();
|
||||
|
||||
const handleResendEmailVerificationToken = useCallback(
|
||||
(email: string | null) => {
|
||||
return async () => {
|
||||
if (!email) {
|
||||
enqueueSnackBar('Invalid email', {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await resendEmailVerificationToken({
|
||||
variables: { email },
|
||||
});
|
||||
|
||||
if (data?.resendEmailVerificationToken?.success === true) {
|
||||
enqueueSnackBar('Email verification link resent!', {
|
||||
variant: SnackBarVariant.Success,
|
||||
});
|
||||
} else {
|
||||
enqueueSnackBar('There was some issue', {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
enqueueSnackBar((error as Error).message, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
[enqueueSnackBar, resendEmailVerificationToken],
|
||||
);
|
||||
|
||||
return { handleResendEmailVerificationToken, loading };
|
||||
};
|
||||
@ -4,6 +4,7 @@ export enum SignInUpStep {
|
||||
Init = 'init',
|
||||
Email = 'email',
|
||||
Password = 'password',
|
||||
EmailVerification = 'emailVerification',
|
||||
WorkspaceSelection = 'workspaceSelection',
|
||||
SSOIdentityProviderSelection = 'SSOIdentityProviderSelection',
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { clientConfigApiStatusState } from '@/client-config/states/clientConfigA
|
||||
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
|
||||
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
|
||||
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
|
||||
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
|
||||
import { supportChatState } from '@/client-config/states/supportChatState';
|
||||
@ -29,6 +30,9 @@ export const ClientConfigProviderEffect = () => {
|
||||
const setIsMultiWorkspaceEnabled = useSetRecoilState(
|
||||
isMultiWorkspaceEnabledState,
|
||||
);
|
||||
const setIsEmailVerificationRequired = useSetRecoilState(
|
||||
isEmailVerificationRequiredState,
|
||||
);
|
||||
|
||||
const setBilling = useSetRecoilState(billingState);
|
||||
const setSupportChat = useSetRecoilState(supportChatState);
|
||||
@ -89,6 +93,9 @@ export const ClientConfigProviderEffect = () => {
|
||||
setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled);
|
||||
setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled);
|
||||
setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled);
|
||||
setIsEmailVerificationRequired(
|
||||
data?.clientConfig.isEmailVerificationRequired,
|
||||
);
|
||||
setBilling(data?.clientConfig.billing);
|
||||
setSupportChat(data?.clientConfig.support);
|
||||
|
||||
@ -115,6 +122,7 @@ export const ClientConfigProviderEffect = () => {
|
||||
setIsDebugMode,
|
||||
setIsDeveloperDefaultSignInPrefilled,
|
||||
setIsMultiWorkspaceEnabled,
|
||||
setIsEmailVerificationRequired,
|
||||
setSupportChat,
|
||||
setBilling,
|
||||
setSentryConfig,
|
||||
|
||||
@ -22,6 +22,7 @@ export const GET_CLIENT_CONFIG = gql`
|
||||
}
|
||||
signInPrefilled
|
||||
isMultiWorkspaceEnabled
|
||||
isEmailVerificationRequired
|
||||
defaultSubdomain
|
||||
frontDomain
|
||||
debugMode
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const isEmailVerificationRequiredState = createState<boolean>({
|
||||
key: 'isEmailVerificationRequired',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
export enum AppPath {
|
||||
// Not logged-in
|
||||
Verify = '/verify',
|
||||
VerifyEmail = '/verify-email',
|
||||
SignInUp = '/welcome',
|
||||
Invite = '/invite/:workspaceInviteHash',
|
||||
ResetPassword = '/reset-password/:passwordResetToken',
|
||||
|
||||
@ -35,6 +35,7 @@ export type SnackBarProps = Pick<ComponentPropsWithoutRef<'div'>, 'id'> & {
|
||||
onClose?: () => void;
|
||||
role?: 'alert' | 'status';
|
||||
variant?: SnackBarVariant;
|
||||
dedupeKey?: string;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
|
||||
import {
|
||||
@ -27,8 +28,17 @@ export const useSnackBar = () => {
|
||||
|
||||
const setSnackBarQueue = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(newValue) =>
|
||||
(newValue: SnackBarOptions) =>
|
||||
set(snackBarInternalScopedState({ scopeId }), (prev) => {
|
||||
if (
|
||||
isDefined(newValue.dedupeKey) &&
|
||||
prev.queue.some(
|
||||
(snackBar) => snackBar.dedupeKey === newValue.dedupeKey,
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (prev.queue.length >= prev.maxQueue) {
|
||||
return {
|
||||
...prev,
|
||||
|
||||
@ -67,6 +67,17 @@ const testCases = [
|
||||
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: false },
|
||||
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false },
|
||||
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: true },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: true },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: true },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: true },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: true },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: true },
|
||||
|
||||
{ loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
|
||||
{ loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true },
|
||||
{ loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: true },
|
||||
|
||||
@ -28,6 +28,7 @@ export const useShowAuthModal = () => {
|
||||
if (
|
||||
isMatchingLocation(AppPath.Invite) ||
|
||||
isMatchingLocation(AppPath.ResetPassword) ||
|
||||
isMatchingLocation(AppPath.VerifyEmail) ||
|
||||
isMatchingLocation(AppPath.SignInUp)
|
||||
) {
|
||||
return isDefaultLayoutAuthModalVisible;
|
||||
|
||||
Reference in New Issue
Block a user