From e7d48d5373b5ccd809994eacc23452186bdff65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20M?= Date: Thu, 13 Jul 2023 01:53:48 +0200 Subject: [PATCH] Add validation on onboarding flow inputs (#556) * feat: wip react-hook-form * feat: use react-hook-form for password login * feat: clean regex * feat: add react-hook-form on create workspace * feat: add react-hook-form on create profile page * fix: clean rebased code * fix: rebase issue * fix: add new stories to go over 65% --------- Co-authored-by: Charles Bochet --- front/package.json | 5 +- front/src/generated/graphql.tsx | 61 +----- .../modules/auth/components/ui/Section.tsx | 31 --- front/src/modules/auth/hooks/useAuth.ts | 29 +-- front/src/modules/auth/services/update.ts | 15 +- front/src/modules/auth/utils/passwordRegex.ts | 1 + .../__stories__/SoonPill.stories.tsx | 17 ++ .../__stories__/ActionBar.stories.tsx | 19 ++ .../ui/components/inputs/TextInput.tsx | 125 ++++++++---- front/src/modules/ui/icons/index.ts | 1 + front/src/pages/auth/CreateProfile.tsx | 165 ++++++++++++---- front/src/pages/auth/CreateWorkspace.tsx | 115 +++++++---- front/src/pages/auth/PasswordLogin.tsx | 180 ++++++++++++------ front/yarn.lock | 35 ++++ 14 files changed, 498 insertions(+), 301 deletions(-) delete mode 100644 front/src/modules/auth/components/ui/Section.tsx create mode 100644 front/src/modules/auth/utils/passwordRegex.ts create mode 100644 front/src/modules/ui/components/accessories/__stories__/SoonPill.stories.tsx create mode 100644 front/src/modules/ui/components/action-bar/__stories__/ActionBar.stories.tsx diff --git a/front/package.json b/front/package.json index 9aedd8b99..92e33d811 100644 --- a/front/package.json +++ b/front/package.json @@ -11,6 +11,7 @@ "@emotion/styled": "^11.10.5", "@floating-ui/react": "^0.24.3", "@hello-pangea/dnd": "^16.2.0", + "@hookform/resolvers": "^3.1.1", "@tabler/icons-react": "^2.20.0", "@tanstack/react-table": "^8.8.5", "@types/node": "^16.18.4", @@ -33,6 +34,7 @@ "react": "^18.2.0", "react-datepicker": "^4.11.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.45.1", "react-hotkeys-hook": "^4.4.0", "react-loading-skeleton": "^3.3.1", "react-modal": "^3.16.1", @@ -44,7 +46,8 @@ "ts-key-enum": "^2.0.12", "url": "^0.11.1", "uuid": "^9.0.0", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "yup": "^1.2.0" }, "scripts": { "start": "PORT=3001 craco start", diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 2a3f1d5fe..b9080f10b 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -3110,20 +3110,12 @@ export type ChallengeMutation = { __typename?: 'Mutation', challenge: { __typena export type SignUpMutationVariables = Exact<{ email: Scalars['String']; password: Scalars['String']; + workspaceInviteHash?: InputMaybe; }>; export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', expiresAt: string, token: string } } }; -export type SignUpToWorkspaceMutationVariables = Exact<{ - email: Scalars['String']; - password: Scalars['String']; - workspaceInviteHash: Scalars['String']; -}>; - - -export type SignUpToWorkspaceMutation = { __typename?: 'Mutation', signUp: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', expiresAt: string, token: string } } }; - export type VerifyMutationVariables = Exact<{ loginToken: Scalars['String']; }>; @@ -3565,8 +3557,12 @@ export type ChallengeMutationHookResult = ReturnType; export type ChallengeMutationOptions = Apollo.BaseMutationOptions; export const SignUpDocument = gql` - mutation SignUp($email: String!, $password: String!) { - signUp(email: $email, password: $password) { + mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String) { + signUp( + email: $email + password: $password + workspaceInviteHash: $workspaceInviteHash + ) { loginToken { expiresAt token @@ -3591,6 +3587,7 @@ export type SignUpMutationFn = Apollo.MutationFunction; export type SignUpMutationResult = Apollo.MutationResult; export type SignUpMutationOptions = Apollo.BaseMutationOptions; -export const SignUpToWorkspaceDocument = gql` - mutation SignUpToWorkspace($email: String!, $password: String!, $workspaceInviteHash: String!) { - signUp( - email: $email - password: $password - workspaceInviteHash: $workspaceInviteHash - ) { - loginToken { - expiresAt - token - } - } -} - `; -export type SignUpToWorkspaceMutationFn = Apollo.MutationFunction; - -/** - * __useSignUpToWorkspaceMutation__ - * - * To run a mutation, you first call `useSignUpToWorkspaceMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useSignUpToWorkspaceMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [signUpToWorkspaceMutation, { data, loading, error }] = useSignUpToWorkspaceMutation({ - * variables: { - * email: // value for 'email' - * password: // value for 'password' - * workspaceInviteHash: // value for 'workspaceInviteHash' - * }, - * }); - */ -export function useSignUpToWorkspaceMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(SignUpToWorkspaceDocument, options); - } -export type SignUpToWorkspaceMutationHookResult = ReturnType; -export type SignUpToWorkspaceMutationResult = Apollo.MutationResult; -export type SignUpToWorkspaceMutationOptions = Apollo.BaseMutationOptions; export const VerifyDocument = gql` mutation Verify($loginToken: String!) { verify(loginToken: $loginToken) { diff --git a/front/src/modules/auth/components/ui/Section.tsx b/front/src/modules/auth/components/ui/Section.tsx deleted file mode 100644 index 04b48de61..000000000 --- a/front/src/modules/auth/components/ui/Section.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import styled from '@emotion/styled'; - -type OwnProps = { - title: string; - description?: string; -}; - -const StyledContainer = styled.div` - display: flex; - flex-direction: column; -`; - -const StyledTitle = styled.span` - color: ${({ theme }) => theme.font.color.primary}; - font-weight: ${({ theme }) => theme.font.weight.medium}; -`; - -const StyledDescription = styled.span` - color: ${({ theme }) => theme.font.color.tertiary}; - font-weight: ${({ theme }) => theme.font.weight.regular}; - margin-top: ${({ theme }) => theme.spacing(1)}; -`; - -export function Section({ title, description }: OwnProps): JSX.Element { - return ( - - {title} - {description && {description}} - - ); -} diff --git a/front/src/modules/auth/hooks/useAuth.ts b/front/src/modules/auth/hooks/useAuth.ts index 90bfdde7a..bfe17b25a 100644 --- a/front/src/modules/auth/hooks/useAuth.ts +++ b/front/src/modules/auth/hooks/useAuth.ts @@ -4,7 +4,6 @@ import { useRecoilState } from 'recoil'; import { useChallengeMutation, useSignUpMutation, - useSignUpToWorkspaceMutation, useVerifyMutation, } from '~/generated/graphql'; @@ -19,7 +18,6 @@ export function useAuth() { const [challenge] = useChallengeMutation(); const [signUp] = useSignUpMutation(); - const [SignUpToWorkspace] = useSignUpToWorkspaceMutation(); const [verify] = useVerifyMutation(); const handleChallenge = useCallback( @@ -82,30 +80,8 @@ export function useAuth() { }, [setTokenPair]); const handleSignUp = useCallback( - async (email: string, password: string) => { + async (email: string, password: string, workspaceInviteHash?: string) => { const signUpResult = await signUp({ - variables: { - email, - password, - }, - }); - - if (signUpResult.errors) { - throw signUpResult.errors; - } - - if (!signUpResult.data?.signUp) { - throw new Error('No login token'); - } - - await handleVerify(signUpResult.data?.signUp.loginToken.token); - }, - [signUp, handleVerify], - ); - - const handleSignUpToWorkspace = useCallback( - async (email: string, password: string, workspaceInviteHash: string) => { - const signUpResult = await SignUpToWorkspace({ variables: { email, password, @@ -123,7 +99,7 @@ export function useAuth() { await handleVerify(signUpResult.data?.signUp.loginToken.token); }, - [SignUpToWorkspace, handleVerify], + [signUp, handleVerify], ); return { @@ -131,7 +107,6 @@ export function useAuth() { verify: handleVerify, login: handleLogin, signUp: handleSignUp, - signUpToWorkspace: handleSignUpToWorkspace, logout: handleLogout, }; } diff --git a/front/src/modules/auth/services/update.ts b/front/src/modules/auth/services/update.ts index 8b2e30c45..f79fc2913 100644 --- a/front/src/modules/auth/services/update.ts +++ b/front/src/modules/auth/services/update.ts @@ -12,21 +12,10 @@ export const CHALLENGE = gql` `; export const SIGN_UP = gql` - mutation SignUp($email: String!, $password: String!) { - signUp(email: $email, password: $password) { - loginToken { - expiresAt - token - } - } - } -`; - -export const SIGN_UP_TO_WORKSPACE = gql` - mutation SignUpToWorkspace( + mutation SignUp( $email: String! $password: String! - $workspaceInviteHash: String! + $workspaceInviteHash: String ) { signUp( email: $email diff --git a/front/src/modules/auth/utils/passwordRegex.ts b/front/src/modules/auth/utils/passwordRegex.ts new file mode 100644 index 000000000..5c88d5f41 --- /dev/null +++ b/front/src/modules/auth/utils/passwordRegex.ts @@ -0,0 +1 @@ +export const PASSWORD_REGEX = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/; diff --git a/front/src/modules/ui/components/accessories/__stories__/SoonPill.stories.tsx b/front/src/modules/ui/components/accessories/__stories__/SoonPill.stories.tsx new file mode 100644 index 000000000..52046071a --- /dev/null +++ b/front/src/modules/ui/components/accessories/__stories__/SoonPill.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; + +import { SoonPill } from '../SoonPill'; + +const meta: Meta = { + title: 'UI/Accessories/SoonPill', + component: SoonPill, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: getRenderWrapperForComponent(), +}; diff --git a/front/src/modules/ui/components/action-bar/__stories__/ActionBar.stories.tsx b/front/src/modules/ui/components/action-bar/__stories__/ActionBar.stories.tsx new file mode 100644 index 000000000..2c901fb32 --- /dev/null +++ b/front/src/modules/ui/components/action-bar/__stories__/ActionBar.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; + +import { ActionBar } from '../ActionBar'; + +const meta: Meta = { + title: 'UI/ActionBar/ActionBar', + component: ActionBar, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: getRenderWrapperForComponent( + } selectedIds={[]} />, + ), +}; diff --git a/front/src/modules/ui/components/inputs/TextInput.tsx b/front/src/modules/ui/components/inputs/TextInput.tsx index 01a87764f..d451cd3b1 100644 --- a/front/src/modules/ui/components/inputs/TextInput.tsx +++ b/front/src/modules/ui/components/inputs/TextInput.tsx @@ -1,6 +1,13 @@ -import { ChangeEvent, useRef, useState } from 'react'; +import { + ChangeEvent, + FocusEventHandler, + InputHTMLAttributes, + useRef, + useState, +} from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { IconAlertCircle } from '@tabler/icons-react'; import { Key } from 'ts-key-enum'; import { usePreviousHotkeysScope } from '@/hotkeys/hooks/internal/usePreviousHotkeysScope'; @@ -8,18 +15,17 @@ import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys'; import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope'; import { IconEye, IconEyeOff } from '@/ui/icons/index'; -type OwnProps = Omit< - React.InputHTMLAttributes, - 'onChange' -> & { +type OwnProps = Omit, 'onChange'> & { label?: string; onChange?: (text: string) => void; fullWidth?: boolean; + error?: string; }; -const StyledContainer = styled.div` +const StyledContainer = styled.div>` display: flex; flex-direction: column; + width: ${({ fullWidth, theme }) => (fullWidth ? `100%` : 'auto')}; `; const StyledLabel = styled.span` @@ -30,19 +36,28 @@ const StyledLabel = styled.span` text-transform: uppercase; `; -const StyledInput = styled.input<{ fullWidth: boolean }>` +const StyledInputContainer = styled.div` + display: flex; + flex-direction: row; + + width: 100%; +`; + +const StyledInput = styled.input>` background-color: ${({ theme }) => theme.background.tertiary}; border: none; - border-radius: ${({ theme }) => theme.border.radius.sm}; - + border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm}; + border-top-left-radius: ${({ theme }) => theme.border.radius.sm}; color: ${({ theme }) => theme.font.color.primary}; + display: flex; + flex-grow: 1; font-family: ${({ theme }) => theme.font.family}; - font-weight: ${({ theme }) => theme.font.weight.regular}; + font-weight: ${({ theme }) => theme.font.weight.regular}; outline: none; padding: ${({ theme }) => theme.spacing(2)}; - width: ${({ fullWidth, theme }) => - fullWidth ? `calc(100% - ${theme.spacing(4)})` : 'auto'}; + + width: 100%; &::placeholder, &::-webkit-input-placeholder { @@ -52,28 +67,46 @@ const StyledInput = styled.input<{ fullWidth: boolean }>` } `; -const StyledIconContainer = styled.div` - align-items: center; - display: flex; - position: relative; +const StyledErrorHelper = styled.div` + color: ${({ theme }) => theme.color.red}; + font-size: ${({ theme }) => theme.font.size.xs}; + padding: ${({ theme }) => theme.spacing(1)}; `; -const StyledIcon = styled.div` +const StyledTrailingIconContainer = styled.div` + align-items: center; + background-color: ${({ theme }) => theme.background.tertiary}; + border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm}; + border-top-right-radius: ${({ theme }) => theme.border.radius.sm}; + display: flex; + justify-content: center; + padding-right: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledTrailingIcon = styled.div` align-items: center; color: ${({ theme }) => theme.font.color.light}; cursor: pointer; - position: absolute; - right: ${({ theme }) => theme.spacing(2)}; + display: flex; + justify-content: center; `; +const INPUT_TYPE_PASSWORD = 'password'; + export function TextInput({ label, value, onChange, + onFocus, + onBlur, fullWidth, + error, + required, type, ...props }: OwnProps): JSX.Element { + const theme = useTheme(); + const inputRef = useRef(null); const { @@ -81,13 +114,15 @@ export function TextInput({ setHotkeysScopeAndMemorizePreviousScope, } = usePreviousHotkeysScope(); - function handleFocus() { + const handleFocus: FocusEventHandler = (e) => { + onFocus?.(e); setHotkeysScopeAndMemorizePreviousScope(InternalHotkeysScope.TextInput); - } + }; - function handleBlur() { + const handleBlur: FocusEventHandler = (e) => { + onBlur?.(e); goBackToPreviousHotkeysScope(); - } + }; useScopedHotkeys( [Key.Enter, Key.Escape], @@ -103,19 +138,17 @@ export function TextInput({ setPasswordVisible(!passwordVisible); }; - const theme = useTheme(); - return ( - - {label && {label}} - + + {label && {label + (required ? '*' : '')}} + ) => { if (onChange) { @@ -124,19 +157,27 @@ export function TextInput({ }} {...props} /> - {type === 'password' && ( // only show the icon for password inputs - - {passwordVisible ? ( - - ) : ( - - )} - - )} - + + {error && ( + + + + )} + {!error && type === INPUT_TYPE_PASSWORD && ( + + {passwordVisible ? ( + + ) : ( + + )} + + )} + + + {error && {error}} ); } diff --git a/front/src/modules/ui/icons/index.ts b/front/src/modules/ui/icons/index.ts index 643dba895..c1feebb6c 100644 --- a/front/src/modules/ui/icons/index.ts +++ b/front/src/modules/ui/icons/index.ts @@ -35,5 +35,6 @@ export { IconNotes } from '@tabler/icons-react'; export { IconCirclePlus } from '@tabler/icons-react'; export { IconCheckbox } from '@tabler/icons-react'; export { IconTimelineEvent } from '@tabler/icons-react'; +export { IconAlertCircle } from '@tabler/icons-react'; export { IconEye } from '@tabler/icons-react'; export { IconEyeOff } from '@tabler/icons-react'; diff --git a/front/src/pages/auth/CreateProfile.tsx b/front/src/pages/auth/CreateProfile.tsx index 11bd770b3..8f0984118 100644 --- a/front/src/pages/auth/CreateProfile.tsx +++ b/front/src/pages/auth/CreateProfile.tsx @@ -1,9 +1,12 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect } from 'react'; +import { Controller, SubmitHandler, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { getOperationName } from '@apollo/client/utilities'; import styled from '@emotion/styled'; +import { yupResolver } from '@hookform/resolvers/yup'; 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'; @@ -12,9 +15,9 @@ import { currentUserState } from '@/auth/states/currentUserState'; import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus'; import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys'; import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope'; -import { NameFields } from '@/settings/profile/components/NameFields'; import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader'; import { MainButton } from '@/ui/components/buttons/MainButton'; +import { TextInput } from '@/ui/components/inputs/TextInput'; import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle'; import { GET_CURRENT_USER } from '@/users/queries'; import { useUpdateUserMutation } from '~/generated/graphql'; @@ -36,6 +39,27 @@ const StyledButtonContainer = styled.div` width: 200px; `; +const StyledComboInputContainer = styled.div` + display: flex; + flex-direction: row; + > * + * { + margin-left: ${({ theme }) => theme.spacing(4)}; + } +`; + +const StyledErrorContainer = styled.div` + color: ${({ theme }) => theme.color.red}; +`; + +const validationSchema = Yup.object() + .shape({ + firstName: Yup.string().required('First name can not be empty'), + lastName: Yup.string().required('Last name can not be empty'), + }) + .required(); + +type Form = Yup.InferType; + export function CreateProfile() { const navigate = useNavigate(); const onboardingStatus = useOnboardingStatus(); @@ -44,50 +68,69 @@ export function CreateProfile() { const [updateUser] = useUpdateUserMutation(); - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); + // Form + const { + control, + handleSubmit, + formState: { isValid, isSubmitting }, + setError, + getValues, + } = useForm
({ + mode: 'onChange', + defaultValues: { + firstName: currentUser?.firstName ?? '', + lastName: currentUser?.lastName ?? '', + }, + resolver: yupResolver(validationSchema), + }); - const handleCreate = useCallback(async () => { - try { - if (!currentUser?.id) { - throw new Error('User is not logged in'); - } + const onSubmit: SubmitHandler = useCallback( + async (data) => { + try { + if (!currentUser?.id) { + throw new Error('User is not logged in'); + } + if (!data.firstName || !data.lastName) { + throw new Error('First name or last name is missing'); + } - const { data, errors } = await updateUser({ - variables: { - where: { - id: currentUser?.id, - }, - data: { - firstName: { - set: firstName, + const result = await updateUser({ + variables: { + where: { + id: currentUser?.id, }, - lastName: { - set: lastName, + data: { + firstName: { + set: data.firstName, + }, + lastName: { + set: data.lastName, + }, }, }, - }, - refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''], - awaitRefetchQueries: true, - }); + refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''], + awaitRefetchQueries: true, + }); - if (errors || !data?.updateUser) { - throw errors; + if (result.errors || !result.data?.updateUser) { + throw result.errors; + } + + navigate('/'); + } catch (error: any) { + setError('root', { message: error?.message }); } - - navigate('/'); - } catch (error) { - console.error(error); - } - }, [currentUser?.id, firstName, lastName, navigate, updateUser]); + }, + [currentUser?.id, navigate, setError, updateUser], + ); useScopedHotkeys( Key.Enter, () => { - handleCreate(); + onSubmit(getValues()); }, InternalHotkeysScope.CreateProfile, - [handleCreate], + [onSubmit], ); useEffect(() => { @@ -110,21 +153,63 @@ export function CreateProfile() { title="Name" description="Your name as it will be displayed on the app" /> - + {/* TODO: When react-hook-form is added to edit page we should create a dedicated component with context */} + + ( + + )} + /> + ( + + )} + /> + + {/* Will be replaced by error snack bar */} + ( + {errors?.root?.message} + )} + /> ); } diff --git a/front/src/pages/auth/CreateWorkspace.tsx b/front/src/pages/auth/CreateWorkspace.tsx index 146f705b2..a64733439 100644 --- a/front/src/pages/auth/CreateWorkspace.tsx +++ b/front/src/pages/auth/CreateWorkspace.tsx @@ -1,7 +1,10 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect } from 'react'; +import { Controller, SubmitHandler, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { getOperationName } from '@apollo/client/utilities'; 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'; @@ -33,49 +36,73 @@ const StyledButtonContainer = styled.div` width: 200px; `; +const StyledErrorContainer = styled.div` + color: ${({ theme }) => theme.color.red}; +`; + +const validationSchema = Yup.object() + .shape({ + name: Yup.string().required('Name can not be empty'), + }) + .required(); + +type Form = Yup.InferType; + export function CreateWorkspace() { const navigate = useNavigate(); const onboardingStatus = useOnboardingStatus(); - const [workspaceName, setWorkspaceName] = useState(''); - const [updateWorkspace] = useUpdateWorkspaceMutation(); - const handleCreate = useCallback(async () => { - try { - if (!workspaceName) { - throw new Error('Workspace name is required'); - } + // Form + const { + control, + handleSubmit, + formState: { isValid, isSubmitting }, + setError, + getValues, + } = useForm({ + mode: 'onChange', + defaultValues: { + name: '', + }, + resolver: yupResolver(validationSchema), + }); - const { data, errors } = await updateWorkspace({ - variables: { - data: { - displayName: { - set: workspaceName, + const onSubmit: SubmitHandler = useCallback( + async (data) => { + try { + const result = await updateWorkspace({ + variables: { + data: { + displayName: { + set: data.name, + }, }, }, - }, - refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''], - awaitRefetchQueries: true, - }); + refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''], + awaitRefetchQueries: true, + }); - if (errors || !data?.updateWorkspace) { - throw errors; + if (result.errors || !result.data?.updateWorkspace) { + throw result.errors ?? new Error('Unknown error'); + } + + navigate('/auth/create/profile'); + } catch (error: any) { + setError('root', { message: error?.message }); } - - navigate('/auth/create/profile'); - } catch (error) { - console.error(error); - } - }, [navigate, updateWorkspace, workspaceName]); + }, + [navigate, setError, updateWorkspace], + ); useScopedHotkeys( 'enter', () => { - handleCreate(); + onSubmit(getValues()); }, InternalHotkeysScope.CreateWokspace, - [handleCreate], + [onSubmit], ); useEffect(() => { @@ -94,6 +121,7 @@ export function CreateWorkspace() { + {/* Picture is actually uploaded on the fly */} @@ -101,22 +129,41 @@ export function CreateWorkspace() { title="Workspace name" description="The name of your organization" /> - ( + + )} /> + {/* Will be replaced by error snack bar */} + ( + {errors?.root?.message} + )} + /> ); } diff --git a/front/src/pages/auth/PasswordLogin.tsx b/front/src/pages/auth/PasswordLogin.tsx index 896737e47..568a996cb 100644 --- a/front/src/pages/auth/PasswordLogin.tsx +++ b/front/src/pages/auth/PasswordLogin.tsx @@ -1,14 +1,17 @@ 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 { motion } from 'framer-motion'; +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 { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys'; import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope'; @@ -24,7 +27,7 @@ const StyledContentContainer = styled.div` } `; -const StyledAnimatedContent = styled(motion.div)` +const StyledForm = styled.form` align-items: center; display: flex; flex-direction: column; @@ -48,65 +51,88 @@ const StyledErrorContainer = styled.div` color: ${({ theme }) => theme.color.red}; `; +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, one uppercase and one number', + ) + .required(), + }) + .required(); + +type Form = Yup.InferType; + export function PasswordLogin() { const navigate = useNavigate(); const [isDemoMode] = useRecoilState(isDemoModeState); - const [authFlowUserEmail, setAuthFlowUserEmail] = useRecoilState( - authFlowUserEmailState, - ); - const [internalPassword, setInternalPassword] = useState( - isDemoMode ? 'Applecar2025' : '', - ); - const [formError, setFormError] = useState(''); + const [authFlowUserEmail] = useRecoilState(authFlowUserEmailState); + const [showErrors, setShowErrors] = useState(false); - const { login, signUp, signUpToWorkspace } = useAuth(); - const { loading, data } = useCheckUserExistsQuery({ + const workspaceInviteHash = useParams().workspaceInviteHash; + + const { data: checkUserExistsData } = useCheckUserExistsQuery({ variables: { email: authFlowUserEmail, }, }); - const workspaceInviteHash = useParams().workspaceInviteHash; + const { login, signUp } = useAuth(); - const handleSubmit = useCallback(async () => { - try { - if (data?.checkUserExists.exists) { - await login(authFlowUserEmail, internalPassword); - } else { - if (workspaceInviteHash) { - await signUpToWorkspace( - authFlowUserEmail, - internalPassword, - workspaceInviteHash, - ); - } else { - await signUp(authFlowUserEmail, internalPassword); + // Form + const { + control, + handleSubmit, + formState: { isSubmitting }, + setError, + 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) { + setError('root', { message: err?.message }); } - navigate('/auth/create/workspace'); - } catch (err: any) { - setFormError(err.message); - } - }, [ - login, - signUp, - signUpToWorkspace, - authFlowUserEmail, - internalPassword, - navigate, - data?.checkUserExists.exists, - - workspaceInviteHash, - ]); - + }, + [ + login, + navigate, + setError, + signUp, + workspaceInviteHash, + checkUserExistsData, + ], + ); useScopedHotkeys( 'enter', () => { - handleSubmit(); + onSubmit(getValues()); }, InternalHotkeysScope.PasswordLogin, - [handleSubmit], + [onSubmit], ); return ( @@ -115,40 +141,74 @@ export function PasswordLogin() { Welcome to Twenty Enter your credentials to sign{' '} - {data?.checkUserExists.exists ? 'in' : 'up'} + {checkUserExistsData?.checkUserExists.exists ? 'in' : 'up'} - + { + setShowErrors(true); + return handleSubmit(onSubmit)(event); + }} + > - setAuthFlowUserEmail(value)} - fullWidth + ( + + )} /> - setInternalPassword(value)} - fullWidth - type="password" + ( + + )} /> - {formError && {formError}} - + {/* Will be replaced by error snack bar */} + ( + {errors?.root?.message} + )} + /> + ); } diff --git a/front/yarn.lock b/front/yarn.lock index ae321e62a..d447d498d 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -2458,6 +2458,11 @@ redux "^4.2.1" use-memo-one "^1.1.3" +"@hookform/resolvers@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.1.1.tgz#b374d33e356428fff9c6ef3c933441fe15e40784" + integrity sha512-tS16bAUkqjITNSvbJuO1x7MXbn7Oe8ZziDTJdA9mMvsoYthnOOiznOTGBYwbdlYBgU+tgpI/BtTU3paRbCuSlg== + "@humanwhocodes/config-array@^0.11.10": version "0.11.10" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2" @@ -15381,6 +15386,11 @@ prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +property-expr@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" + integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== + property-information@^6.0.0: version "6.2.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.2.0.tgz#b74f522c31c097b5149e3c3cb8d7f3defd986a1d" @@ -15802,6 +15812,11 @@ react-fast-compare@^3.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== +react-hook-form@^7.45.1: + version "7.45.1" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.45.1.tgz#e352c7f4dbc7540f0756abbb4dcfd1122fecc9bb" + integrity sha512-6dWoFJwycbuFfw/iKMcl+RdAOAOHDiF11KWYhNDRN/OkUt+Di5qsZHwA0OwsVnu9y135gkHpTw9DJA+WzCeR9w== + react-hotkeys-hook@^4.4.0: version "4.4.1" resolved "https://registry.yarnpkg.com/react-hotkeys-hook/-/react-hotkeys-hook-4.4.1.tgz#1f7a7a1c9c21d4fa3280bf340fcca8fd77d81994" @@ -17743,6 +17758,11 @@ thunky@^1.0.2: resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== +tiny-case@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03" + integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== + tiny-invariant@^1.0.6: version "1.3.1" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" @@ -17801,6 +17821,11 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== + tough-cookie@^4.0.0: version "4.1.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" @@ -19272,6 +19297,16 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +yup@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/yup/-/yup-1.2.0.tgz#9e51af0c63bdfc9be0fdc6c10aa0710899d8aff6" + integrity sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ== + dependencies: + property-expr "^2.0.5" + tiny-case "^1.0.3" + toposort "^2.0.2" + type-fest "^2.19.0" + zen-observable-ts@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz#6c6d9ea3d3a842812c6e9519209365a122ba8b58"