Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View 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>
);
};

View 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>
);

View File

@ -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>
);

View 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>;
};

View File

@ -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
}
}
`;

View File

@ -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
}
}
}
`;

View File

@ -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
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GENERATE_ONE_TRANSIENT_TOKEN = gql`
mutation generateTransientToken {
generateTransientToken {
transientToken {
token
}
}
}
`;

View File

@ -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
}
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const RENEW_TOKEN = gql`
mutation RenewToken($refreshToken: String!) {
renewToken(refreshToken: $refreshToken) {
tokens {
...AuthTokensFragment
}
}
}
`;

View File

@ -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
}
}
}
`;

View File

@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const VERIFY = gql`
mutation Verify($loginToken: String!) {
verify(loginToken: $loginToken) {
user {
...UserQueryFragment
}
tokens {
...AuthTokensFragment
}
}
}
`;

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const CHECK_USER_EXISTS = gql`
query CheckUserExists($email: String!) {
checkUserExists(email: $email) {
exists
}
}
`;

View 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,
};
};

View 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;
};

View File

@ -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,
);
};

View File

@ -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;
};

View File

@ -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>
);

View File

@ -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 />;

View File

@ -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>
</>
);
};

View File

@ -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,
};
};

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isVerifyPendingState = atom<boolean>({
key: 'isVerifyPendingState',
default: false,
});

View File

@ -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')],
});

View File

@ -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;
};

View File

@ -0,0 +1 @@
export const PASSWORD_REGEX = /^.{8,}$/;