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:
+75
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+13
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+123
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+273
@@ -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>
|
||||
);
|
||||
};
|
||||
+20
@@ -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>({
|
||||
|
||||
Reference in New Issue
Block a user