From 8790369f720611a60b3c535eb9691ba4772ae5a9 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 21 Jun 2023 04:17:31 +0200 Subject: [PATCH] Implement Authentication with email + password (#343) * Implement Login screen ui and add RequireNotAuth guard * Perform login through auth/password-login flow --- front/src/App.tsx | 30 ++--- .../modules/auth/components/InputLabel.tsx | 12 -- .../auth/components/RequireNotAuth.tsx | 52 ++++++++ .../src/modules/auth/components/SubTitle.tsx | 11 -- .../auth/components/{ => ui}/FooterNote.tsx | 0 .../{ => ui}/HorizontalSeparator.tsx | 0 .../modules/auth/components/ui/InputLabel.tsx | 16 +++ .../modules/auth/components/{ => ui}/Logo.tsx | 2 +- .../auth/components/{ => ui}/Modal.tsx | 0 .../modules/auth/components/ui/SubTitle.tsx | 15 +++ .../auth/components/{ => ui}/Title.tsx | 7 +- .../auth/states/authFlowUserEmailState.ts | 6 + .../ui/components/inputs/TextInput.tsx | 11 +- front/src/pages/auth/Index.tsx | 47 ++++++-- front/src/pages/auth/Login.tsx | 17 --- front/src/pages/auth/PasswordLogin.tsx | 114 ++++++++++++++++++ .../pages/auth/{Callback.tsx => Verify.tsx} | 2 +- .../__stories__/PasswordLogin.stories.tsx | 22 ++++ 18 files changed, 288 insertions(+), 76 deletions(-) delete mode 100644 front/src/modules/auth/components/InputLabel.tsx create mode 100644 front/src/modules/auth/components/RequireNotAuth.tsx delete mode 100644 front/src/modules/auth/components/SubTitle.tsx rename front/src/modules/auth/components/{ => ui}/FooterNote.tsx (100%) rename front/src/modules/auth/components/{ => ui}/HorizontalSeparator.tsx (100%) create mode 100644 front/src/modules/auth/components/ui/InputLabel.tsx rename front/src/modules/auth/components/{ => ui}/Logo.tsx (75%) rename front/src/modules/auth/components/{ => ui}/Modal.tsx (100%) create mode 100644 front/src/modules/auth/components/ui/SubTitle.tsx rename front/src/modules/auth/components/{ => ui}/Title.tsx (61%) create mode 100644 front/src/modules/auth/states/authFlowUserEmailState.ts delete mode 100644 front/src/pages/auth/Login.tsx create mode 100644 front/src/pages/auth/PasswordLogin.tsx rename front/src/pages/auth/{Callback.tsx => Verify.tsx} (96%) create mode 100644 front/src/pages/auth/__stories__/PasswordLogin.stories.tsx diff --git a/front/src/App.tsx b/front/src/App.tsx index 62fb0dff4..118d0d146 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -1,15 +1,15 @@ import { Navigate, Route, Routes } from 'react-router-dom'; +import { RequireAuth } from '@/auth/components/RequireAuth'; +import { RequireNotAuth } from '@/auth/components/RequireNotAuth'; import { DefaultLayout } from '@/ui/layout/DefaultLayout'; - -import { RequireAuth } from './modules/auth/components/RequireAuth'; -import { Callback } from './pages/auth/Callback'; -import { Index } from './pages/auth/Index'; -import { Login } from './pages/auth/Login'; -import { Companies } from './pages/companies/Companies'; -import { Opportunities } from './pages/opportunities/Opportunities'; -import { People } from './pages/people/People'; -import { SettingsProfile } from './pages/settings/SettingsProfile'; +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 { Opportunities } from '~/pages/opportunities/Opportunities'; +import { People } from '~/pages/people/People'; +import { SettingsProfile } from '~/pages/settings/SettingsProfile'; export function App() { return ( @@ -39,11 +39,13 @@ export function App() { - } /> - } /> - } /> - + + + } /> + } /> + } /> + + } /> diff --git a/front/src/modules/auth/components/InputLabel.tsx b/front/src/modules/auth/components/InputLabel.tsx deleted file mode 100644 index 5be3c97c7..000000000 --- a/front/src/modules/auth/components/InputLabel.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import styled from '@emotion/styled'; - -type OwnProps = { - label: string; - subLabel?: string; -}; - -const StyledContainer = styled.div``; - -export function SubTitle({ label, subLabel }: OwnProps): JSX.Element { - return {label}; -} diff --git a/front/src/modules/auth/components/RequireNotAuth.tsx b/front/src/modules/auth/components/RequireNotAuth.tsx new file mode 100644 index 000000000..9f51800c5 --- /dev/null +++ b/front/src/modules/auth/components/RequireNotAuth.tsx @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { keyframes } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { hasAccessToken } from '../services/AuthService'; + +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 RequireNotAuth({ + children, +}: { + children: JSX.Element; +}): JSX.Element { + const navigate = useNavigate(); + + useEffect(() => { + if (hasAccessToken()) { + navigate('/'); + } + }, [navigate]); + + if (hasAccessToken()) + return ( + + + Please hold on a moment, we're directing you to the app... + + + ); + return children; +} diff --git a/front/src/modules/auth/components/SubTitle.tsx b/front/src/modules/auth/components/SubTitle.tsx deleted file mode 100644 index 9348eb1a1..000000000 --- a/front/src/modules/auth/components/SubTitle.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import styled from '@emotion/styled'; - -type OwnProps = { - subTitle: string; -}; - -const StyledSubTitle = styled.div``; - -export function SubTitle({ subTitle }: OwnProps): JSX.Element { - return {subTitle}; -} diff --git a/front/src/modules/auth/components/FooterNote.tsx b/front/src/modules/auth/components/ui/FooterNote.tsx similarity index 100% rename from front/src/modules/auth/components/FooterNote.tsx rename to front/src/modules/auth/components/ui/FooterNote.tsx diff --git a/front/src/modules/auth/components/HorizontalSeparator.tsx b/front/src/modules/auth/components/ui/HorizontalSeparator.tsx similarity index 100% rename from front/src/modules/auth/components/HorizontalSeparator.tsx rename to front/src/modules/auth/components/ui/HorizontalSeparator.tsx diff --git a/front/src/modules/auth/components/ui/InputLabel.tsx b/front/src/modules/auth/components/ui/InputLabel.tsx new file mode 100644 index 000000000..b03f0a921 --- /dev/null +++ b/front/src/modules/auth/components/ui/InputLabel.tsx @@ -0,0 +1,16 @@ +import styled from '@emotion/styled'; + +type OwnProps = { + label: string; + subLabel?: string; +}; + +const StyledContainer = styled.div` + font-weight: ${({ theme }) => theme.fontWeightMedium}; + margin-bottom: ${({ theme }) => theme.spacing(4)}; + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +export function InputLabel({ label, subLabel }: OwnProps): JSX.Element { + return {label}; +} diff --git a/front/src/modules/auth/components/Logo.tsx b/front/src/modules/auth/components/ui/Logo.tsx similarity index 75% rename from front/src/modules/auth/components/Logo.tsx rename to front/src/modules/auth/components/ui/Logo.tsx index 960250069..7ad0adba6 100644 --- a/front/src/modules/auth/components/Logo.tsx +++ b/front/src/modules/auth/components/ui/Logo.tsx @@ -13,7 +13,7 @@ const StyledLogo = styled.div` export function Logo(): JSX.Element { return ( - logo + logo ); } diff --git a/front/src/modules/auth/components/Modal.tsx b/front/src/modules/auth/components/ui/Modal.tsx similarity index 100% rename from front/src/modules/auth/components/Modal.tsx rename to front/src/modules/auth/components/ui/Modal.tsx diff --git a/front/src/modules/auth/components/ui/SubTitle.tsx b/front/src/modules/auth/components/ui/SubTitle.tsx new file mode 100644 index 000000000..4d52a2baf --- /dev/null +++ b/front/src/modules/auth/components/ui/SubTitle.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +type OwnProps = { + children: React.ReactNode; +}; + +const StyledSubTitle = styled.div` + color: ${({ theme }) => theme.text60}; + margin-top: ${({ theme }) => theme.spacing(2)}; +`; + +export function SubTitle({ children }: OwnProps): JSX.Element { + return {children}; +} diff --git a/front/src/modules/auth/components/Title.tsx b/front/src/modules/auth/components/ui/Title.tsx similarity index 61% rename from front/src/modules/auth/components/Title.tsx rename to front/src/modules/auth/components/ui/Title.tsx index cfcaafbf7..097af7b18 100644 --- a/front/src/modules/auth/components/Title.tsx +++ b/front/src/modules/auth/components/ui/Title.tsx @@ -1,7 +1,8 @@ +import React from 'react'; import styled from '@emotion/styled'; type OwnProps = { - title: string; + children: React.ReactNode; }; const StyledTitle = styled.div` @@ -10,6 +11,6 @@ const StyledTitle = styled.div` margin-top: ${({ theme }) => theme.spacing(10)}; `; -export function Title({ title }: OwnProps): JSX.Element { - return {title}; +export function Title({ children }: OwnProps): JSX.Element { + return {children}; } diff --git a/front/src/modules/auth/states/authFlowUserEmailState.ts b/front/src/modules/auth/states/authFlowUserEmailState.ts new file mode 100644 index 000000000..ca3d1d42b --- /dev/null +++ b/front/src/modules/auth/states/authFlowUserEmailState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const authFlowUserEmailState = atom({ + key: 'authFlowUserEmailState', + default: '', +}); diff --git a/front/src/modules/ui/components/inputs/TextInput.tsx b/front/src/modules/ui/components/inputs/TextInput.tsx index 308a1990c..30a259824 100644 --- a/front/src/modules/ui/components/inputs/TextInput.tsx +++ b/front/src/modules/ui/components/inputs/TextInput.tsx @@ -1,10 +1,11 @@ import { ChangeEvent, useState } from 'react'; import styled from '@emotion/styled'; -type OwnProps = { - value: string; +type OwnProps = Omit< + React.InputHTMLAttributes, + 'onChange' +> & { onChange?: (text: string) => void; - placeholder?: string; fullWidth?: boolean; }; @@ -32,8 +33,8 @@ const StyledInput = styled.input<{ fullWidth: boolean }>` export function TextInput({ value, onChange, - placeholder, fullWidth, + ...props }: OwnProps): JSX.Element { const [internalValue, setInternalValue] = useState(value); @@ -41,13 +42,13 @@ export function TextInput({ ) => { setInternalValue(event.target.value); if (onChange) { onChange(event.target.value); } }} + {...props} /> ); } diff --git a/front/src/pages/auth/Index.tsx b/front/src/pages/auth/Index.tsx index ec2fd467a..1cad25aee 100644 --- a/front/src/pages/auth/Index.tsx +++ b/front/src/pages/auth/Index.tsx @@ -1,21 +1,23 @@ import { useCallback, useEffect } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; import { useNavigate } from 'react-router-dom'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; -import { FooterNote } from '@/auth/components/FooterNote'; -import { HorizontalSeparator } from '@/auth/components/HorizontalSeparator'; -import { Logo } from '@/auth/components/Logo'; -import { Modal } from '@/auth/components/Modal'; -import { Title } from '@/auth/components/Title'; +import { FooterNote } from '@/auth/components/ui/FooterNote'; +import { HorizontalSeparator } from '@/auth/components/ui/HorizontalSeparator'; +import { Logo } from '@/auth/components/ui/Logo'; +import { Modal } from '@/auth/components/ui/Modal'; +import { Title } from '@/auth/components/ui/Title'; import { useMockData } from '@/auth/hooks/useMockData'; import { hasAccessToken } from '@/auth/services/AuthService'; +import { authFlowUserEmailState } from '@/auth/states/authFlowUserEmailState'; import { PrimaryButton } from '@/ui/components/buttons/PrimaryButton'; import { SecondaryButton } from '@/ui/components/buttons/SecondaryButton'; import { TextInput } from '@/ui/components/inputs/TextInput'; import { IconBrandGoogle } from '@/ui/icons'; - -import { Companies } from '../companies/Companies'; +import { Companies } from '~/pages/companies/Companies'; const StyledContentContainer = styled.div` padding-bottom: ${({ theme }) => theme.spacing(8)}; @@ -28,6 +30,8 @@ export function Index() { const theme = useTheme(); useMockData(); + const [, setAuthFlowUserEmail] = useRecoilState(authFlowUserEmailState); + useEffect(() => { if (hasAccessToken()) { navigate('/'); @@ -35,15 +39,31 @@ export function Index() { }, [navigate]); const onGoogleLoginClick = useCallback(() => { - navigate('/auth/login'); + window.location.href = process.env.REACT_APP_AUTH_URL + '/google' || ''; + }, []); + + const onPasswordLoginClick = useCallback(() => { + navigate('/auth/password-login'); }, [navigate]); + useHotkeys( + 'enter', + () => { + onPasswordLoginClick(); + }, + { + enableOnContentEditable: true, + enableOnFormTags: true, + }, + [onPasswordLoginClick], + ); + return ( <> - + <Title>Welcome to Twenty @@ -51,11 +71,14 @@ export function Index() { console.log(value)} + value="" + placeholder="Email" + onChange={(value) => setAuthFlowUserEmail(value)} fullWidth={true} /> - Continue + + Continue + By using Twenty, you agree to the Terms of Service and Data Processing diff --git a/front/src/pages/auth/Login.tsx b/front/src/pages/auth/Login.tsx deleted file mode 100644 index 4a273f1ca..000000000 --- a/front/src/pages/auth/Login.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { hasAccessToken } from '@/auth/services/AuthService'; - -export function Login() { - const navigate = useNavigate(); - useEffect(() => { - if (!hasAccessToken()) { - window.location.href = process.env.REACT_APP_AUTH_URL + '/google' || ''; - } else { - navigate('/'); - } - }, [navigate]); - - return <>; -} diff --git a/front/src/pages/auth/PasswordLogin.tsx b/front/src/pages/auth/PasswordLogin.tsx new file mode 100644 index 000000000..48542df3a --- /dev/null +++ b/front/src/pages/auth/PasswordLogin.tsx @@ -0,0 +1,114 @@ +import { useCallback, useState } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { useNavigate } from 'react-router-dom'; +import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; + +import { InputLabel } from '@/auth/components/ui/InputLabel'; +import { Logo } from '@/auth/components/ui/Logo'; +import { Modal } from '@/auth/components/ui/Modal'; +import { SubTitle } from '@/auth/components/ui/SubTitle'; +import { Title } from '@/auth/components/ui/Title'; +import { getTokensFromLoginToken } from '@/auth/services/AuthService'; +import { authFlowUserEmailState } from '@/auth/states/authFlowUserEmailState'; +import { PrimaryButton } from '@/ui/components/buttons/PrimaryButton'; +import { TextInput } from '@/ui/components/inputs/TextInput'; +import { Companies } from '~/pages/companies/Companies'; + +const StyledContentContainer = styled.div` + padding-bottom: ${({ theme }) => theme.spacing(4)}; + padding-top: ${({ theme }) => theme.spacing(6)}; + width: 320px; +`; + +const StyledInputContainer = styled.div` + margin-top: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledButtonContainer = styled.div` + margin-top: ${({ theme }) => theme.spacing(7)}; +`; + +export function PasswordLogin() { + const navigate = useNavigate(); + const [authFlowUserEmail, setAuthFlowUserEmail] = useRecoilState( + authFlowUserEmailState, + ); + + const [internalPassword, setInternalPassword] = useState(''); + + const userLogin = useCallback(async () => { + const response = await fetch( + process.env.REACT_APP_AUTH_URL + '/password' || '', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: authFlowUserEmail, + password: internalPassword, + }), + }, + ); + + if (response.ok) { + const { loginToken } = await response.json(); + if (!loginToken) { + // TODO Display error message + return; + } + await getTokensFromLoginToken(loginToken.token); + navigate('/'); + } + }, [authFlowUserEmail, internalPassword, navigate]); + + useHotkeys( + 'enter', + () => { + userLogin(); + }, + { + enableOnContentEditable: true, + enableOnFormTags: true, + }, + [userLogin], + ); + + return ( + <> + + + + Welcome to Twenty + Enter your credentials to sign in + + + + setAuthFlowUserEmail(value)} + fullWidth + /> + + + + setInternalPassword(value)} + fullWidth + type="password" + /> + + + Continue + + + + + + + ); +} diff --git a/front/src/pages/auth/Callback.tsx b/front/src/pages/auth/Verify.tsx similarity index 96% rename from front/src/pages/auth/Callback.tsx rename to front/src/pages/auth/Verify.tsx index f2de3af74..c67017135 100644 --- a/front/src/pages/auth/Callback.tsx +++ b/front/src/pages/auth/Verify.tsx @@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { getTokensFromLoginToken } from '@/auth/services/AuthService'; -export function Callback() { +export function Verify() { const [searchParams] = useSearchParams(); const [isLoading, setIsLoading] = useState(false); diff --git a/front/src/pages/auth/__stories__/PasswordLogin.stories.tsx b/front/src/pages/auth/__stories__/PasswordLogin.stories.tsx new file mode 100644 index 000000000..05ace005a --- /dev/null +++ b/front/src/pages/auth/__stories__/PasswordLogin.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +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, + }, +};