From 775b4c353dd5ede066e525909364644748cdce52 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 21 Jul 2023 22:05:45 -0700 Subject: [PATCH] Refactor login (#748) * wip refactor login * wip refactor login * Fix lint conflicts * Complete Sign In only * Feature complete * Fix test * Fix test --- front/.storybook/preview.ts | 5 +- front/craco.config.js | 3 + front/src/App.tsx | 128 +++-------- front/src/generated/graphql.tsx | 7 +- front/src/hooks/useIsMatchingLocation.ts | 2 +- .../modules/auth/components/{ui => }/Logo.tsx | 2 + .../auth/components/{ui => }/Modal.tsx | 3 - .../auth/components/RequireOnboarded.tsx | 71 ------ .../auth/components/RequireOnboarding.tsx | 57 ----- .../auth/components/{ui => }/SubTitle.tsx | 1 - .../auth/components/{ui => }/Title.tsx | 18 +- front/src/modules/auth/hooks/useAuth.ts | 49 +++- .../modules/auth/hooks/useOnboardingStatus.ts | 8 +- front/src/modules/auth/queries/update.ts | 5 + .../components}/FooterNote.tsx | 0 .../components}/HorizontalSeparator.tsx | 0 .../sign-in-up/components/SignInUpForm.tsx | 210 ++++++++++++++++++ .../auth/sign-in-up/hooks/useSignInUp.tsx | 178 +++++++++++++++ .../settings/components/SettingsNavbar.tsx | 6 +- front/src/modules/types/AppPath.ts | 13 +- front/src/modules/types/AuthPath.ts | 8 - front/src/modules/types/PageHotkeyScope.ts | 3 +- .../animation/components/AnimatedEaseIn.tsx | 2 +- .../ui/button/components/MainButton.tsx | 2 +- .../modules/ui/input/components/TextInput.tsx | 12 +- .../ui/layout/components/DefaultLayout.tsx | 87 ++++++-- .../ui/title/components/SubSectionTitle.tsx | 1 + .../modules/users/components/UserProvider.tsx | 7 +- front/src/pages/auth/CreateProfile.tsx | 26 +-- front/src/pages/auth/CreateWorkspace.tsx | 36 ++- front/src/pages/auth/Index.tsx | 119 ---------- front/src/pages/auth/PasswordLogin.tsx | 203 ----------------- front/src/pages/auth/SignInUp.tsx | 5 + front/src/pages/auth/Verify.tsx | 9 +- .../__stories__/CreateProfile.stories.tsx | 2 +- .../__stories__/CreateWorkspace.stories.tsx | 4 +- .../pages/auth/__stories__/Index.stories.tsx | 16 +- .../__stories__/PasswordLogin.stories.tsx | 31 --- .../settings/SettingsWorkspaceMembers.tsx | 2 +- .../__stories__/SettingsProfile.stories.tsx | 4 - .../SettingsWorkspaceMembers.stories.tsx | 4 - .../HotkeyScopeBrowserRouterSync.tsx | 35 +-- front/src/testing/graphqlMocks.ts | 1 - front/src/testing/mock-data/companies.ts | 8 + server/.env.example | 2 +- .../controllers/google-auth.controller.ts | 67 +++--- .../core/auth/guards/google-oauth.guard.ts | 27 +++ .../auth/strategies/google.auth.strategy.ts | 31 ++- server/src/core/company/company.service.ts | 2 +- 49 files changed, 758 insertions(+), 764 deletions(-) rename front/src/modules/auth/components/{ui => }/Logo.tsx (78%) rename front/src/modules/auth/components/{ui => }/Modal.tsx (89%) delete mode 100644 front/src/modules/auth/components/RequireOnboarded.tsx delete mode 100644 front/src/modules/auth/components/RequireOnboarding.tsx rename front/src/modules/auth/components/{ui => }/SubTitle.tsx (86%) rename front/src/modules/auth/components/{ui => }/Title.tsx (52%) rename front/src/modules/auth/{components/ui => sign-in-up/components}/FooterNote.tsx (100%) rename front/src/modules/auth/{components/ui => sign-in-up/components}/HorizontalSeparator.tsx (100%) create mode 100644 front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx create mode 100644 front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx delete mode 100644 front/src/modules/types/AuthPath.ts delete mode 100644 front/src/pages/auth/Index.tsx delete mode 100644 front/src/pages/auth/PasswordLogin.tsx create mode 100644 front/src/pages/auth/SignInUp.tsx delete mode 100644 front/src/pages/auth/__stories__/PasswordLogin.stories.tsx create mode 100644 server/src/core/auth/guards/google-oauth.guard.ts diff --git a/front/.storybook/preview.ts b/front/.storybook/preview.ts index 249492952..1852a6426 100644 --- a/front/.storybook/preview.ts +++ b/front/.storybook/preview.ts @@ -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'], diff --git a/front/craco.config.js b/front/craco.config.js index 9f9efefe4..014f218cf 100644 --- a/front/craco.config.js +++ b/front/craco.config.js @@ -8,6 +8,9 @@ module.exports = { if (error.message === "ResizeObserver loop limit exceeded") { return false; } + if (error.message === "Unauthorized") { + return false; + } return true; }, }, diff --git a/front/src/App.tsx b/front/src/App.tsx index 1f64485f4..aaeedffd7 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -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 ( - - - - - } /> - } /> - } /> - } /> - } - /> - } /> - - - - - ); -} +import { SignInUp } from './pages/auth/SignInUp'; export function App() { return ( @@ -58,65 +25,40 @@ export function App() { - - - - - - } - /> - - - } - /> - } /> - } - /> - } /> - } - /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } - /> - - } - /> - } - /> - } - /> - } - /> - - } - /> - - + } /> + + } + /> + } + /> + } + /> + } + /> + } /> diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 0b8457e94..a9bc5350d 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -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 { diff --git a/front/src/hooks/useIsMatchingLocation.ts b/front/src/hooks/useIsMatchingLocation.ts index e9c9594b1..477cc69bc 100644 --- a/front/src/hooks/useIsMatchingLocation.ts +++ b/front/src/hooks/useIsMatchingLocation.ts @@ -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; diff --git a/front/src/modules/auth/components/ui/Logo.tsx b/front/src/modules/auth/components/Logo.tsx similarity index 78% rename from front/src/modules/auth/components/ui/Logo.tsx rename to front/src/modules/auth/components/Logo.tsx index 129cdb75d..4a33ea49b 100644 --- a/front/src/modules/auth/components/ui/Logo.tsx +++ b/front/src/modules/auth/components/Logo.tsx @@ -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%; diff --git a/front/src/modules/auth/components/ui/Modal.tsx b/front/src/modules/auth/components/Modal.tsx similarity index 89% rename from front/src/modules/auth/components/ui/Modal.tsx rename to front/src/modules/auth/components/Modal.tsx index 98fee0874..0222befb9 100644 --- a/front/src/modules/auth/components/ui/Modal.tsx +++ b/front/src/modules/auth/components/Modal.tsx @@ -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) { diff --git a/front/src/modules/auth/components/RequireOnboarded.tsx b/front/src/modules/auth/components/RequireOnboarded.tsx deleted file mode 100644 index d18fc2918..000000000 --- a/front/src/modules/auth/components/RequireOnboarded.tsx +++ /dev/null @@ -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 ( - - - {onboardingStatus === OnboardingStatus.OngoingUserCreation && ( -
- Please hold on a moment, we're directing you to our login page... -
- )} - {onboardingStatus !== OnboardingStatus.OngoingUserCreation && ( -
- Please hold on a moment, we're directing you to our onboarding - flow... -
- )} -
-
- ); - } - - return children; -} diff --git a/front/src/modules/auth/components/RequireOnboarding.tsx b/front/src/modules/auth/components/RequireOnboarding.tsx deleted file mode 100644 index 93aa215a1..000000000 --- a/front/src/modules/auth/components/RequireOnboarding.tsx +++ /dev/null @@ -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 ( - - - Please hold on a moment, we're directing you to the app... - - - ); - } - - return children; -} diff --git a/front/src/modules/auth/components/ui/SubTitle.tsx b/front/src/modules/auth/components/SubTitle.tsx similarity index 86% rename from front/src/modules/auth/components/ui/SubTitle.tsx rename to front/src/modules/auth/components/SubTitle.tsx index 013a40b54..69d2e385c 100644 --- a/front/src/modules/auth/components/ui/SubTitle.tsx +++ b/front/src/modules/auth/components/SubTitle.tsx @@ -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 { diff --git a/front/src/modules/auth/components/ui/Title.tsx b/front/src/modules/auth/components/Title.tsx similarity index 52% rename from front/src/modules/auth/components/ui/Title.tsx rename to front/src/modules/auth/components/Title.tsx index 46191e824..218348f61 100644 --- a/front/src/modules/auth/components/ui/Title.tsx +++ b/front/src/modules/auth/components/Title.tsx @@ -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 ; + if (animate) { + return ( + + {children} + + ); } return {children}; diff --git a/front/src/modules/auth/hooks/useAuth.ts b/front/src/modules/auth/hooks/useAuth.ts index dbb061101..aa4af1f80 100644 --- a/front/src/modules/auth/hooks/useAuth.ts +++ b/front/src/modules/auth/hooks/useAuth.ts @@ -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, }; } diff --git a/front/src/modules/auth/hooks/useOnboardingStatus.ts b/front/src/modules/auth/hooks/useOnboardingStatus.ts index 68ec6813c..62ce18646 100644 --- a/front/src/modules/auth/hooks/useOnboardingStatus.ts +++ b/front/src/modules/auth/hooks/useOnboardingStatus.ts @@ -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); } diff --git a/front/src/modules/auth/queries/update.ts b/front/src/modules/auth/queries/update.ts index f79fc2913..5f21e9cc9 100644 --- a/front/src/modules/auth/queries/update.ts +++ b/front/src/modules/auth/queries/update.ts @@ -48,6 +48,11 @@ export const VERIFY = gql` logo } } + settings { + id + colorScheme + locale + } } tokens { accessToken { diff --git a/front/src/modules/auth/components/ui/FooterNote.tsx b/front/src/modules/auth/sign-in-up/components/FooterNote.tsx similarity index 100% rename from front/src/modules/auth/components/ui/FooterNote.tsx rename to front/src/modules/auth/sign-in-up/components/FooterNote.tsx diff --git a/front/src/modules/auth/components/ui/HorizontalSeparator.tsx b/front/src/modules/auth/sign-in-up/components/HorizontalSeparator.tsx similarity index 100% rename from front/src/modules/auth/components/ui/HorizontalSeparator.tsx rename to front/src/modules/auth/sign-in-up/components/HorizontalSeparator.tsx diff --git a/front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx new file mode 100644 index 000000000..4a6565fd1 --- /dev/null +++ b/front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -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 ( + <> + + + + + {signInUpMode === SignInUpMode.SignIn + ? 'Sign in to Twenty' + : 'Sign up to Twenty'} + + + {authProviders.google && ( + <> + } + title="Continue with Google" + onClick={signInWithGoogle} + fullWidth + /> + + + )} + + { + event.preventDefault(); + }} + > + {signInUpStep !== SignInUpStep.Init && ( + + ( + + { + onChange(value); + if (signInUpStep === SignInUpStep.Password) { + continueWithEmail(); + } + }} + error={showErrors ? error?.message : undefined} + fullWidth + disableHotkeys + /> + + )} + /> + + )} + {signInUpStep === SignInUpStep.Password && ( + + ( + + + + )} + /> + + )} + + { + 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 + /> + + + + By using Twenty, you agree to the Terms of Service and Data Processing + Agreement. + + + ); +} diff --git a/front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx new file mode 100644 index 000000000..936fe3c70 --- /dev/null +++ b/front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -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; + +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.Init, + ); + const [signInUpMode, setSignInUpMode] = useState( + isMatchingLocation(AppPath.SignIn) + ? SignInUpMode.SignIn + : SignInUpMode.SignUp, + ); + const [showErrors, setShowErrors] = useState(false); + const [, setCurrentUser] = useRecoilState(currentUserState); + + const form = useForm
({ + 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 = 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, + }; +} diff --git a/front/src/modules/settings/components/SettingsNavbar.tsx b/front/src/modules/settings/components/SettingsNavbar.tsx index b41aa10d6..91fbe6b8f 100644 --- a/front/src/modules/settings/components/SettingsNavbar.tsx +++ b/front/src/modules/settings/components/SettingsNavbar.tsx @@ -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 ( diff --git a/front/src/modules/types/AppPath.ts b/front/src/modules/types/AppPath.ts index 170264763..f48177b06 100644 --- a/front/src/modules/types/AppPath.ts +++ b/front/src/modules/types/AppPath.ts @@ -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', diff --git a/front/src/modules/types/AuthPath.ts b/front/src/modules/types/AuthPath.ts deleted file mode 100644 index 3d3c3aca4..000000000 --- a/front/src/modules/types/AuthPath.ts +++ /dev/null @@ -1,8 +0,0 @@ -export enum AuthPath { - Index = '', - Callback = 'callback', - PasswordLogin = 'password-login', - CreateWorkspace = 'create/workspace', - CreateProfile = 'create/profile', - InviteLink = 'invite/:workspaceInviteHash', -} diff --git a/front/src/modules/types/PageHotkeyScope.ts b/front/src/modules/types/PageHotkeyScope.ts index 058fed9f0..04205d0fe 100644 --- a/front/src/modules/types/PageHotkeyScope.ts +++ b/front/src/modules/types/PageHotkeyScope.ts @@ -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', diff --git a/front/src/modules/ui/animation/components/AnimatedEaseIn.tsx b/front/src/modules/ui/animation/components/AnimatedEaseIn.tsx index 9eb8186aa..0753caa9a 100644 --- a/front/src/modules/ui/animation/components/AnimatedEaseIn.tsx +++ b/front/src/modules/ui/animation/components/AnimatedEaseIn.tsx @@ -9,7 +9,7 @@ type Props = Omit< export function AnimatedEaseIn({ children, - duration = 0.8, + duration = 0.3, ...restProps }: Props) { const initial = { opacity: 0 }; diff --git a/front/src/modules/ui/button/components/MainButton.tsx b/front/src/modules/ui/button/components/MainButton.tsx index 28bef874b..3b33171d5 100644 --- a/front/src/modules/ui/button/components/MainButton.tsx +++ b/front/src/modules/ui/button/components/MainButton.tsx @@ -55,9 +55,9 @@ const StyledButton = styled.button>` 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': diff --git a/front/src/modules/ui/input/components/TextInput.tsx b/front/src/modules/ui/input/components/TextInput.tsx index 56aca09ba..c50a7d1dd 100644 --- a/front/src/modules/ui/input/components/TextInput.tsx +++ b/front/src/modules/ui/input/components/TextInput.tsx @@ -20,6 +20,7 @@ type OwnProps = Omit, '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 = (e) => { onFocus?.(e); - setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput); + if (!disableHotkeys) { + setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput); + } }; const handleBlur: FocusEventHandler = (e) => { onBlur?.(e); - goBackToPreviousHotkeyScope(); + if (!disableHotkeys) { + goBackToPreviousHotkeyScope(); + } }; useScopedHotkeys( - [Key.Enter, Key.Escape], + [Key.Escape, Key.Enter], () => { inputRef.current?.blur(); }, diff --git a/front/src/modules/ui/layout/components/DefaultLayout.tsx b/front/src/modules/ui/layout/components/DefaultLayout.tsx index 8c17e0a78..f45e048d7 100644 --- a/front/src/modules/ui/layout/components/DefaultLayout.tsx +++ b/front/src/modules/ui/layout/components/DefaultLayout.tsx @@ -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 ( - {userIsAuthenticated ? ( - <> - - - - - {children} - - ) : ( - children - )} + <> + + + + + + {onboardingStatus && + onboardingStatus !== OnboardingStatus.Completed ? ( + <> + + + + {children} + + + + ) : ( + <>{children} + )} + + ); } diff --git a/front/src/modules/ui/title/components/SubSectionTitle.tsx b/front/src/modules/ui/title/components/SubSectionTitle.tsx index 832b49b58..eb2e72312 100644 --- a/front/src/modules/ui/title/components/SubSectionTitle.tsx +++ b/front/src/modules/ui/title/components/SubSectionTitle.tsx @@ -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` diff --git a/front/src/modules/users/components/UserProvider.tsx b/front/src/modules/users/components/UserProvider.tsx index 3cbf66b46..e8440237d 100644 --- a/front/src/modules/users/components/UserProvider.tsx +++ b/front/src/modules/users/components/UserProvider.tsx @@ -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) { diff --git a/front/src/pages/auth/CreateProfile.tsx b/front/src/pages/auth/CreateProfile.tsx index 410e45c36..944f70a35 100644 --- a/front/src/pages/auth/CreateProfile.tsx +++ b/front/src/pages/auth/CreateProfile.tsx @@ -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; 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 ( <> Create profile @@ -159,6 +146,7 @@ export function CreateProfile() { fieldState: { error }, }) => ( )} /> @@ -184,6 +173,7 @@ export function CreateProfile() { placeholder="Cook" error={error?.message} fullWidth + disableHotkeys /> )} /> diff --git a/front/src/pages/auth/CreateWorkspace.tsx b/front/src/pages/auth/CreateWorkspace.tsx index dd9003949..cee9dcdaa 100644 --- a/front/src/pages/auth/CreateWorkspace.tsx +++ b/front/src/pages/auth/CreateWorkspace.tsx @@ -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; 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 ( <> Create your workspace @@ -119,7 +112,6 @@ export function CreateWorkspace() { - {/* Picture is actually uploaded on the fly */} @@ -135,12 +127,14 @@ export function CreateWorkspace() { fieldState: { error }, }) => ( )} /> diff --git a/front/src/pages/auth/Index.tsx b/front/src/pages/auth/Index.tsx deleted file mode 100644 index 9c52e50ea..000000000 --- a/front/src/pages/auth/Index.tsx +++ /dev/null @@ -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 ( - <> - - - - Welcome to Twenty - - {authProviders.google && ( - } - title="Continue with Google" - onClick={onGoogleLoginClick} - fullWidth - /> - )} - {visible && ( - - - setAuthFlowUserEmail(value)} - fullWidth={true} - /> - - )} - - - - By using Twenty, you agree to the Terms of Service and Data Processing - Agreement. - - - ); -} diff --git a/front/src/pages/auth/PasswordLogin.tsx b/front/src/pages/auth/PasswordLogin.tsx deleted file mode 100644 index 355b4bdfa..000000000 --- a/front/src/pages/auth/PasswordLogin.tsx +++ /dev/null @@ -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; - -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({ - mode: 'onChange', - defaultValues: { - exist: false, - email: authFlowUserEmail, - password: isDemoMode ? 'Applecar2025' : '', - }, - resolver: yupResolver(validationSchema), - }); - - const onSubmit: SubmitHandler = 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 ( - <> - - Welcome to Twenty - - Enter your credentials to sign{' '} - {checkUserExistsData?.checkUserExists.exists ? 'in' : 'up'} - - { - setShowErrors(true); - return handleSubmit(onSubmit)(event); - }} - > - - - - ( - - )} - /> - - - - ( - - )} - /> - - - - - - - - ); -} diff --git a/front/src/pages/auth/SignInUp.tsx b/front/src/pages/auth/SignInUp.tsx new file mode 100644 index 000000000..907d9ba7e --- /dev/null +++ b/front/src/pages/auth/SignInUp.tsx @@ -0,0 +1,5 @@ +import { SignInUpForm } from '../../modules/auth/sign-in-up/components/SignInUpForm'; + +export function SignInUp() { + return ; +} diff --git a/front/src/pages/auth/Verify.tsx b/front/src/pages/auth/Verify.tsx index f903bdb64..4cb0a648e 100644 --- a/front/src/pages/auth/Verify.tsx +++ b/front/src/pages/auth/Verify.tsx @@ -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) { diff --git a/front/src/pages/auth/__stories__/CreateProfile.stories.tsx b/front/src/pages/auth/__stories__/CreateProfile.stories.tsx index cc923da6a..1ee175063 100644 --- a/front/src/pages/auth/__stories__/CreateProfile.stories.tsx +++ b/front/src/pages/auth/__stories__/CreateProfile.stories.tsx @@ -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'; diff --git a/front/src/pages/auth/__stories__/CreateWorkspace.stories.tsx b/front/src/pages/auth/__stories__/CreateWorkspace.stories.tsx index 8d87d0d7d..1a8959bb5 100644 --- a/front/src/pages/auth/__stories__/CreateWorkspace.stories.tsx +++ b/front/src/pages/auth/__stories__/CreateWorkspace.stories.tsx @@ -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 = { , - '/auth/create-workspace', + '/create-workspace', ), parameters: { msw: graphqlMocks, diff --git a/front/src/pages/auth/__stories__/Index.stories.tsx b/front/src/pages/auth/__stories__/Index.stories.tsx index b528f9d7c..a0dbb0b22 100644 --- a/front/src/pages/auth/__stories__/Index.stories.tsx +++ b/front/src/pages/auth/__stories__/Index.stories.tsx @@ -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 = { - title: 'Pages/Auth/Index', - component: Index, +const meta: Meta = { + title: 'Pages/Auth/SignInUp', + component: SignInUp, }; export default meta; -export type Story = StoryObj; +export type Story = StoryObj; export const Default: Story = { render: getRenderWrapperForPage( - + , - '/auth', + '/', ), parameters: { msw: graphqlMocks, diff --git a/front/src/pages/auth/__stories__/PasswordLogin.stories.tsx b/front/src/pages/auth/__stories__/PasswordLogin.stories.tsx deleted file mode 100644 index 2d28475f5..000000000 --- a/front/src/pages/auth/__stories__/PasswordLogin.stories.tsx +++ /dev/null @@ -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 = { - title: 'Pages/Auth/PasswordLogin', - component: PasswordLogin, -}; - -export default meta; - -export type Story = StoryObj; - -export const Default: Story = { - render: getRenderWrapperForPage( - - - - - , - '/auth/password-login', - ), - parameters: { - msw: graphqlMocks, - }, -}; diff --git a/front/src/pages/settings/SettingsWorkspaceMembers.tsx b/front/src/pages/settings/SettingsWorkspaceMembers.tsx index d05a4f2cc..820fe9f05 100644 --- a/front/src/pages/settings/SettingsWorkspaceMembers.tsx +++ b/front/src/pages/settings/SettingsWorkspaceMembers.tsx @@ -85,7 +85,7 @@ export function SettingsWorkspaceMembers() { description="Send an invitation to use Twenty" /> )} diff --git a/front/src/pages/settings/__stories__/SettingsProfile.stories.tsx b/front/src/pages/settings/__stories__/SettingsProfile.stories.tsx index 526d33a6d..fbd1ff2c2 100644 --- a/front/src/pages/settings/__stories__/SettingsProfile.stories.tsx +++ b/front/src/pages/settings/__stories__/SettingsProfile.stories.tsx @@ -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(, '/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}`, - }, }, }; diff --git a/front/src/pages/settings/__stories__/SettingsWorkspaceMembers.stories.tsx b/front/src/pages/settings/__stories__/SettingsWorkspaceMembers.stories.tsx index f358ffc05..0f1a74cae 100644 --- a/front/src/pages/settings/__stories__/SettingsWorkspaceMembers.stories.tsx +++ b/front/src/pages/settings/__stories__/SettingsWorkspaceMembers.stories.tsx @@ -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}`, - }, }, }; diff --git a/front/src/sync-hooks/HotkeyScopeBrowserRouterSync.tsx b/front/src/sync-hooks/HotkeyScopeBrowserRouterSync.tsx index 555ed9292..f20915154 100644 --- a/front/src/sync-hooks/HotkeyScopeBrowserRouterSync.tsx +++ b/front/src/sync-hooks/HotkeyScopeBrowserRouterSync.tsx @@ -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; diff --git a/front/src/testing/graphqlMocks.ts b/front/src/testing/graphqlMocks.ts index af96ae45f..46f591253 100644 --- a/front/src/testing/graphqlMocks.ts +++ b/front/src/testing/graphqlMocks.ts @@ -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); diff --git a/front/src/testing/mock-data/companies.ts b/front/src/testing/mock-data/companies.ts index 60664a38e..5dffe59bf 100644 --- a/front/src/testing/mock-data/companies.ts +++ b/front/src/testing/mock-data/companies.ts @@ -9,6 +9,7 @@ type MockedCompany = Pick< | 'createdAt' | 'address' | 'employees' + | 'linkedinUrl' | '_commentThreadCount' > & { accountOwner: Pick< @@ -31,6 +32,7 @@ export const mockedCompaniesData: Array = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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', diff --git a/server/.env.example b/server/.env.example index 5ce7ffa8f..4e9efd984 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/src/core/auth/controllers/google-auth.controller.ts b/server/src/core/auth/controllers/google-auth.controller.ts index 9e62537ba..afe6245ba 100644 --- a/server/src/core/auth/controllers/google-auth.controller.ts +++ b/server/src/core/auth/controllers/google-auth.controller.ts @@ -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); diff --git a/server/src/core/auth/guards/google-oauth.guard.ts b/server/src/core/auth/guards/google-oauth.guard.ts new file mode 100644 index 000000000..4cdde7175 --- /dev/null +++ b/server/src/core/auth/guards/google-oauth.guard.ts @@ -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; + } + } +} diff --git a/server/src/core/auth/strategies/google.auth.strategy.ts b/server/src/core/auth/strategies/google.auth.strategy.ts index f83d185dc..0c5f8f3ff 100644 --- a/server/src/core/auth/strategies/google.auth.strategy.ts +++ b/server/src/core/auth/strategies/google.auth.strategy.ts @@ -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 { - const { name, emails, photos } = profile; + ): Promise { + 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); } diff --git a/server/src/core/company/company.service.ts b/server/src/core/company/company.service.ts index 5045c4f00..177dc5adb 100644 --- a/server/src/core/company/company.service.ts +++ b/server/src/core/company/company.service.ts @@ -46,6 +46,6 @@ export class CompanyService { data: companies, }); - return this.findMany({ where: { workspaceId }}); + return this.findMany({ where: { workspaceId } }); } }