Implement Two-Factor Authentication (2FA) (#13141)

Implementation is very simple

Established authentication dynamic is intercepted at
getAuthTokensFromLoginToken. If 2FA is required, a pattern similar to
EmailVerification is executed. That is, getAuthTokensFromLoginToken
mutation fails with either of the following errors:

1. TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED
2. TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED

UI knows how to respond accordingly.

2FA provisioning occurs at the 2FA resolver.
2FA verification, currently only OTP, is handled by auth.resolver's
getAuthTokensFromOTP

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.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:
oliver
2025-07-23 06:42:01 -06:00
committed by GitHub
parent dd5ae66449
commit 4d3124f840
106 changed files with 5103 additions and 103 deletions
@@ -0,0 +1,75 @@
import { loginTokenState } from '@/auth/states/loginTokenState';
import { qrCodeState } from '@/auth/states/qrCode';
import { useOrigin } from '@/domain-manager/hooks/useOrigin';
import { useCurrentUserWorkspaceTwoFactorAuthentication } from '@/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication';
import { AppPath } from '@/types/AppPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLingui } from '@lingui/react/macro';
import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const TwoFactorAuthenticationSetupEffect = () => {
const { initiateCurrentUserWorkspaceOtpProvisioning } =
useCurrentUserWorkspaceTwoFactorAuthentication();
const { enqueueErrorSnackBar } = useSnackBar();
const navigate = useNavigateApp();
const { origin } = useOrigin();
const loginToken = useRecoilValue(loginTokenState);
const qrCode = useRecoilValue(qrCodeState);
const setQrCodeState = useSetRecoilState(qrCodeState);
const { t } = useLingui();
useEffect(() => {
if (isDefined(qrCode)) {
return;
}
const handleTwoFactorAuthenticationProvisioningInitiation = async () => {
try {
if (!loginToken) {
enqueueErrorSnackBar({
message: t`Login token missing. Two Factor Authentication setup can not be initiated.`,
options: {
dedupeKey: 'invalid-session-dedupe-key',
},
});
return navigate(AppPath.SignInUp);
}
const initiateOTPProvisioningResult =
await initiateCurrentUserWorkspaceOtpProvisioning({
variables: {
loginToken: loginToken,
origin,
},
});
if (!initiateOTPProvisioningResult.data?.initiateOTPProvisioning.uri)
return;
setQrCodeState(
initiateOTPProvisioningResult.data?.initiateOTPProvisioning.uri,
);
} catch (error) {
enqueueErrorSnackBar({
message: t`Two factor authentication provisioning failed.`,
options: {
dedupeKey:
'two-factor-authentication-provisioning-initiation-failed',
},
});
}
};
handleTwoFactorAuthenticationProvisioningInitiation();
// Two factor authentication provisioning only needs to run once at mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <></>;
};
@@ -0,0 +1,21 @@
import { gql } from '@apollo/client';
export const GET_AUTH_TOKENS_FROM_OTP = gql`
mutation getAuthTokensFromOTP(
$loginToken: String!
$otp: String!
$captchaToken: String
$origin: String!
) {
getAuthTokensFromOTP(
loginToken: $loginToken
otp: $otp
captchaToken: $captchaToken
origin: $origin
) {
tokens {
...AuthTokensFragment
}
}
}
`;
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const INITIATE_OTP_PROVISIONING = gql`
mutation initiateOTPProvisioning($loginToken: String!, $origin: String!) {
initiateOTPProvisioning(loginToken: $loginToken, origin: $origin) {
uri
}
}
`;
export const INITIATE_OTP_PROVISIONING_FOR_AUTHENTICATED_USER = gql`
mutation initiateOTPProvisioningForAuthenticatedUser {
initiateOTPProvisioningForAuthenticatedUser {
uri
}
}
`;
@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const DELETE_TWO_FACTOR_AUTHENTICATION_METHOD = gql`
mutation deleteTwoFactorAuthenticationMethod(
$twoFactorAuthenticationMethodId: UUID!
) {
deleteTwoFactorAuthenticationMethod(
twoFactorAuthenticationMethodId: $twoFactorAuthenticationMethodId
) {
success
}
}
`;
@@ -31,6 +31,42 @@ jest.mock('@/object-metadata/hooks/useRefreshObjectMetadataItem', () => ({
})),
}));
jest.mock('@/domain-manager/hooks/useOrigin', () => ({
useOrigin: jest.fn().mockImplementation(() => ({
origin: 'http://localhost',
})),
}));
jest.mock('@/captcha/hooks/useRequestFreshCaptchaToken', () => ({
useRequestFreshCaptchaToken: jest.fn().mockImplementation(() => ({
requestFreshCaptchaToken: jest.fn(),
})),
}));
jest.mock('@/auth/sign-in-up/hooks/useSignUpInNewWorkspace', () => ({
useSignUpInNewWorkspace: jest.fn().mockImplementation(() => ({
createWorkspace: jest.fn(),
})),
}));
jest.mock('@/domain-manager/hooks/useRedirectToWorkspaceDomain', () => ({
useRedirectToWorkspaceDomain: jest.fn().mockImplementation(() => ({
redirectToWorkspaceDomain: jest.fn(),
})),
}));
jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace', () => ({
useIsCurrentLocationOnAWorkspace: jest.fn().mockImplementation(() => ({
isOnAWorkspace: true,
})),
}));
jest.mock('@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain', () => ({
useLastAuthenticatedWorkspaceDomain: jest.fn().mockImplementation(() => ({
setLastAuthenticateWorkspaceDomain: jest.fn(),
})),
}));
const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={Object.values(mocks)} addTypename={false}>
<RecoilRoot>
@@ -20,6 +20,7 @@ import {
AuthTokenPair,
useCheckUserExistsLazyQuery,
useGetAuthTokensFromLoginTokenMutation,
useGetAuthTokensFromOtpMutation,
useGetCurrentUserLazyQuery,
useGetLoginTokenFromCredentialsMutation,
useGetLoginTokenFromEmailVerificationTokenMutation,
@@ -74,12 +75,15 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { iconsState } from 'twenty-ui/display';
import { AuthToken } from '~/generated/graphql';
import { cookieStorage } from '~/utils/cookie-storage';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { loginTokenState } from '../states/loginTokenState';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
const setLoginToken = useSetRecoilState(loginTokenState);
const setCurrentUser = useSetRecoilState(currentUserState);
const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState);
const setCurrentWorkspaceMember = useSetRecoilState(
@@ -114,6 +118,7 @@ export const useAuth = () => {
const [getLoginTokenFromEmailVerificationToken] =
useGetLoginTokenFromEmailVerificationTokenMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery();
const [getAuthTokensFromOtp] = useGetAuthTokensFromOtpMutation();
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
@@ -368,26 +373,16 @@ export const useAuth = () => {
[setTokenPair],
);
const handleGetAuthTokensFromLoginToken = useCallback(
async (loginToken: string) => {
const getAuthTokensResult = await getAuthTokensFromLoginToken({
variables: {
loginToken,
origin,
},
});
const handleSetLoginToken = useCallback(
(token: AuthToken['token']) => {
setLoginToken(token);
},
[setLoginToken],
);
if (isDefined(getAuthTokensResult.errors)) {
throw getAuthTokensResult.errors;
}
if (!getAuthTokensResult.data?.getAuthTokensFromLoginToken) {
throw new Error('No getAuthTokensFromLoginToken result');
}
handleSetAuthTokens(
getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens,
);
const handleLoadWorkspaceAfterAuthentication = useCallback(
async (authTokens: AuthTokenPair) => {
handleSetAuthTokens(authTokens);
// TODO: We can't parallelize this yet because when loadCurrentUSer is loaded
// then UserProvider updates its children and PrefetchDataProvider is triggered
@@ -395,12 +390,59 @@ export const useAuth = () => {
await refreshObjectMetadataItems();
await loadCurrentUser();
},
[loadCurrentUser, handleSetAuthTokens, refreshObjectMetadataItems],
);
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,
loadCurrentUser,
origin,
handleSetAuthTokens,
refreshObjectMetadataItems,
handleLoadWorkspaceAfterAuthentication,
setSignInUpStep,
navigate,
],
);
@@ -654,6 +696,32 @@ export const useAuth = () => {
[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 getAuthTokensFromLoginToken result');
}
await handleLoadWorkspaceAfterAuthentication(
getAuthTokensFromOtpResult.data.getAuthTokensFromOTP.tokens,
);
},
[getAuthTokensFromOtp, origin, handleLoadWorkspaceAfterAuthentication],
);
return {
getLoginTokenFromCredentials: handleGetLoginTokenFromCredentials,
getLoginTokenFromEmailVerificationToken:
@@ -672,5 +740,6 @@ export const useAuth = () => {
signInWithGoogle: handleGoogleLogin,
signInWithMicrosoft: handleMicrosoftLogin,
setAuthTokens: handleSetAuthTokens,
getAuthTokensFromOTP: handleGetAuthTokensFromOTP,
};
};
@@ -0,0 +1,123 @@
import { TwoFactorAuthenticationSetupEffect } from '@/auth/components/TwoFactorAuthenticationProvisionEffect';
import { qrCodeState } from '@/auth/states/qrCode';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { extractSecretFromOtpUri } from '@/settings/two-factor-authentication/utils/extractSecretFromOtpUri';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import QRCode from 'react-qr-code';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { IconCopy } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
const StyledMainContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
text-align: center;
`;
const StyledTextContainer = styled.div`
align-items: center;
margin-bottom: ${({ theme }) => theme.spacing(4)};
color: ${({ theme }) => theme.font.color.tertiary};
max-width: 280px;
text-align: center;
font-size: ${({ theme }) => theme.font.size.sm};
& > a {
color: ${({ theme }) => theme.font.color.tertiary};
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
`;
const StyledForm = styled.div`
align-items: center;
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledCopySetupKeyLink = styled.button`
background: none;
border: none;
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
font-size: ${({ theme }) => theme.font.size.sm};
margin-top: ${({ theme }) => theme.spacing(2)};
padding: 0;
text-decoration: underline;
&:hover {
color: ${({ theme }) => theme.font.color.primary};
}
`;
export const SignInUpTwoFactorAuthenticationProvision = () => {
const { t } = useLingui();
const theme = useTheme();
const { enqueueSuccessSnackBar } = useSnackBar();
const qrCode = useRecoilValue(qrCodeState);
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const handleClick = () => {
setSignInUpStep(SignInUpStep.TwoFactorAuthenticationVerification);
};
const handleCopySetupKey = async () => {
if (!qrCode) return;
const secret = extractSecretFromOtpUri(qrCode);
if (secret !== null) {
await navigator.clipboard.writeText(secret);
enqueueSuccessSnackBar({
message: t`Setup key copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
}
};
return (
<>
<TwoFactorAuthenticationSetupEffect />
<StyledForm>
<StyledTextContainer>
<Trans>
Use authenticator apps and browser extensions like 1Password, Authy,
Microsoft Authenticator to generate one-time passwords
</Trans>
</StyledTextContainer>
<StyledMainContentContainer>
{!qrCode ? <Loader /> : <QRCode value={qrCode} />}
{qrCode && (
<StyledCopySetupKeyLink onClick={handleCopySetupKey}>
<IconCopy size={theme.icon.size.sm} />
<Trans>Copy Setup Key</Trans>
</StyledCopySetupKeyLink>
)}
</StyledMainContentContainer>
<MainButton
title={'Next'}
onClick={handleClick}
variant={'primary'}
fullWidth
/>
</StyledForm>
</>
);
};
@@ -0,0 +1,273 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useAuth } from '@/auth/hooks/useAuth';
import {
OTPFormValues,
useTwoFactorAuthenticationForm,
} from '@/auth/sign-in-up/hooks/useTwoFactorAuthenticationForm';
import { loginTokenState } from '@/auth/states/loginTokenState';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { AppPath } from '@/types/AppPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Trans, useLingui } from '@lingui/react/macro';
import { OTPInput, SlotProps } from 'input-otp';
import { Controller } from 'react-hook-form';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { MainButton } from 'twenty-ui/input';
import { ClickToActionLink } from 'twenty-ui/navigation';
import { useNavigateApp } from '~/hooks/useNavigateApp';
const StyledMainContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
text-align: center;
`;
const StyledForm = styled.form`
align-items: center;
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledSlot = styled.div<{ isActive: boolean }>`
position: relative;
width: 2.5rem;
height: 3.5rem;
font-size: 2rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
border-top: 1px solid ${({ theme }) => theme.border.color.medium};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
border-right: 1px solid ${({ theme }) => theme.border.color.medium};
&:first-of-type {
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
border-top-left-radius: 0.375rem;
border-bottom-left-radius: 0.375rem;
}
&:last-of-type {
border-top-right-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
}
.group:hover &,
.group:focus-within & {
border-color: ${({ theme }) => theme.border.color.medium};
}
outline: 0;
outline-color: ${({ theme }) => theme.border.color.medium};
${({ isActive, theme }) =>
isActive &&
css`
outline-width: 1px;
outline-style: solid;
outline-color: ${theme.border.color.strong};
`}
`;
const StyledPlaceholderChar = styled.div`
.group:has(input[data-input-otp-placeholder-shown]) & {
opacity: 0.2;
}
`;
export const Slot = (props: SlotProps) => {
return (
<StyledSlot isActive={props.isActive}>
<StyledPlaceholderChar>
{props.char ?? props.placeholderChar}
</StyledPlaceholderChar>
{props.hasFakeCaret && <FakeCaret />}
</StyledSlot>
);
};
const StyledCaretContainer = styled.div`
align-items: center;
animation: caret-blink 1s steps(2, start) infinite;
display: flex;
inset: 0;
justify-content: center;
pointer-events: none;
position: absolute;
@keyframes caret-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
`;
const StyledCaret = styled.div`
width: 1px;
height: 2rem;
background-color: white;
`;
const FakeCaret = () => {
return (
<StyledCaretContainer>
<StyledCaret />
</StyledCaretContainer>
);
};
const StyledDashContainer = styled.div`
display: flex;
width: 2.5rem;
justify-content: center;
align-items: center;
`;
const StyledDash = styled.div`
background-color: black;
border-radius: 9999px;
height: 0.25rem;
width: 0.75rem;
`;
const FakeDash = () => {
return (
<StyledDashContainer>
<StyledDash />
</StyledDashContainer>
);
};
const StyledOTPContainer = styled.div`
display: flex;
align-items: center;
&:has(:disabled) {
opacity: 0.3;
}
`;
const StyledSlotGroup = styled.div`
display: flex;
`;
const StyledTextContainer = styled.div`
align-items: center;
margin-bottom: ${({ theme }) => theme.spacing(4)};
color: ${({ theme }) => theme.font.color.tertiary};
max-width: 280px;
text-align: center;
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledActionBackLinkContainer = styled.div`
margin: ${({ theme }) => theme.spacing(3)} 0 0;
`;
export const SignInUpTOTPVerification = () => {
const { getAuthTokensFromOTP } = useAuth();
const { enqueueErrorSnackBar } = useSnackBar();
const navigate = useNavigateApp();
const { readCaptchaToken } = useReadCaptchaToken();
const loginToken = useRecoilValue(loginTokenState);
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const { t } = useLingui();
const { form } = useTwoFactorAuthenticationForm();
const submitOTP = async (values: OTPFormValues) => {
try {
const captchaToken = await readCaptchaToken();
if (!loginToken) {
return navigate(AppPath.SignInUp);
}
await getAuthTokensFromOTP(values.otp, loginToken, captchaToken);
} catch (error) {
form.setValue('otp', '');
enqueueErrorSnackBar({
message: t`Invalid verification code. Please try again.`,
options: {
dedupeKey: 'invalid-otp-dedupe-key',
},
});
}
};
const handleBack = () => {
setSignInUpStep(SignInUpStep.TwoFactorAuthenticationProvision);
};
return (
<StyledForm onSubmit={form.handleSubmit(submitOTP)}>
<StyledTextContainer>
<Trans>Paste the code below</Trans>
</StyledTextContainer>
<StyledMainContentContainer>
{/* // eslint-disable-next-line react/jsx-props-no-spreading */}
<Controller
name="otp"
control={form.control}
render={({ field: { onChange, onBlur, value } }) => (
<OTPInput
maxLength={6}
onBlur={onBlur}
onChange={onChange}
value={value}
render={({ slots }) => (
<StyledOTPContainer>
<StyledSlotGroup>
{slots.slice(0, 3).map((slot, idx) => (
<Slot
key={idx}
// eslint-disable-next-line react/jsx-props-no-spreading
{...slot}
/>
))}
</StyledSlotGroup>
<FakeDash />
<StyledSlotGroup>
{slots.slice(3).map((slot, idx) => (
<Slot
key={idx}
// eslint-disable-next-line react/jsx-props-no-spreading
{...slot}
/>
))}
</StyledSlotGroup>
</StyledOTPContainer>
)}
/>
)}
/>
</StyledMainContentContainer>
<MainButton
title={'Submit'}
type="submit"
variant={'primary'}
fullWidth
/>
<StyledActionBackLinkContainer>
<ClickToActionLink onClick={handleBack}>
<Trans>Back</Trans>
</ClickToActionLink>
</StyledActionBackLinkContainer>
</StyledForm>
);
};
@@ -0,0 +1,20 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const otpValidationSchema = z.object({
otp: z.string().trim().length(6, 'OTP must be exactly 6 digits'),
});
export type OTPFormValues = z.infer<typeof otpValidationSchema>;
export const useTwoFactorAuthenticationForm = () => {
const form = useForm<OTPFormValues>({
mode: 'onSubmit',
defaultValues: {
otp: '',
},
resolver: zodResolver(otpValidationSchema),
});
return { form };
};
@@ -3,7 +3,10 @@ import { UserWorkspace } from '~/generated/graphql';
export type CurrentUserWorkspace = Pick<
UserWorkspace,
'settingsPermissions' | 'objectRecordsPermissions' | 'objectPermissions'
| 'settingsPermissions'
| 'objectRecordsPermissions'
| 'objectPermissions'
| 'twoFactorAuthenticationMethodSummary'
>;
export const currentUserWorkspaceState =
@@ -23,6 +23,7 @@ export type CurrentWorkspace = Pick<
| 'customDomain'
| 'workspaceUrls'
| 'metadataVersion'
| 'isTwoFactorAuthenticationEnforced'
> & {
defaultRole?: Omit<Role, 'workspaceMembers'> | null;
defaultAgent?: { id: string } | null;
@@ -0,0 +1,7 @@
import { createState } from 'twenty-ui/utilities';
import { AuthToken } from '~/generated/graphql';
export const loginTokenState = createState<AuthToken['token'] | null>({
key: 'loginTokenState',
defaultValue: null,
});
@@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const qrCodeState = createState<string | null>({
key: 'qrCodeState',
defaultValue: null,
});
@@ -6,6 +6,8 @@ export enum SignInUpStep {
EmailVerification = 'emailVerification',
WorkspaceSelection = 'workspaceSelection',
SSOIdentityProviderSelection = 'SSOIdentityProviderSelection',
TwoFactorAuthenticationVerification = 'TwoFactorAuthenticationVerification',
TwoFactorAuthenticationProvision = 'TwoFactorAuthenticationProvision',
}
export const signInUpStepState = createState<SignInUpStep>({