Migrate to a monorepo structure (#2909)
This commit is contained in:
67
packages/twenty-front/src/modules/auth/components/Logo.tsx
Normal file
67
packages/twenty-front/src/modules/auth/components/Logo.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { getImageAbsoluteURIOrBase64 } from '@/users/utils/getProfilePictureAbsoluteURI';
|
||||
|
||||
type LogoProps = {
|
||||
workspaceLogo?: string | null;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 48px;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
|
||||
position: relative;
|
||||
width: 48px;
|
||||
`;
|
||||
|
||||
const StyledTwentyLogo = styled.img`
|
||||
border-radius: ${({ theme }) => theme.border.radius.xs};
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
`;
|
||||
|
||||
const StyledTwentyLogoContainer = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
bottom: ${({ theme }) => `-${theme.spacing(3)}`};
|
||||
display: flex;
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
|
||||
position: absolute;
|
||||
right: ${({ theme }) => `-${theme.spacing(3)}`};
|
||||
width: 28px;
|
||||
`;
|
||||
|
||||
type StyledMainLogoProps = {
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
const StyledMainLogo = styled.div<StyledMainLogoProps>`
|
||||
background: url(${(props) => props.logo});
|
||||
background-size: cover;
|
||||
height: 100%;
|
||||
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const Logo = ({ workspaceLogo }: LogoProps) => {
|
||||
if (!workspaceLogo) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledMainLogo logo="/icons/android/android-launchericon-192-192.png" />
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledMainLogo logo={getImageAbsoluteURIOrBase64(workspaceLogo)} />
|
||||
<StyledTwentyLogoContainer>
|
||||
<StyledTwentyLogo src="/icons/android/android-launchericon-192-192.png" />
|
||||
</StyledTwentyLogoContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
17
packages/twenty-front/src/modules/auth/components/Modal.tsx
Normal file
17
packages/twenty-front/src/modules/auth/components/Modal.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Modal as UIModal } from '@/ui/layout/modal/components/Modal';
|
||||
|
||||
const StyledContent = styled(UIModal.Content)`
|
||||
align-items: center;
|
||||
width: calc(400px - ${({ theme }) => theme.spacing(10 * 2)});
|
||||
`;
|
||||
|
||||
type AuthModalProps = { children: React.ReactNode };
|
||||
|
||||
export const AuthModal = ({ children }: AuthModalProps) => (
|
||||
<UIModal isOpen={true}>
|
||||
<StyledContent>{children}</StyledContent>
|
||||
</UIModal>
|
||||
);
|
||||
@ -0,0 +1,14 @@
|
||||
import { JSX, ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type SubTitleProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const StyledSubTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
`;
|
||||
|
||||
export const SubTitle = ({ children }: SubTitleProps): JSX.Element => (
|
||||
<StyledSubTitle>{children}</StyledSubTitle>
|
||||
);
|
||||
28
packages/twenty-front/src/modules/auth/components/Title.tsx
Normal file
28
packages/twenty-front/src/modules/auth/components/Title.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
|
||||
|
||||
type TitleProps = React.PropsWithChildren & {
|
||||
animate?: boolean;
|
||||
};
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.xl};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const Title = ({ children, animate = false }: TitleProps) => {
|
||||
if (animate) {
|
||||
return (
|
||||
<StyledTitle>
|
||||
<AnimatedEaseIn>{children}</AnimatedEaseIn>
|
||||
</StyledTitle>
|
||||
);
|
||||
}
|
||||
|
||||
return <StyledTitle>{children}</StyledTitle>;
|
||||
};
|
||||
@ -0,0 +1,19 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const AUTH_TOKEN = gql`
|
||||
fragment AuthTokenFragment on AuthToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
`;
|
||||
|
||||
export const AUTH_TOKENS = gql`
|
||||
fragment AuthTokensFragment on AuthTokenPair {
|
||||
accessToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
refreshToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CHALLENGE = gql`
|
||||
mutation Challenge($email: String!, $password: String!) {
|
||||
challenge(email: $email, password: $password) {
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GENERATE_ONE_API_KEY_TOKEN = gql`
|
||||
mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) {
|
||||
generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) {
|
||||
token
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GENERATE_ONE_TRANSIENT_TOKEN = gql`
|
||||
mutation generateTransientToken {
|
||||
generateTransientToken {
|
||||
transientToken {
|
||||
token
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,15 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
// TODO: Fragments should be used instead of duplicating the user fields !
|
||||
export const IMPERSONATE = gql`
|
||||
mutation Impersonate($userId: String!) {
|
||||
impersonate(userId: $userId) {
|
||||
user {
|
||||
...UserQueryFragment
|
||||
}
|
||||
tokens {
|
||||
...AuthTokensFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const RENEW_TOKEN = gql`
|
||||
mutation RenewToken($refreshToken: String!) {
|
||||
renewToken(refreshToken: $refreshToken) {
|
||||
tokens {
|
||||
...AuthTokensFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,19 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SIGN_UP = gql`
|
||||
mutation SignUp(
|
||||
$email: String!
|
||||
$password: String!
|
||||
$workspaceInviteHash: String
|
||||
) {
|
||||
signUp(
|
||||
email: $email
|
||||
password: $password
|
||||
workspaceInviteHash: $workspaceInviteHash
|
||||
) {
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,14 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const VERIFY = gql`
|
||||
mutation Verify($loginToken: String!) {
|
||||
verify(loginToken: $loginToken) {
|
||||
user {
|
||||
...UserQueryFragment
|
||||
}
|
||||
tokens {
|
||||
...AuthTokensFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CHECK_USER_EXISTS = gql`
|
||||
query CheckUserExists($email: String!) {
|
||||
checkUserExists(email: $email) {
|
||||
exists
|
||||
}
|
||||
}
|
||||
`;
|
||||
187
packages/twenty-front/src/modules/auth/hooks/useAuth.ts
Normal file
187
packages/twenty-front/src/modules/auth/hooks/useAuth.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import {
|
||||
snapshot_UNSTABLE,
|
||||
useGotoRecoilSnapshot,
|
||||
useRecoilState,
|
||||
useSetRecoilState,
|
||||
} from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { isVerifyPendingState } from '@/auth/states/isVerifyPendingState';
|
||||
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
|
||||
import { REACT_APP_SERVER_AUTH_URL } from '~/config';
|
||||
import {
|
||||
useChallengeMutation,
|
||||
useCheckUserExistsLazyQuery,
|
||||
useSignUpMutation,
|
||||
useVerifyMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
import { currentUserState } from '../states/currentUserState';
|
||||
import { tokenPairState } from '../states/tokenPairState';
|
||||
|
||||
export const useAuth = () => {
|
||||
const [, setTokenPair] = useRecoilState(tokenPairState);
|
||||
const setCurrentUser = useSetRecoilState(currentUserState);
|
||||
const setCurrentWorkspaceMember = useSetRecoilState(
|
||||
currentWorkspaceMemberState,
|
||||
);
|
||||
|
||||
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
|
||||
const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState);
|
||||
|
||||
const [challenge] = useChallengeMutation();
|
||||
const [signUp] = useSignUpMutation();
|
||||
const [verify] = useVerifyMutation();
|
||||
const [checkUserExistsQuery, { data: checkUserExistsData }] =
|
||||
useCheckUserExistsLazyQuery();
|
||||
|
||||
const client = useApolloClient();
|
||||
|
||||
const goToRecoilSnapshot = useGotoRecoilSnapshot();
|
||||
|
||||
const handleChallenge = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
const challengeResult = await challenge({
|
||||
variables: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
if (challengeResult.errors) {
|
||||
throw challengeResult.errors;
|
||||
}
|
||||
|
||||
if (!challengeResult.data?.challenge) {
|
||||
throw new Error('No login token');
|
||||
}
|
||||
|
||||
return challengeResult.data.challenge;
|
||||
},
|
||||
[challenge],
|
||||
);
|
||||
|
||||
const handleVerify = useCallback(
|
||||
async (loginToken: string) => {
|
||||
const verifyResult = await verify({
|
||||
variables: { loginToken },
|
||||
});
|
||||
|
||||
if (verifyResult.errors) {
|
||||
throw verifyResult.errors;
|
||||
}
|
||||
|
||||
if (!verifyResult.data?.verify) {
|
||||
throw new Error('No verify result');
|
||||
}
|
||||
|
||||
setTokenPair(verifyResult.data?.verify.tokens);
|
||||
|
||||
const user = verifyResult.data?.verify.user;
|
||||
const workspaceMember = {
|
||||
...user.workspaceMember,
|
||||
colorScheme: user.workspaceMember?.colorScheme as ColorScheme,
|
||||
};
|
||||
const workspace = user.defaultWorkspace ?? null;
|
||||
setCurrentUser(user);
|
||||
setCurrentWorkspaceMember(workspaceMember);
|
||||
setCurrentWorkspace(workspace);
|
||||
return {
|
||||
user,
|
||||
workspaceMember,
|
||||
workspace,
|
||||
tokens: verifyResult.data?.verify.tokens,
|
||||
};
|
||||
},
|
||||
[
|
||||
verify,
|
||||
setTokenPair,
|
||||
setCurrentUser,
|
||||
setCurrentWorkspaceMember,
|
||||
setCurrentWorkspace,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCrendentialsSignIn = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
const { loginToken } = await handleChallenge(email, password);
|
||||
setIsVerifyPendingState(true);
|
||||
|
||||
const { user, workspaceMember, workspace } = await handleVerify(
|
||||
loginToken.token,
|
||||
);
|
||||
|
||||
setIsVerifyPendingState(false);
|
||||
|
||||
return {
|
||||
user,
|
||||
workspaceMember,
|
||||
workspace,
|
||||
};
|
||||
},
|
||||
[handleChallenge, handleVerify, setIsVerifyPendingState],
|
||||
);
|
||||
|
||||
const handleSignOut = useCallback(() => {
|
||||
goToRecoilSnapshot(snapshot_UNSTABLE());
|
||||
setTokenPair(null);
|
||||
setCurrentUser(null);
|
||||
client.clearStore().then(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
}, [goToRecoilSnapshot, setTokenPair, setCurrentUser, client]);
|
||||
|
||||
const handleCredentialsSignUp = useCallback(
|
||||
async (email: string, password: string, workspaceInviteHash?: string) => {
|
||||
setIsVerifyPendingState(true);
|
||||
|
||||
const signUpResult = await signUp({
|
||||
variables: {
|
||||
email,
|
||||
password,
|
||||
workspaceInviteHash,
|
||||
},
|
||||
});
|
||||
|
||||
if (signUpResult.errors) {
|
||||
throw signUpResult.errors;
|
||||
}
|
||||
|
||||
if (!signUpResult.data?.signUp) {
|
||||
throw new Error('No login token');
|
||||
}
|
||||
|
||||
const { user, workspace, workspaceMember } = await handleVerify(
|
||||
signUpResult.data?.signUp.loginToken.token,
|
||||
);
|
||||
|
||||
setIsVerifyPendingState(false);
|
||||
|
||||
return { user, workspaceMember, workspace };
|
||||
},
|
||||
[setIsVerifyPendingState, signUp, handleVerify],
|
||||
);
|
||||
|
||||
const handleGoogleLogin = useCallback((workspaceInviteHash?: string) => {
|
||||
const authServerUrl = REACT_APP_SERVER_AUTH_URL;
|
||||
window.location.href =
|
||||
`${authServerUrl}/google/${
|
||||
workspaceInviteHash ? '?inviteHash=' + workspaceInviteHash : ''
|
||||
}` || '';
|
||||
}, []);
|
||||
|
||||
return {
|
||||
challenge: handleChallenge,
|
||||
verify: handleVerify,
|
||||
|
||||
checkUserExists: { checkUserExistsData, checkUserExistsQuery },
|
||||
|
||||
signOut: handleSignOut,
|
||||
signUpWithCredentials: handleCredentialsSignUp,
|
||||
signInWithCredentials: handleCrendentialsSignIn,
|
||||
signInWithGoogle: handleGoogleLogin,
|
||||
};
|
||||
};
|
||||
12
packages/twenty-front/src/modules/auth/hooks/useIsLogged.ts
Normal file
12
packages/twenty-front/src/modules/auth/hooks/useIsLogged.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import { isVerifyPendingState } from '@/auth/states/isVerifyPendingState';
|
||||
|
||||
import { tokenPairState } from '../states/tokenPairState';
|
||||
|
||||
export const useIsLogged = (): boolean => {
|
||||
const [tokenPair] = useRecoilState(tokenPairState);
|
||||
const isVerifyPending = useRecoilValue(isVerifyPendingState);
|
||||
|
||||
return !!tokenPair && !isVerifyPending;
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
|
||||
import { useIsLogged } from '../hooks/useIsLogged';
|
||||
import {
|
||||
getOnboardingStatus,
|
||||
OnboardingStatus,
|
||||
} from '../utils/getOnboardingStatus';
|
||||
|
||||
export const useOnboardingStatus = (): OnboardingStatus | undefined => {
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const isLoggedIn = useIsLogged();
|
||||
|
||||
return getOnboardingStatus(
|
||||
isLoggedIn,
|
||||
currentWorkspaceMember,
|
||||
currentWorkspace,
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,71 @@
|
||||
import {
|
||||
ApolloClient,
|
||||
ApolloLink,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
UriFunction,
|
||||
} from '@apollo/client';
|
||||
|
||||
import { loggerLink } from '@/apollo/utils';
|
||||
import {
|
||||
AuthTokenPair,
|
||||
RenewTokenDocument,
|
||||
RenewTokenMutation,
|
||||
RenewTokenMutationVariables,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
const logger = loggerLink(() => 'Twenty-Refresh');
|
||||
|
||||
/**
|
||||
* Renew token mutation with custom apollo client
|
||||
* @param uri string | UriFunction | undefined
|
||||
* @param refreshToken string
|
||||
* @returns RenewTokenMutation
|
||||
*/
|
||||
const renewTokenMutation = async (
|
||||
uri: string | UriFunction | undefined,
|
||||
refreshToken: string,
|
||||
) => {
|
||||
const httpLink = new HttpLink({ uri });
|
||||
|
||||
// Create new client to call refresh token graphql mutation
|
||||
const client = new ApolloClient({
|
||||
link: ApolloLink.from([logger, httpLink]),
|
||||
cache: new InMemoryCache({}),
|
||||
});
|
||||
|
||||
const { data, errors } = await client.mutate<
|
||||
RenewTokenMutation,
|
||||
RenewTokenMutationVariables
|
||||
>({
|
||||
mutation: RenewTokenDocument,
|
||||
variables: {
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
if (errors || !data) {
|
||||
throw new Error('Something went wrong during token renewal');
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renew token and update cookie storage
|
||||
* @param uri string | UriFunction | undefined
|
||||
* @returns TokenPair
|
||||
*/
|
||||
export const renewToken = async (
|
||||
uri: string | UriFunction | undefined,
|
||||
tokenPair: AuthTokenPair | undefined | null,
|
||||
) => {
|
||||
if (!tokenPair) {
|
||||
throw new Error('Refresh token is not defined');
|
||||
}
|
||||
|
||||
const data = await renewTokenMutation(uri, tokenPair.refreshToken.token);
|
||||
|
||||
return data.renewToken.tokens;
|
||||
};
|
||||
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type FooterNoteProps = { children: React.ReactNode };
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const FooterNote = ({ children }: FooterNoteProps) => (
|
||||
<StyledContainer>{children}</StyledContainer>
|
||||
);
|
||||
@ -0,0 +1,12 @@
|
||||
import { JSX } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledSeparator = styled.div`
|
||||
background-color: ${({ theme }) => theme.border.color.medium};
|
||||
height: 1px;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
margin-top: ${({ theme }) => theme.spacing(3)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const HorizontalSeparator = (): JSX.Element => <StyledSeparator />;
|
||||
@ -0,0 +1,235 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { IconGoogle } from '@/ui/display/icon/components/IconGoogle';
|
||||
import { MainButton } from '@/ui/input/button/components/MainButton';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
|
||||
|
||||
import { Logo } from '../../components/Logo';
|
||||
import { Title } from '../../components/Title';
|
||||
import { SignInUpMode, SignInUpStep, useSignInUp } from '../hooks/useSignInUp';
|
||||
|
||||
import { FooterNote } from './FooterNote';
|
||||
import { HorizontalSeparator } from './HorizontalSeparator';
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
width: 200px;
|
||||
`;
|
||||
|
||||
const StyledFooterNote = styled(FooterNote)`
|
||||
max-width: 280px;
|
||||
`;
|
||||
|
||||
const StyledForm = styled.form`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledFullWidthMotionDiv = styled(motion.div)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
export const SignInUpForm = () => {
|
||||
const {
|
||||
authProviders,
|
||||
signInWithGoogle,
|
||||
signInUpStep,
|
||||
signInUpMode,
|
||||
showErrors,
|
||||
setShowErrors,
|
||||
continueWithCredentials,
|
||||
continueWithEmail,
|
||||
submitCredentials,
|
||||
form: {
|
||||
control,
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
},
|
||||
workspace,
|
||||
} = useSignInUp();
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
} else if (signInUpStep === SignInUpStep.Email) {
|
||||
continueWithCredentials();
|
||||
} else if (signInUpStep === SignInUpStep.Password) {
|
||||
setShowErrors(true);
|
||||
handleSubmit(submitCredentials)();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buttonTitle = useMemo(() => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
return 'Continue With Email';
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.Email) {
|
||||
return 'Continue';
|
||||
}
|
||||
|
||||
return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up';
|
||||
}, [signInUpMode, signInUpStep]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (signInUpMode === SignInUpMode.Invite) {
|
||||
return `Join ${workspace?.displayName ?? ''} team`;
|
||||
}
|
||||
|
||||
return signInUpMode === SignInUpMode.SignIn
|
||||
? 'Sign in to Twenty'
|
||||
: 'Sign up to Twenty';
|
||||
}, [signInUpMode, workspace?.displayName]);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatedEaseIn>
|
||||
<Logo workspaceLogo={workspace?.logo} />
|
||||
</AnimatedEaseIn>
|
||||
<Title animate>{title}</Title>
|
||||
<StyledContentContainer>
|
||||
{authProviders.google && (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconGoogle size={theme.icon.size.lg} />}
|
||||
title="Continue with Google"
|
||||
onClick={signInWithGoogle}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<StyledForm
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{signInUpStep !== SignInUpStep.Init && (
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="Email"
|
||||
onBlur={onBlur}
|
||||
onChange={(value: string) => {
|
||||
onChange(value);
|
||||
if (signInUpStep === SignInUpStep.Password) {
|
||||
continueWithEmail();
|
||||
}
|
||||
}}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
)}
|
||||
|
||||
<MainButton
|
||||
variant="secondary"
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
return;
|
||||
}
|
||||
if (signInUpStep === SignInUpStep.Email) {
|
||||
continueWithCredentials();
|
||||
return;
|
||||
}
|
||||
setShowErrors(true);
|
||||
handleSubmit(submitCredentials)();
|
||||
}}
|
||||
disabled={
|
||||
SignInUpStep.Init
|
||||
? false
|
||||
: signInUpStep === SignInUpStep.Email
|
||||
? !watch('email')
|
||||
: !watch('email') || !watch('password') || isSubmitting
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
</StyledContentContainer>
|
||||
<StyledFooterNote>
|
||||
By using Twenty, you agree to the Terms of Service and Data Processing
|
||||
Agreement.
|
||||
</StyledFooterNote>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql';
|
||||
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
|
||||
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { PASSWORD_REGEX } from '../../utils/passwordRegex';
|
||||
|
||||
export enum SignInUpMode {
|
||||
SignIn = 'sign-in',
|
||||
SignUp = 'sign-up',
|
||||
Invite = 'invite',
|
||||
}
|
||||
|
||||
export enum SignInUpStep {
|
||||
Init = 'init',
|
||||
Email = 'email',
|
||||
Password = 'password',
|
||||
}
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
exist: z.boolean(),
|
||||
email: z.string().email('Email must be a valid email'),
|
||||
password: z
|
||||
.string()
|
||||
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
|
||||
})
|
||||
.required();
|
||||
|
||||
type Form = z.infer<typeof validationSchema>;
|
||||
|
||||
export const useSignInUp = () => {
|
||||
const navigate = useNavigate();
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const isMatchingLocation = useIsMatchingLocation();
|
||||
const [authProviders] = useRecoilState(authProvidersState);
|
||||
const isSignInPrefilled = useRecoilValue(isSignInPrefilledState);
|
||||
const workspaceInviteHash = useParams().workspaceInviteHash;
|
||||
const [signInUpStep, setSignInUpStep] = useState<SignInUpStep>(
|
||||
SignInUpStep.Init,
|
||||
);
|
||||
const [signInUpMode, setSignInUpMode] = useState<SignInUpMode>(() => {
|
||||
if (isMatchingLocation(AppPath.Invite)) {
|
||||
return SignInUpMode.Invite;
|
||||
}
|
||||
|
||||
return isMatchingLocation(AppPath.SignIn)
|
||||
? SignInUpMode.SignIn
|
||||
: SignInUpMode.SignUp;
|
||||
});
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
const { data: workspaceFromInviteHash } = useGetWorkspaceFromInviteHashQuery({
|
||||
variables: { inviteHash: workspaceInviteHash || '' },
|
||||
});
|
||||
|
||||
const form = useForm<Form>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
exist: false,
|
||||
},
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isSignInPrefilled) {
|
||||
form.setValue('email', 'tim@apple.dev');
|
||||
form.setValue('password', 'Applecar2025');
|
||||
}
|
||||
}, [form, isSignInPrefilled]);
|
||||
|
||||
const {
|
||||
signInWithCredentials,
|
||||
signUpWithCredentials,
|
||||
signInWithGoogle,
|
||||
checkUserExists: { checkUserExistsQuery },
|
||||
} = useAuth();
|
||||
|
||||
const continueWithEmail = useCallback(() => {
|
||||
setSignInUpStep(SignInUpStep.Email);
|
||||
setSignInUpMode(
|
||||
isMatchingLocation(AppPath.SignIn)
|
||||
? SignInUpMode.SignIn
|
||||
: SignInUpMode.SignUp,
|
||||
);
|
||||
}, [setSignInUpStep, setSignInUpMode, isMatchingLocation]);
|
||||
|
||||
const continueWithCredentials = useCallback(() => {
|
||||
checkUserExistsQuery({
|
||||
variables: {
|
||||
email: form.getValues('email').toLowerCase(),
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
if (data?.checkUserExists.exists) {
|
||||
setSignInUpMode(SignInUpMode.SignIn);
|
||||
} else {
|
||||
setSignInUpMode(SignInUpMode.SignUp);
|
||||
}
|
||||
setSignInUpStep(SignInUpStep.Password);
|
||||
},
|
||||
});
|
||||
}, [setSignInUpStep, checkUserExistsQuery, form, setSignInUpMode]);
|
||||
|
||||
const submitCredentials: SubmitHandler<Form> = useCallback(
|
||||
async (data) => {
|
||||
try {
|
||||
if (!data.email || !data.password) {
|
||||
throw new Error('Email and password are required');
|
||||
}
|
||||
let currentWorkspace;
|
||||
|
||||
if (signInUpMode === SignInUpMode.SignIn) {
|
||||
const { workspace } = await signInWithCredentials(
|
||||
data.email.toLowerCase(),
|
||||
data.password,
|
||||
);
|
||||
currentWorkspace = workspace;
|
||||
} else {
|
||||
const { workspace } = await signUpWithCredentials(
|
||||
data.email.toLowerCase(),
|
||||
data.password,
|
||||
workspaceInviteHash,
|
||||
);
|
||||
currentWorkspace = workspace;
|
||||
}
|
||||
if (currentWorkspace?.displayName) {
|
||||
navigate('/');
|
||||
} else {
|
||||
navigate('/create/workspace');
|
||||
}
|
||||
} catch (err: any) {
|
||||
enqueueSnackBar(err?.message, {
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
signInUpMode,
|
||||
signInWithCredentials,
|
||||
signUpWithCredentials,
|
||||
workspaceInviteHash,
|
||||
navigate,
|
||||
enqueueSnackBar,
|
||||
],
|
||||
);
|
||||
|
||||
const goBackToEmailStep = useCallback(() => {
|
||||
setSignInUpStep(SignInUpStep.Email);
|
||||
}, [setSignInUpStep]);
|
||||
|
||||
useScopedHotkeys(
|
||||
'enter',
|
||||
() => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.Email) {
|
||||
continueWithCredentials();
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.Password) {
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}
|
||||
},
|
||||
PageHotkeyScope.SignInUp,
|
||||
[continueWithEmail],
|
||||
);
|
||||
|
||||
return {
|
||||
authProviders,
|
||||
signInWithGoogle: () => signInWithGoogle(workspaceInviteHash),
|
||||
signInUpStep,
|
||||
signInUpMode,
|
||||
showErrors,
|
||||
setShowErrors,
|
||||
continueWithCredentials,
|
||||
continueWithEmail,
|
||||
goBackToEmailStep,
|
||||
submitCredentials,
|
||||
form,
|
||||
workspace: workspaceFromInviteHash?.findWorkspaceFromInviteHash,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { User } from '~/generated/graphql';
|
||||
|
||||
export type CurrentUser = Pick<
|
||||
User,
|
||||
'id' | 'email' | 'supportUserHash' | 'canImpersonate'
|
||||
>;
|
||||
|
||||
export const currentUserState = atom<CurrentUser | null>({
|
||||
key: 'currentUserState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,8 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
|
||||
|
||||
export const currentWorkspaceMemberState = atom<WorkspaceMember | null>({
|
||||
key: 'currentWorkspaceMemberState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,18 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { Workspace } from '~/generated/graphql';
|
||||
|
||||
export type CurrentWorkspace = Pick<
|
||||
Workspace,
|
||||
| 'id'
|
||||
| 'inviteHash'
|
||||
| 'logo'
|
||||
| 'displayName'
|
||||
| 'allowImpersonation'
|
||||
| 'featureFlags'
|
||||
>;
|
||||
|
||||
export const currentWorkspaceState = atom<CurrentWorkspace | null>({
|
||||
key: 'currentWorkspaceState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const isVerifyPendingState = atom<boolean>({
|
||||
key: 'isVerifyPendingState',
|
||||
default: false,
|
||||
});
|
||||
@ -0,0 +1,10 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { AuthTokenPair } from '~/generated/graphql';
|
||||
import { cookieStorageEffect } from '~/utils/recoil-effects';
|
||||
|
||||
export const tokenPairState = atom<AuthTokenPair | null>({
|
||||
key: 'tokenPairState',
|
||||
default: null,
|
||||
effects: [cookieStorageEffect('tokenPair')],
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import { CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
|
||||
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
|
||||
|
||||
export enum OnboardingStatus {
|
||||
OngoingUserCreation = 'ongoing_user_creation',
|
||||
OngoingWorkspaceCreation = 'ongoing_workspace_creation',
|
||||
OngoingProfileCreation = 'ongoing_profile_creation',
|
||||
Completed = 'completed',
|
||||
}
|
||||
|
||||
export const getOnboardingStatus = (
|
||||
isLoggedIn: boolean,
|
||||
currentWorkspaceMember: WorkspaceMember | null,
|
||||
currentWorkspace: CurrentWorkspace | null,
|
||||
) => {
|
||||
if (!isLoggedIn) {
|
||||
return OnboardingStatus.OngoingUserCreation;
|
||||
}
|
||||
|
||||
// if the user has not been fetched yet, we can't know the onboarding status
|
||||
if (!currentWorkspaceMember) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!currentWorkspace?.displayName) {
|
||||
return OnboardingStatus.OngoingWorkspaceCreation;
|
||||
}
|
||||
if (
|
||||
!currentWorkspaceMember.name.firstName ||
|
||||
!currentWorkspaceMember.name.lastName
|
||||
) {
|
||||
return OnboardingStatus.OngoingProfileCreation;
|
||||
}
|
||||
|
||||
return OnboardingStatus.Completed;
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const PASSWORD_REGEX = /^.{8,}$/;
|
||||
Reference in New Issue
Block a user