Refactor login (#748)

* wip refactor login

* wip refactor login

* Fix lint conflicts

* Complete Sign In only

* Feature complete

* Fix test

* Fix test
This commit is contained in:
Charles Bochet 2023-07-21 22:05:45 -07:00 committed by GitHub
parent 725a46adfa
commit 775b4c353d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 758 additions and 764 deletions

View File

@ -4,7 +4,7 @@ import { ThemeProvider } from '@emotion/react';
import { withThemeFromJSXProvider } from '@storybook/addon-styling';
import { lightTheme, darkTheme } from '../src/modules/ui/themes/themes';
import { RootDecorator } from '../src/testing/decorators';
import { mockedUserJWT } from '../src/testing/mock-data/jwt';
initialize();
const preview: Preview = {
@ -28,6 +28,9 @@ const preview: Preview = {
date: /Date$/,
},
},
cookie: {
tokenPair: `{%22accessToken%22:{%22token%22:%22${mockedUserJWT}%22%2C%22expiresAt%22:%222023-07-18T15:06:40.704Z%22%2C%22__typename%22:%22AuthToken%22}%2C%22refreshToken%22:{%22token%22:%22${mockedUserJWT}%22%2C%22expiresAt%22:%222023-10-15T15:06:41.558Z%22%2C%22__typename%22:%22AuthToken%22}%2C%22__typename%22:%22AuthTokenPair%22}`,
},
options: {
storySort: {
order: ['UI', 'Modules', 'Pages'],

View File

@ -8,6 +8,9 @@ module.exports = {
if (error.message === "ResizeObserver loop limit exceeded") {
return false;
}
if (error.message === "Unauthorized") {
return false;
}
return true;
},
},

View File

@ -1,18 +1,10 @@
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { AnimatePresence, LayoutGroup } from 'framer-motion';
import { Navigate, Route, Routes } from 'react-router-dom';
import { RequireOnboarded } from '@/auth/components/RequireOnboarded';
import { RequireOnboarding } from '@/auth/components/RequireOnboarding';
import { AuthModal } from '@/auth/components/ui/Modal';
import { AppPath } from '@/types/AppPath';
import { AuthPath } from '@/types/AuthPath';
import { SettingsPath } from '@/types/SettingsPath';
import { AuthLayout } from '@/ui/layout/components/AuthLayout';
import { DefaultLayout } from '@/ui/layout/components/DefaultLayout';
import { CreateProfile } from '~/pages/auth/CreateProfile';
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
import { Index } from '~/pages/auth/Index';
import { PasswordLogin } from '~/pages/auth/PasswordLogin';
import { Verify } from '~/pages/auth/Verify';
import { Companies } from '~/pages/companies/Companies';
import { CompanyShow } from '~/pages/companies/CompanyShow';
@ -25,32 +17,7 @@ import { SettingsWorksapce } from '~/pages/settings/SettingsWorkspace';
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
import { AppInternalHooks } from '~/sync-hooks/AppInternalHooks';
/**
* AuthRoutes is used to allow transitions between auth pages with framer-motion.
*/
function AuthRoutes() {
const location = useLocation();
return (
<LayoutGroup>
<AuthModal>
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route path={AuthPath.Index} element={<Index />} />
<Route path={AuthPath.Callback} element={<Verify />} />
<Route path={AuthPath.PasswordLogin} element={<PasswordLogin />} />
<Route path={AuthPath.InviteLink} element={<PasswordLogin />} />
<Route
path={AuthPath.CreateWorkspace}
element={<CreateWorkspace />}
/>
<Route path={AuthPath.CreateProfile} element={<CreateProfile />} />
</Routes>
</AnimatePresence>
</AuthModal>
</LayoutGroup>
);
}
import { SignInUp } from './pages/auth/SignInUp';
export function App() {
return (
@ -58,65 +25,40 @@ export function App() {
<AppInternalHooks />
<DefaultLayout>
<Routes>
<Route
path={AppPath.AuthCatchAll}
element={
<RequireOnboarding>
<AuthLayout>
<AuthRoutes />
</AuthLayout>
</RequireOnboarding>
}
/>
<Route
path="*"
element={
<RequireOnboarded>
<Routes>
<Route
path=""
element={<Navigate to={AppPath.CompaniesPage} replace />}
/>
<Route path={AppPath.PeoplePage} element={<People />} />
<Route
path={AppPath.PersonShowPage}
element={<PersonShow />}
/>
<Route path={AppPath.CompaniesPage} element={<Companies />} />
<Route
path={AppPath.CompanyShowPage}
element={<CompanyShow />}
/>
<Route path={AppPath.Verify} element={<Verify />} />
<Route path={AppPath.SignIn} element={<SignInUp />} />
<Route path={AppPath.SignUp} element={<SignInUp />} />
<Route path={AppPath.Invite} element={<SignInUp />} />
<Route path={AppPath.CreateWorkspace} element={<CreateWorkspace />} />
<Route path={AppPath.CreateProfile} element={<CreateProfile />} />
<Route path="/" element={<Navigate to={AppPath.CompaniesPage} />} />
<Route path={AppPath.PeoplePage} element={<People />} />
<Route path={AppPath.PersonShowPage} element={<PersonShow />} />
<Route path={AppPath.CompaniesPage} element={<Companies />} />
<Route path={AppPath.CompanyShowPage} element={<CompanyShow />} />
<Route
path={AppPath.OpportunitiesPage}
element={<Opportunities />}
/>
<Route
path={AppPath.SettingsCatchAll}
element={
<Routes>
<Route
path={SettingsPath.ProfilePage}
element={<SettingsProfile />}
/>
<Route
path={SettingsPath.Experience}
element={<SettingsExperience />}
/>
<Route
path={SettingsPath.WorkspaceMembersPage}
element={<SettingsWorkspaceMembers />}
/>
<Route
path={SettingsPath.Workspace}
element={<SettingsWorksapce />}
/>
</Routes>
}
/>
</Routes>
</RequireOnboarded>
<Route path={AppPath.OpportunitiesPage} element={<Opportunities />} />
<Route
path={AppPath.SettingsCatchAll}
element={
<Routes>
<Route
path={SettingsPath.ProfilePage}
element={<SettingsProfile />}
/>
<Route
path={SettingsPath.Experience}
element={<SettingsExperience />}
/>
<Route
path={SettingsPath.WorkspaceMembersPage}
element={<SettingsWorkspaceMembers />}
/>
<Route
path={SettingsPath.Workspace}
element={<SettingsWorksapce />}
/>
</Routes>
}
/>
</Routes>

View File

@ -2024,7 +2024,7 @@ export type VerifyMutationVariables = Exact<{
}>;
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null } } | null }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, colorScheme: ColorScheme, locale: string } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type RenewTokenMutationVariables = Exact<{
refreshToken: Scalars['String'];
@ -2924,6 +2924,11 @@ export const VerifyDocument = gql`
logo
}
}
settings {
id
colorScheme
locale
}
}
tokens {
accessToken {

View File

@ -6,7 +6,7 @@ import { AppBasePath } from '@/types/AppBasePath';
export function useIsMatchingLocation() {
const location = useLocation();
return function isMatchingLocation(basePath: AppBasePath, path: string) {
return function isMatchingLocation(path: string, basePath?: AppBasePath) {
const constructedPath = basePath
? parse(`${basePath}/${path}`).pathname ?? ''
: path;

View File

@ -4,6 +4,8 @@ type Props = React.ComponentProps<'div'>;
const StyledLogo = styled.div`
height: 48px;
margin-bottom: ${({ theme }) => theme.spacing(4)};
margin-top: ${({ theme }) => theme.spacing(4)};
img {
height: 100%;

View File

@ -11,9 +11,6 @@ const StyledContainer = styled.div`
flex-direction: column;
padding: ${({ theme }) => theme.spacing(10)};
width: calc(400px - ${({ theme }) => theme.spacing(10 * 2)});
> * + * {
margin-top: ${({ theme }) => theme.spacing(8)};
}
`;
export function AuthModal({ children, ...restProps }: Props) {

View File

@ -1,71 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import { useOnboardingStatus } from '../hooks/useOnboardingStatus';
import { OnboardingStatus } from '../utils/getOnboardingStatus';
const EmptyContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
`;
const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`;
const FadeInStyle = styled.div`
animation: ${fadeIn} 1s forwards;
opacity: 0;
`;
export function RequireOnboarded({
children,
}: {
children: JSX.Element;
}): JSX.Element {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
useEffect(() => {
if (onboardingStatus === OnboardingStatus.OngoingUserCreation) {
navigate('/auth');
} else if (onboardingStatus === OnboardingStatus.OngoingWorkspaceCreation) {
navigate('/auth/create/workspace');
} else if (onboardingStatus === OnboardingStatus.OngoingProfileCreation) {
navigate('/auth/create/profile');
}
}, [onboardingStatus, navigate]);
if (onboardingStatus && onboardingStatus !== OnboardingStatus.Completed) {
return (
<EmptyContainer>
<FadeInStyle>
{onboardingStatus === OnboardingStatus.OngoingUserCreation && (
<div>
Please hold on a moment, we're directing you to our login page...
</div>
)}
{onboardingStatus !== OnboardingStatus.OngoingUserCreation && (
<div>
Please hold on a moment, we're directing you to our onboarding
flow...
</div>
)}
</FadeInStyle>
</EmptyContainer>
);
}
return children;
}

View File

@ -1,57 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import { useOnboardingStatus } from '../hooks/useOnboardingStatus';
import { OnboardingStatus } from '../utils/getOnboardingStatus';
const EmptyContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
`;
const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`;
const FadeInStyle = styled.div`
animation: ${fadeIn} 1s forwards;
opacity: 0;
`;
export function RequireOnboarding({
children,
}: {
children: JSX.Element;
}): JSX.Element {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
useEffect(() => {
if (onboardingStatus === OnboardingStatus.Completed) {
navigate('/');
}
}, [navigate, onboardingStatus]);
if (onboardingStatus === OnboardingStatus.Completed) {
return (
<EmptyContainer>
<FadeInStyle>
Please hold on a moment, we're directing you to the app...
</FadeInStyle>
</EmptyContainer>
);
}
return children;
}

View File

@ -7,7 +7,6 @@ type OwnProps = {
const StyledSubTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export function SubTitle({ children }: OwnProps): JSX.Element {

View File

@ -1,7 +1,7 @@
import React from 'react';
import styled from '@emotion/styled';
import { AnimatedTextWord } from '@/ui/animation/components/AnimatedTextWord';
import { AnimatedEaseIn } from '../../ui/animation/components/AnimatedEaseIn';
type Props = React.PropsWithChildren & {
animate?: boolean;
@ -11,17 +11,17 @@ const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledAnimatedTextWord = styled(AnimatedTextWord)`
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 function Title({ children, animate = false }: Props) {
if (animate && typeof children === 'string') {
return <StyledAnimatedTextWord text={children} />;
if (animate) {
return (
<StyledTitle>
<AnimatedEaseIn>{children}</AnimatedEaseIn>
</StyledTitle>
);
}
return <StyledTitle>{children}</StyledTitle>;

View File

@ -1,8 +1,10 @@
import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import { useRecoilState } from 'recoil';
import {
useChallengeMutation,
useCheckUserExistsLazyQuery,
useSignUpMutation,
useVerifyMutation,
} from '~/generated/graphql';
@ -13,12 +15,16 @@ import { tokenPairState } from '../states/tokenPairState';
export function useAuth() {
const [, setTokenPair] = useRecoilState(tokenPairState);
const [, setCurrentUser] = useRecoilState(currentUserState);
const [, setIsAuthenticating] = useRecoilState(isAuthenticatingState);
const [, setCurrentUser] = useRecoilState(currentUserState);
const [challenge] = useChallengeMutation();
const [signUp] = useSignUpMutation();
const [verify] = useVerifyMutation();
const [checkUserExistsQuery, { data: checkUserExistsData }] =
useCheckUserExistsLazyQuery();
const client = useApolloClient();
const handleChallenge = useCallback(
async (email: string, password: string) => {
@ -65,21 +71,25 @@ export function useAuth() {
[setIsAuthenticating, setTokenPair, verify],
);
const handleLogin = useCallback(
const handleCrendentialsSignIn = useCallback(
async (email: string, password: string) => {
const { loginToken } = await handleChallenge(email, password);
await handleVerify(loginToken.token);
const { user } = await handleVerify(loginToken.token);
return { user };
},
[handleChallenge, handleVerify],
);
const handleLogout = useCallback(() => {
const handleSignOut = useCallback(() => {
setTokenPair(null);
setCurrentUser(null);
}, [setTokenPair, setCurrentUser]);
client.clearStore().then(() => {
sessionStorage.clear();
});
}, [setTokenPair, client, setCurrentUser]);
const handleSignUp = useCallback(
const handleCredentialsSignUp = useCallback(
async (email: string, password: string, workspaceInviteHash?: string) => {
const signUpResult = await signUp({
variables: {
@ -97,16 +107,33 @@ export function useAuth() {
throw new Error('No login token');
}
await handleVerify(signUpResult.data?.signUp.loginToken.token);
const { user } = await handleVerify(
signUpResult.data?.signUp.loginToken.token,
);
setCurrentUser(user);
return { user };
},
[signUp, handleVerify],
[signUp, handleVerify, setCurrentUser],
);
const handleGoogleLogin = useCallback((workspaceInviteHash?: string) => {
window.location.href =
`${process.env.REACT_APP_AUTH_URL}/google/${
workspaceInviteHash ? '?inviteHash=' + workspaceInviteHash : ''
}` || '';
}, []);
return {
challenge: handleChallenge,
verify: handleVerify,
login: handleLogin,
signUp: handleSignUp,
logout: handleLogout,
checkUserExists: { checkUserExistsData, checkUserExistsQuery },
signOut: handleSignOut,
signUpWithCredentials: handleCredentialsSignUp,
signInWithCredentials: handleCrendentialsSignIn,
signInWithGoogle: handleGoogleLogin,
};
}

View File

@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { useIsLogged } from '../hooks/useIsLogged';
@ -12,10 +11,5 @@ export function useOnboardingStatus(): OnboardingStatus | undefined {
const [currentUser] = useRecoilState(currentUserState);
const isLoggedIn = useIsLogged();
const onboardingStatus = useMemo(
() => getOnboardingStatus(isLoggedIn, currentUser),
[currentUser, isLoggedIn],
);
return onboardingStatus;
return getOnboardingStatus(isLoggedIn, currentUser);
}

View File

@ -48,6 +48,11 @@ export const VERIFY = gql`
logo
}
}
settings {
id
colorScheme
locale
}
}
tokens {
accessToken {

View File

@ -0,0 +1,210 @@
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 { AnimatedEaseIn } from '@/ui/animation/components/AnimatedEaseIn';
import { MainButton } from '@/ui/button/components/MainButton';
import { IconBrandGoogle } from '@/ui/icon';
import { TextInput } from '@/ui/input/components/TextInput';
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 function SignInUpForm() {
const {
authProviders,
signInWithGoogle,
signInUpStep,
signInUpMode,
showErrors,
setShowErrors,
continueWithCredentials,
continueWithEmail,
submitCredentials,
form: {
control,
watch,
handleSubmit,
formState: { isSubmitting },
},
} = useSignInUp();
const theme = useTheme();
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]);
return (
<>
<AnimatedEaseIn>
<Logo />
</AnimatedEaseIn>
<Title animate>
{signInUpMode === SignInUpMode.SignIn
? 'Sign in to Twenty'
: 'Sign up to Twenty'}
</Title>
<StyledContentContainer>
{authProviders.google && (
<>
<MainButton
icon={<IconBrandGoogle size={theme.icon.size.sm} stroke={4} />}
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}
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}
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,178 @@
import { useCallback, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { yupResolver } from '@hookform/resolvers/yup';
import { useRecoilState, useRecoilValue } from 'recoil';
import * as Yup from 'yup';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { isDemoModeState } from '@/client-config/states/isDemoModeState';
import { AppPath } from '@/types/AppPath';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { useAuth } from '../../hooks/useAuth';
import { currentUserState } from '../../states/currentUserState';
import { PASSWORD_REGEX } from '../../utils/passwordRegex';
export enum SignInUpMode {
SignIn = 'sign-in',
SignUp = 'sign-up',
}
export enum SignInUpStep {
Init = 'init',
Email = 'email',
Password = 'password',
}
const validationSchema = Yup.object()
.shape({
exist: Yup.boolean().required(),
email: Yup.string()
.email('Email must be a valid email')
.required('Email must be a valid email'),
password: Yup.string()
.matches(PASSWORD_REGEX, 'Password must contain at least 8 characters')
.required(),
})
.required();
type Form = Yup.InferType<typeof validationSchema>;
export function useSignInUp() {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const isMatchingLocation = useIsMatchingLocation();
const [authProviders] = useRecoilState(authProvidersState);
const isDemoMode = useRecoilValue(isDemoModeState);
const workspaceInviteHash = useParams().workspaceInviteHash;
const [signInUpStep, setSignInUpStep] = useState<SignInUpStep>(
SignInUpStep.Init,
);
const [signInUpMode, setSignInUpMode] = useState<SignInUpMode>(
isMatchingLocation(AppPath.SignIn)
? SignInUpMode.SignIn
: SignInUpMode.SignUp,
);
const [showErrors, setShowErrors] = useState(false);
const [, setCurrentUser] = useRecoilState(currentUserState);
const form = useForm<Form>({
mode: 'onChange',
defaultValues: {
exist: false,
email: isDemoMode ? 'tim@apple.dev' : '',
password: isDemoMode ? 'Applecar2025' : '',
},
resolver: yupResolver(validationSchema),
});
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'),
},
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');
}
if (signInUpMode === SignInUpMode.SignIn) {
const { user } = await signInWithCredentials(
data.email,
data.password,
);
setCurrentUser(user);
} else {
const { user } = await signUpWithCredentials(
data.email,
data.password,
workspaceInviteHash,
);
setCurrentUser(user);
}
navigate('/create/workspace');
} catch (err: any) {
enqueueSnackBar(err?.message, {
variant: 'error',
});
}
},
[
navigate,
signInWithCredentials,
signUpWithCredentials,
workspaceInviteHash,
enqueueSnackBar,
signInUpMode,
setCurrentUser,
],
);
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,
};
}

View File

@ -17,11 +17,11 @@ import SubNavbar from '@/ui/navbar/components/SubNavbar';
export function SettingsNavbar() {
const theme = useTheme();
const { logout } = useAuth();
const { signOut } = useAuth();
const handleLogout = useCallback(() => {
logout();
}, [logout]);
signOut();
}, [signOut]);
return (
<SubNavbar backButtonTitle="Settings">

View File

@ -1,5 +1,16 @@
export enum AppPath {
AuthCatchAll = `/auth/*`,
// Not logged-in
Verify = 'verify',
SignIn = 'sign-in',
SignUp = 'sign-up',
Invite = 'invite/:workspaceInviteHash',
// Onboarding
CreateWorkspace = 'create/workspace',
CreateProfile = 'create/profile',
// Onboarded
Index = '',
PeoplePage = '/people',
CompaniesPage = '/companies',
CompanyShowPage = '/companies/:companyId',

View File

@ -1,8 +0,0 @@
export enum AuthPath {
Index = '',
Callback = 'callback',
PasswordLogin = 'password-login',
CreateWorkspace = 'create/workspace',
CreateProfile = 'create/profile',
InviteLink = 'invite/:workspaceInviteHash',
}

View File

@ -1,8 +1,7 @@
export enum PageHotkeyScope {
Settings = 'settings',
CreateWokspace = 'create-workspace',
PasswordLogin = 'password-login',
AuthIndex = 'auth-index',
SignInUp = 'sign-in-up',
CreateProfile = 'create-profile',
ShowPage = 'show-page',
PersonShowPage = 'person-show-page',

View File

@ -9,7 +9,7 @@ type Props = Omit<
export function AnimatedEaseIn({
children,
duration = 0.8,
duration = 0.3,
...restProps
}: Props) {
const initial = { opacity: 0 };

View File

@ -55,9 +55,9 @@ const StyledButton = styled.button<Pick<Props, 'fullWidth' | 'variant'>>`
font-weight: ${({ theme }) => theme.font.weight.semiBold};
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
outline: none;
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
${({ theme, variant }) => {
switch (variant) {
case 'secondary':

View File

@ -20,6 +20,7 @@ type OwnProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
label?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
error?: string;
};
@ -104,6 +105,7 @@ export function TextInput({
error,
required,
type,
disableHotkeys = false,
...props
}: OwnProps): JSX.Element {
const theme = useTheme();
@ -117,16 +119,20 @@ export function TextInput({
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => {
onFocus?.(e);
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
if (!disableHotkeys) {
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
}
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
onBlur?.(e);
goBackToPreviousHotkeyScope();
if (!disableHotkeys) {
goBackToPreviousHotkeyScope();
}
};
useScopedHotkeys(
[Key.Enter, Key.Escape],
[Key.Escape, Key.Enter],
() => {
inputRef.current?.blur();
},

View File

@ -1,12 +1,20 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import { AnimatePresence, LayoutGroup } from 'framer-motion';
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { AuthModal } from '@/auth/components/Modal';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { NavbarAnimatedContainer } from '@/ui/navbar/components/NavbarAnimatedContainer';
import { MOBILE_VIEWPORT } from '@/ui/themes/themes';
import { AppNavbar } from '~/AppNavbar';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { CompaniesMockMode } from '~/pages/companies/CompaniesMockMode';
import { AppPath } from '../../../types/AppPath';
import { isNavbarOpenedState } from '../states/isNavbarOpenedState';
const StyledLayout = styled.div`
@ -38,22 +46,71 @@ type OwnProps = {
};
export function DefaultLayout({ children }: OwnProps) {
const currentUser = useRecoilState(currentUserState);
const userIsAuthenticated = !!currentUser;
const navigate = useNavigate();
const isMatchingLocation = useIsMatchingLocation();
const onboardingStatus = useOnboardingStatus();
useEffect(() => {
const isMachinOngoingUserCreationRoute =
isMatchingLocation(AppPath.SignUp) ||
isMatchingLocation(AppPath.SignIn) ||
isMatchingLocation(AppPath.Invite) ||
isMatchingLocation(AppPath.Verify);
const isMatchingOnboardingRoute =
isMatchingLocation(AppPath.SignUp) ||
isMatchingLocation(AppPath.SignIn) ||
isMatchingLocation(AppPath.Invite) ||
isMatchingLocation(AppPath.Verify) ||
isMatchingLocation(AppPath.CreateWorkspace) ||
isMatchingLocation(AppPath.CreateProfile);
if (
onboardingStatus === OnboardingStatus.OngoingUserCreation &&
!isMachinOngoingUserCreationRoute
) {
navigate(AppPath.SignIn);
} else if (
onboardingStatus === OnboardingStatus.OngoingWorkspaceCreation &&
!isMatchingLocation(AppPath.CreateWorkspace)
) {
navigate(AppPath.CreateWorkspace);
} else if (
onboardingStatus === OnboardingStatus.OngoingProfileCreation &&
!isMatchingLocation(AppPath.CreateProfile)
) {
navigate(AppPath.CreateProfile);
} else if (
onboardingStatus === OnboardingStatus.Completed &&
isMatchingOnboardingRoute
) {
navigate('/');
}
}, [onboardingStatus, navigate, isMatchingLocation]);
return (
<StyledLayout>
{userIsAuthenticated ? (
<>
<CommandMenu />
<NavbarAnimatedContainer>
<AppNavbar />
</NavbarAnimatedContainer>
<MainContainer>{children}</MainContainer>
</>
) : (
children
)}
<>
<CommandMenu />
<NavbarAnimatedContainer>
<AppNavbar />
</NavbarAnimatedContainer>
<MainContainer>
{onboardingStatus &&
onboardingStatus !== OnboardingStatus.Completed ? (
<>
<CompaniesMockMode />
<AnimatePresence mode="wait">
<LayoutGroup>
<AuthModal>{children}</AuthModal>
</LayoutGroup>
</AnimatePresence>
</>
) : (
<>{children}</>
)}
</MainContainer>
</>
</StyledLayout>
);
}

View File

@ -8,6 +8,7 @@ type Props = {
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledTitle = styled.h2`

View File

@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { currentUserState } from '@/auth/states/currentUserState';
import { useGetCurrentUserQuery } from '~/generated/graphql';
@ -9,11 +8,7 @@ export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useRecoilState(currentUserState);
const [isLoading, setIsLoading] = useState(true);
const isLogged = useIsLogged();
const { data, loading } = useGetCurrentUserQuery({
skip: !isLogged,
});
const { data, loading } = useGetCurrentUserQuery();
useEffect(() => {
if (!loading) {

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react';
import { useCallback } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { getOperationName } from '@apollo/client/utilities';
@ -8,11 +8,9 @@ import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import * as Yup from 'yup';
import { SubTitle } from '@/auth/components/ui/SubTitle';
import { Title } from '@/auth/components/ui/Title';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { currentUserState } from '@/auth/states/currentUserState';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { MainButton } from '@/ui/button/components/MainButton';
@ -25,18 +23,14 @@ import { useUpdateUserMutation } from '~/generated/graphql';
const StyledContentContainer = styled.div`
width: 100%;
> * + * {
margin-top: ${({ theme }) => theme.spacing(6)};
}
`;
const StyledSectionContainer = styled.div`
> * + * {
margin-top: ${({ theme }) => theme.spacing(4)};
}
margin-top: ${({ theme }) => theme.spacing(8)};
`;
const StyledButtonContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(8)};
width: 200px;
`;
@ -59,7 +53,6 @@ type Form = Yup.InferType<typeof validationSchema>;
export function CreateProfile() {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
const { enqueueSnackBar } = useSnackBar();
@ -129,12 +122,6 @@ export function CreateProfile() {
[onSubmit],
);
useEffect(() => {
if (onboardingStatus !== OnboardingStatus.OngoingProfileCreation) {
navigate('/');
}
}, [onboardingStatus, navigate]);
return (
<>
<Title>Create profile</Title>
@ -159,6 +146,7 @@ export function CreateProfile() {
fieldState: { error },
}) => (
<TextInput
autoFocus
label="First Name"
value={value}
onBlur={onBlur}
@ -166,6 +154,7 @@ export function CreateProfile() {
placeholder="Tim"
error={error?.message}
fullWidth
disableHotkeys
/>
)}
/>
@ -184,6 +173,7 @@ export function CreateProfile() {
placeholder="Cook"
error={error?.message}
fullWidth
disableHotkeys
/>
)}
/>

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react';
import { useCallback } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { getOperationName } from '@apollo/client/utilities';
@ -6,10 +6,8 @@ import styled from '@emotion/styled';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import { SubTitle } from '@/auth/components/ui/SubTitle';
import { Title } from '@/auth/components/ui/Title';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { MainButton } from '@/ui/button/components/MainButton';
@ -18,22 +16,21 @@ import { TextInput } from '@/ui/input/components/TextInput';
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
import { SubSectionTitle } from '@/ui/title/components/SubSectionTitle';
import { GET_CURRENT_USER } from '@/users/queries';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
import {
useGetCurrentUserLazyQuery,
useUpdateWorkspaceMutation,
} from '~/generated/graphql';
const StyledContentContainer = styled.div`
width: 100%;
> * + * {
margin-top: ${({ theme }) => theme.spacing(6)};
}
`;
const StyledSectionContainer = styled.div`
> * + * {
margin-top: ${({ theme }) => theme.spacing(4)};
}
margin-top: ${({ theme }) => theme.spacing(8)};
`;
const StyledButtonContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(8)};
width: 200px;
`;
@ -47,11 +44,11 @@ type Form = Yup.InferType<typeof validationSchema>;
export function CreateWorkspace() {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
const { enqueueSnackBar } = useSnackBar();
const [updateWorkspace] = useUpdateWorkspaceMutation();
useGetCurrentUserLazyQuery();
// Form
const {
@ -84,7 +81,9 @@ export function CreateWorkspace() {
throw result.errors ?? new Error('Unknown error');
}
navigate('/auth/create/profile');
setTimeout(() => {
navigate('/create/profile');
}, 20);
} catch (error: any) {
enqueueSnackBar(error?.message, {
variant: 'error',
@ -103,12 +102,6 @@ export function CreateWorkspace() {
[onSubmit],
);
useEffect(() => {
if (onboardingStatus !== OnboardingStatus.OngoingWorkspaceCreation) {
navigate('/auth/create/profile');
}
}, [onboardingStatus, navigate]);
return (
<>
<Title>Create your workspace</Title>
@ -119,7 +112,6 @@ export function CreateWorkspace() {
<StyledContentContainer>
<StyledSectionContainer>
<SubSectionTitle title="Workspace logo" />
{/* Picture is actually uploaded on the fly */}
<WorkspaceLogoUploader />
</StyledSectionContainer>
<StyledSectionContainer>
@ -135,12 +127,14 @@ export function CreateWorkspace() {
fieldState: { error },
}) => (
<TextInput
autoFocus
value={value}
placeholder="Apple"
onBlur={onBlur}
onChange={onChange}
error={error?.message}
fullWidth
disableHotkeys
/>
)}
/>

View File

@ -1,119 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useRecoilState } from 'recoil';
import { FooterNote } from '@/auth/components/ui/FooterNote';
import { HorizontalSeparator } from '@/auth/components/ui/HorizontalSeparator';
import { Logo } from '@/auth/components/ui/Logo';
import { Title } from '@/auth/components/ui/Title';
import { authFlowUserEmailState } from '@/auth/states/authFlowUserEmailState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { isDemoModeState } from '@/client-config/states/isDemoModeState';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { AnimatedEaseIn } from '@/ui/animation/components/AnimatedEaseIn';
import { MainButton } from '@/ui/button/components/MainButton';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { IconBrandGoogle } from '@/ui/icon';
import { TextInput } from '@/ui/input/components/TextInput';
const StyledContentContainer = styled.div`
width: 200px;
> * + * {
margin-top: ${({ theme }) => theme.spacing(3)};
}
`;
const StyledFooterNote = styled(FooterNote)`
max-width: 283px;
`;
export function Index() {
const navigate = useNavigate();
const theme = useTheme();
const [authProviders] = useRecoilState(authProvidersState);
const [demoMode] = useRecoilState(isDemoModeState);
const [authFlowUserEmail, setAuthFlowUserEmail] = useRecoilState(
authFlowUserEmailState,
);
const [visible, setVisible] = useState(false);
const onGoogleLoginClick = useCallback(() => {
window.location.href = process.env.REACT_APP_AUTH_URL + '/google' || '';
}, []);
const onPasswordLoginClick = useCallback(() => {
if (!visible) {
setVisible(true);
return;
}
navigate('/auth/password-login');
}, [navigate, visible]);
useScopedHotkeys(
'enter',
() => {
onPasswordLoginClick();
},
PageHotkeyScope.AuthIndex,
[onPasswordLoginClick],
);
useEffect(() => {
setAuthFlowUserEmail(demoMode ? 'tim@apple.dev' : '');
}, [navigate, setAuthFlowUserEmail, demoMode]);
return (
<>
<AnimatedEaseIn>
<Logo />
</AnimatedEaseIn>
<Title animate>Welcome to Twenty</Title>
<StyledContentContainer>
{authProviders.google && (
<MainButton
icon={<IconBrandGoogle size={theme.icon.size.sm} stroke={4} />}
title="Continue with Google"
onClick={onGoogleLoginClick}
fullWidth
/>
)}
{visible && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<HorizontalSeparator />
<TextInput
value={authFlowUserEmail}
placeholder="Email"
onChange={(value) => setAuthFlowUserEmail(value)}
fullWidth={true}
/>
</motion.div>
)}
<MainButton
title="Continue with Email"
onClick={onPasswordLoginClick}
disabled={!authFlowUserEmail && visible}
variant="secondary"
fullWidth
/>
</StyledContentContainer>
<StyledFooterNote>
By using Twenty, you agree to the Terms of Service and Data Processing
Agreement.
</StyledFooterNote>
</>
);
}

View File

@ -1,203 +0,0 @@
import { useCallback, useState } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { yupResolver } from '@hookform/resolvers/yup';
import { useRecoilState } from 'recoil';
import * as Yup from 'yup';
import { Logo } from '@/auth/components/ui/Logo';
import { SubTitle } from '@/auth/components/ui/SubTitle';
import { Title } from '@/auth/components/ui/Title';
import { useAuth } from '@/auth/hooks/useAuth';
import { authFlowUserEmailState } from '@/auth/states/authFlowUserEmailState';
import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex';
import { isDemoModeState } from '@/client-config/states/isDemoModeState';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { MainButton } from '@/ui/button/components/MainButton';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { TextInput } from '@/ui/input/components/TextInput';
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
import { SubSectionTitle } from '@/ui/title/components/SubSectionTitle';
import { useCheckUserExistsQuery } from '~/generated/graphql';
const StyledContentContainer = styled.div`
width: 100%;
> * + * {
margin-top: ${({ theme }) => theme.spacing(6)};
}
`;
const StyledForm = styled.form`
align-items: center;
display: flex;
flex-direction: column;
width: 100%;
> * + * {
margin-top: ${({ theme }) => theme.spacing(8)};
}
`;
const StyledSectionContainer = styled.div`
> * + * {
margin-top: ${({ theme }) => theme.spacing(4)};
}
`;
const StyledButtonContainer = styled.div`
width: 200px;
`;
const validationSchema = Yup.object()
.shape({
exist: Yup.boolean().required(),
email: Yup.string().email('Email must be a valid email').required(),
password: Yup.string()
.matches(PASSWORD_REGEX, 'Password must contain at least 8 characters')
.required(),
})
.required();
type Form = Yup.InferType<typeof validationSchema>;
export function PasswordLogin() {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const [isDemoMode] = useRecoilState(isDemoModeState);
const [authFlowUserEmail] = useRecoilState(authFlowUserEmailState);
const [showErrors, setShowErrors] = useState(false);
const workspaceInviteHash = useParams().workspaceInviteHash;
const { data: checkUserExistsData } = useCheckUserExistsQuery({
variables: {
email: authFlowUserEmail,
},
});
const { login, signUp } = useAuth();
// Form
const {
control,
handleSubmit,
formState: { isSubmitting },
watch,
getValues,
} = useForm<Form>({
mode: 'onChange',
defaultValues: {
exist: false,
email: authFlowUserEmail,
password: isDemoMode ? 'Applecar2025' : '',
},
resolver: yupResolver(validationSchema),
});
const onSubmit: SubmitHandler<Form> = useCallback(
async (data) => {
try {
if (!data.email || !data.password) {
throw new Error('Email and password are required');
}
if (checkUserExistsData?.checkUserExists.exists) {
await login(data.email, data.password);
} else {
await signUp(data.email, data.password, workspaceInviteHash);
}
navigate('/auth/create/workspace');
} catch (err: any) {
enqueueSnackBar(err?.message, {
variant: 'error',
});
}
},
[
checkUserExistsData?.checkUserExists.exists,
navigate,
login,
signUp,
workspaceInviteHash,
enqueueSnackBar,
],
);
useScopedHotkeys(
'enter',
() => {
onSubmit(getValues());
},
PageHotkeyScope.PasswordLogin,
[onSubmit],
);
return (
<>
<Logo />
<Title>Welcome to Twenty</Title>
<SubTitle>
Enter your credentials to sign{' '}
{checkUserExistsData?.checkUserExists.exists ? 'in' : 'up'}
</SubTitle>
<StyledForm
onSubmit={(event) => {
setShowErrors(true);
return handleSubmit(onSubmit)(event);
}}
>
<StyledContentContainer>
<StyledSectionContainer>
<SubSectionTitle title="Email" />
<Controller
name="email"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
value={value}
placeholder="Email"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
/>
)}
/>
</StyledSectionContainer>
<StyledSectionContainer>
<SubSectionTitle title="Password" />
<Controller
name="password"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
value={value}
type="password"
placeholder="Password"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
/>
)}
/>
</StyledSectionContainer>
</StyledContentContainer>
<StyledButtonContainer>
<MainButton
title="Continue"
type="submit"
disabled={!watch('email') || !watch('password') || isSubmitting}
fullWidth
/>
</StyledButtonContainer>
</StyledForm>
</>
);
}

View File

@ -0,0 +1,5 @@
import { SignInUpForm } from '../../modules/auth/sign-in-up/components/SignInUpForm';
export function SignInUp() {
return <SignInUpForm />;
}

View File

@ -4,6 +4,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { AppPath } from '../../modules/types/AppPath';
export function Verify() {
const [searchParams] = useSearchParams();
const loginToken = searchParams.get('loginToken');
@ -16,10 +18,11 @@ export function Verify() {
useEffect(() => {
async function getTokens() {
if (!loginToken) {
return;
navigate(AppPath.SignIn);
} else {
await verify(loginToken);
navigate('/');
}
await verify(loginToken);
navigate('/');
}
if (!isLogged) {

View File

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AuthModal } from '@/auth/components/ui/Modal';
import { AuthModal } from '@/auth/components/Modal';
import { AuthLayout } from '@/ui/layout/components/AuthLayout';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getRenderWrapperForPage } from '~/testing/renderWrappers';

View File

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AuthModal } from '@/auth/components/ui/Modal';
import { AuthModal } from '@/auth/components/Modal';
import { AuthLayout } from '@/ui/layout/components/AuthLayout';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getRenderWrapperForPage } from '~/testing/renderWrappers';
@ -23,7 +23,7 @@ export const Default: Story = {
<CreateWorkspace />
</AuthModal>
</AuthLayout>,
'/auth/create-workspace',
'/create-workspace',
),
parameters: {
msw: graphqlMocks,

View File

@ -1,29 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AuthModal } from '@/auth/components/ui/Modal';
import { AuthModal } from '@/auth/components/Modal';
import { AuthLayout } from '@/ui/layout/components/AuthLayout';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getRenderWrapperForPage } from '~/testing/renderWrappers';
import { Index } from '../Index';
import { SignInUp } from '../SignInUp';
const meta: Meta<typeof Index> = {
title: 'Pages/Auth/Index',
component: Index,
const meta: Meta<typeof SignInUp> = {
title: 'Pages/Auth/SignInUp',
component: SignInUp,
};
export default meta;
export type Story = StoryObj<typeof Index>;
export type Story = StoryObj<typeof SignInUp>;
export const Default: Story = {
render: getRenderWrapperForPage(
<AuthLayout>
<AuthModal>
<Index />
<SignInUp />
</AuthModal>
</AuthLayout>,
'/auth',
'/',
),
parameters: {
msw: graphqlMocks,

View File

@ -1,31 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AuthModal } from '@/auth/components/ui/Modal';
import { AuthLayout } from '@/ui/layout/components/AuthLayout';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getRenderWrapperForPage } from '~/testing/renderWrappers';
import { PasswordLogin } from '../PasswordLogin';
const meta: Meta<typeof PasswordLogin> = {
title: 'Pages/Auth/PasswordLogin',
component: PasswordLogin,
};
export default meta;
export type Story = StoryObj<typeof PasswordLogin>;
export const Default: Story = {
render: getRenderWrapperForPage(
<AuthLayout>
<AuthModal>
<PasswordLogin />
</AuthModal>
</AuthLayout>,
'/auth/password-login',
),
parameters: {
msw: graphqlMocks,
},
};

View File

@ -85,7 +85,7 @@ export function SettingsWorkspaceMembers() {
description="Send an invitation to use Twenty"
/>
<WorkspaceInviteLink
inviteLink={`${window.location.origin}/auth/invite/${workspace?.inviteHash}`}
inviteLink={`${window.location.origin}/invite/${workspace?.inviteHash}`}
/>
</>
)}

View File

@ -2,7 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/testing-library';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedUserJWT } from '~/testing/mock-data/jwt';
import { getRenderWrapperForPage } from '~/testing/renderWrappers';
import { SettingsProfile } from '../SettingsProfile';
@ -20,9 +19,6 @@ export const Default: Story = {
render: getRenderWrapperForPage(<SettingsProfile />, '/settings/profile'),
parameters: {
msw: graphqlMocks,
cookie: {
tokenPair: `{%22accessToken%22:{%22token%22:%22${mockedUserJWT}%22%2C%22expiresAt%22:%222023-07-18T15:06:40.704Z%22%2C%22__typename%22:%22AuthToken%22}%2C%22refreshToken%22:{%22token%22:%22${mockedUserJWT}%22%2C%22expiresAt%22:%222023-10-15T15:06:41.558Z%22%2C%22__typename%22:%22AuthToken%22}%2C%22__typename%22:%22AuthTokenPair%22}`,
},
},
};

View File

@ -1,7 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedUserJWT } from '~/testing/mock-data/jwt';
import { getRenderWrapperForPage } from '~/testing/renderWrappers';
import { SettingsWorkspaceMembers } from '../SettingsWorkspaceMembers';
@ -22,8 +21,5 @@ export const Default: Story = {
),
parameters: {
msw: graphqlMocks,
cookie: {
tokenPair: `{%22accessToken%22:{%22token%22:%22${mockedUserJWT}%22%2C%22expiresAt%22:%222023-07-18T15:06:40.704Z%22%2C%22__typename%22:%22AuthToken%22}%2C%22refreshToken%22:{%22token%22:%22${mockedUserJWT}%22%2C%22expiresAt%22:%222023-10-15T15:06:41.558Z%22%2C%22__typename%22:%22AuthToken%22}%2C%22__typename%22:%22AuthTokenPair%22}`,
},
},
};

View File

@ -2,7 +2,6 @@ import { useEffect } from 'react';
import { AppBasePath } from '@/types/AppBasePath';
import { AppPath } from '@/types/AppPath';
import { AuthPath } from '@/types/AuthPath';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { SettingsPath } from '@/types/SettingsPath';
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
@ -16,49 +15,53 @@ export function HotkeyScopeBrowserRouterSync() {
useEffect(() => {
switch (true) {
case isMatchingLocation(AppBasePath.Root, AppPath.CompaniesPage): {
case isMatchingLocation(AppPath.CompaniesPage): {
setHotkeyScope(TableHotkeyScope.Table, { goto: true });
break;
}
case isMatchingLocation(AppBasePath.Root, AppPath.PeoplePage): {
case isMatchingLocation(AppPath.PeoplePage): {
setHotkeyScope(TableHotkeyScope.Table, { goto: true });
break;
}
case isMatchingLocation(AppBasePath.Root, AppPath.CompanyShowPage): {
case isMatchingLocation(AppPath.CompanyShowPage): {
setHotkeyScope(PageHotkeyScope.CompanyShowPage, { goto: true });
break;
}
case isMatchingLocation(AppBasePath.Root, AppPath.PersonShowPage): {
case isMatchingLocation(AppPath.PersonShowPage): {
setHotkeyScope(PageHotkeyScope.PersonShowPage, { goto: true });
break;
}
case isMatchingLocation(AppBasePath.Root, AppPath.OpportunitiesPage): {
case isMatchingLocation(AppPath.OpportunitiesPage): {
setHotkeyScope(PageHotkeyScope.OpportunitiesPage, { goto: true });
break;
}
case isMatchingLocation(AppBasePath.Auth, AuthPath.Index): {
setHotkeyScope(PageHotkeyScope.AuthIndex);
case isMatchingLocation(AppPath.SignIn): {
setHotkeyScope(PageHotkeyScope.SignInUp);
break;
}
case isMatchingLocation(AppBasePath.Auth, AuthPath.CreateProfile): {
case isMatchingLocation(AppPath.SignUp): {
setHotkeyScope(PageHotkeyScope.SignInUp);
break;
}
case isMatchingLocation(AppPath.Invite): {
setHotkeyScope(PageHotkeyScope.SignInUp);
break;
}
case isMatchingLocation(AppPath.CreateProfile): {
setHotkeyScope(PageHotkeyScope.CreateProfile);
break;
}
case isMatchingLocation(AppBasePath.Auth, AuthPath.CreateWorkspace): {
case isMatchingLocation(AppPath.CreateWorkspace): {
setHotkeyScope(PageHotkeyScope.CreateWokspace);
break;
}
case isMatchingLocation(AppBasePath.Auth, AuthPath.PasswordLogin): {
setHotkeyScope(PageHotkeyScope.PasswordLogin);
break;
}
case isMatchingLocation(AppBasePath.Settings, SettingsPath.ProfilePage): {
case isMatchingLocation(SettingsPath.ProfilePage, AppBasePath.Settings): {
setHotkeyScope(PageHotkeyScope.ProfilePage, { goto: true });
break;
}
case isMatchingLocation(
AppBasePath.Settings,
SettingsPath.WorkspaceMembersPage,
AppBasePath.Settings,
): {
setHotkeyScope(PageHotkeyScope.WorkspaceMemberPage, { goto: true });
break;

View File

@ -111,7 +111,6 @@ export const graphqlMocks = [
);
}),
graphql.query(getOperationName(GET_PERSON) ?? '', (req, res, ctx) => {
console.log({ req });
const returnedMockedData = fetchOneFromData<
GetPersonQuery['findUniquePerson']
>(mockedPeopleData, req.variables.id);

View File

@ -9,6 +9,7 @@ type MockedCompany = Pick<
| 'createdAt'
| 'address'
| 'employees'
| 'linkedinUrl'
| '_commentThreadCount'
> & {
accountOwner: Pick<
@ -31,6 +32,7 @@ export const mockedCompaniesData: Array<MockedCompany> = [
createdAt: '2023-04-26T10:08:54.724515+00:00',
address: '17 rue de clignancourt',
employees: 12,
linkedinUrl: 'https://www.linkedin.com/company/airbnb/',
_commentThreadCount: 1,
accountOwner: {
email: 'charles@test.com',
@ -50,6 +52,7 @@ export const mockedCompaniesData: Array<MockedCompany> = [
createdAt: '2023-04-26T10:12:42.33625+00:00',
address: '',
employees: 1,
linkedinUrl: 'https://www.linkedin.com/company/aircall/',
_commentThreadCount: 1,
accountOwner: null,
__typename: 'Company',
@ -61,6 +64,7 @@ export const mockedCompaniesData: Array<MockedCompany> = [
createdAt: '2023-04-26T10:10:32.530184+00:00',
address: '',
employees: 1,
linkedinUrl: 'https://www.linkedin.com/company/algolia/',
_commentThreadCount: 1,
accountOwner: null,
__typename: 'Company',
@ -72,6 +76,7 @@ export const mockedCompaniesData: Array<MockedCompany> = [
createdAt: '2023-03-21T06:30:25.39474+00:00',
address: '',
employees: 10,
linkedinUrl: 'https://www.linkedin.com/company/apple/',
_commentThreadCount: 0,
accountOwner: null,
__typename: 'Company',
@ -83,6 +88,7 @@ export const mockedCompaniesData: Array<MockedCompany> = [
createdAt: '2023-04-26T10:13:29.712485+00:00',
address: '10 rue de la Paix',
employees: 1,
linkedinUrl: 'https://www.linkedin.com/company/qonto/',
_commentThreadCount: 2,
accountOwner: null,
__typename: 'Company',
@ -94,6 +100,7 @@ export const mockedCompaniesData: Array<MockedCompany> = [
createdAt: '2023-04-26T10:09:25.656555+00:00',
address: '',
employees: 1,
linkedinUrl: 'https://www.linkedin.com/company/facebook/',
_commentThreadCount: 13,
accountOwner: null,
__typename: 'Company',
@ -105,6 +112,7 @@ export const mockedCompaniesData: Array<MockedCompany> = [
createdAt: '2023-04-26T10:09:25.656555+00:00',
address: '',
employees: 1,
linkedinUrl: 'https://www.linkedin.com/company/sequoia/',
_commentThreadCount: 1,
accountOwner: null,
__typename: 'Company',

View File

@ -7,7 +7,7 @@ LOGIN_TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_SECRET=secret_refresh_token
REFRESH_TOKEN_EXPIRES_IN=90d
PG_DATABASE_URL=postgres://postgres:postgrespassword@localhost:5432/default?connection_limit=1
FRONT_AUTH_CALLBACK_URL=http://localhost:3001/auth/callback
FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=.local-storage

View File

@ -1,12 +1,4 @@
import {
Controller,
Get,
InternalServerErrorException,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
@ -14,45 +6,64 @@ import { GoogleRequest } from 'src/core/auth/strategies/google.auth.strategy';
import { UserService } from 'src/core/user/user.service';
import { TokenService } from 'src/core/auth/services/token.service';
import { GoogleProviderEnabledGuard } from 'src/core/auth/guards/google-provider-enabled.guard';
import { GoogleOauthGuard } from 'src/core/auth/guards/google-oauth.guard';
import { WorkspaceService } from 'src/core/workspace/services/workspace.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
@Controller('auth/google')
export class GoogleAuthController {
constructor(
private readonly tokenService: TokenService,
private readonly userService: UserService,
private readonly workspaceService: WorkspaceService,
private readonly environmentService: EnvironmentService,
) {}
@Get()
@UseGuards(GoogleProviderEnabledGuard, AuthGuard('google'))
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard)
async googleAuth() {
// As this method is protected by Google Auth guard, it will trigger Google SSO flow
return;
}
@Get('redirect')
@UseGuards(GoogleProviderEnabledGuard, AuthGuard('google'))
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard)
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
const { firstName, lastName, email } = req.user;
const { firstName, lastName, email, workspaceInviteHash } = req.user;
const user = await this.userService.createUser({
data: {
email,
firstName: firstName ?? '',
lastName: lastName ?? '',
locale: 'en',
settings: {
create: {
locale: 'en',
let workspaceId: string | undefined = undefined;
if (workspaceInviteHash) {
const workspace = await this.workspaceService.findFirst({
where: {
inviteHash: workspaceInviteHash,
},
});
if (!workspace) {
return res.redirect(
`${this.environmentService.getFrontAuthCallbackUrl()}`,
);
}
workspaceId = workspace.id;
}
const user = await this.userService.createUser(
{
data: {
email,
firstName: firstName ?? '',
lastName: lastName ?? '',
locale: 'en',
settings: {
create: {
locale: 'en',
},
},
},
},
});
if (!user) {
throw new InternalServerErrorException(
'User email domain does not match an existing workspace',
);
}
workspaceId,
);
const loginToken = await this.tokenService.generateLoginToken(user.email);

View File

@ -0,0 +1,27 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GoogleOauthGuard extends AuthGuard('google') {
constructor() {
super({
prompt: 'select_account',
});
}
async canActivate(context: ExecutionContext) {
try {
const request = context.switchToHttp().getRequest();
const workspaceInviteHash = request.query.inviteHash;
if (workspaceInviteHash && typeof workspaceInviteHash === 'string') {
request.params.workspaceInviteHash = workspaceInviteHash;
}
const activate = (await super.canActivate(context)) as boolean;
return activate;
} catch (ex) {
throw ex;
}
}
}

View File

@ -8,9 +8,10 @@ import { EnvironmentService } from 'src/integrations/environment/environment.ser
export type GoogleRequest = Request & {
user: {
firstName: string | undefined | null;
lastName: string | undefined | null;
firstName?: string | null;
lastName?: string | null;
email: string;
workspaceInviteHash?: string;
};
};
@ -22,23 +23,39 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
clientSecret: environmentService.getAuthGoogleClientSecret(),
callbackURL: environmentService.getAuthGoogleCallbackUrl(),
scope: ['email', 'profile'],
passReqToCallback: true,
});
}
authenticate(req: any, options: any) {
options = {
...options,
state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash,
}),
};
return super.authenticate(req, options);
}
async validate(
request: GoogleRequest,
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
): Promise<any> {
const { name, emails, photos } = profile;
): Promise<void> {
const { name, emails } = profile;
const state =
typeof request.query.state === 'string'
? JSON.parse(request.query.state)
: undefined;
const user = {
email: emails[0].value,
firstName: name.givenName,
lastName: name.familyName,
picture: photos[0].value,
refreshToken,
accessToken,
workspaceInviteHash: state.workspaceInviteHash,
};
done(null, user);
}

View File

@ -46,6 +46,6 @@ export class CompanyService {
data: companies,
});
return this.findMany({ where: { workspaceId }});
return this.findMany({ where: { workspaceId } });
}
}