diff --git a/canopeum_frontend/package-lock.json b/canopeum_frontend/package-lock.json index c1b88b6df..5650df361 100644 --- a/canopeum_frontend/package-lock.json +++ b/canopeum_frontend/package-lock.json @@ -23,6 +23,7 @@ "nswag": "^14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.54.1", "react-i18next": "^14.1.0", "react-map-gl": "^7.1.7", "react-router-dom": "^6.22.3", @@ -12464,6 +12465,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.54.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.1.tgz", + "integrity": "sha512-PUNzFwQeQ5oHiiTUO7GO/EJXGEtuun2Y1A59rLnZBBj+vNEOWt/3ERTiG1/zt7dVeJEM+4vDX/7XQ/qanuvPMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-i18next": { "version": "14.1.3", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz", diff --git a/canopeum_frontend/package.json b/canopeum_frontend/package.json index 731e693f2..4fbfac001 100644 --- a/canopeum_frontend/package.json +++ b/canopeum_frontend/package.json @@ -30,6 +30,7 @@ "nswag": "^14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.54.1", "react-i18next": "^14.1.0", "react-map-gl": "^7.1.7", "react-router-dom": "^6.22.3", diff --git a/canopeum_frontend/src/constants/style.ts b/canopeum_frontend/src/constants/style.ts new file mode 100644 index 000000000..2012593fd --- /dev/null +++ b/canopeum_frontend/src/constants/style.ts @@ -0,0 +1,5 @@ +const invalidFieldClass = 'is-invalid' + +export const formClasses = { + invalidFieldClass, +} diff --git a/canopeum_frontend/src/pages/Register.tsx b/canopeum_frontend/src/pages/Register.tsx index 9b6ec87bc..43170fae6 100644 --- a/canopeum_frontend/src/pages/Register.tsx +++ b/canopeum_frontend/src/pages/Register.tsx @@ -1,15 +1,25 @@ +/* eslint-disable react/jsx-props-no-spreading -- Good practice for React Hook Form */ import { useCallback, useContext, useEffect, useState } from 'react' +import { type SubmitHandler, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { Link, useSearchParams } from 'react-router-dom' import AuthPageLayout from '@components/auth/AuthPageLayout' import { AuthenticationContext } from '@components/context/AuthenticationContext' import { appRoutes } from '@constants/routes.constant' +import { formClasses } from '@constants/style' import useApiClient from '@hooks/ApiClientHook' import type { UserInvitation } from '@services/api' import { RegisterUser } from '@services/api' import { storeToken } from '@utils/auth.utils' -import { type InputValidationError, isValidEmail, isValidPassword, mustMatch } from '@utils/validators' +import { emailRegex, passwordRegex } from '@utils/validators' + +type RegisterFormInputs = { + username: string, + email: string, + password: string, + confirmPassword: string, +} const Register = () => { const [searchParams, _setSearchParams] = useSearchParams() @@ -17,144 +27,26 @@ const Register = () => { const { t: translate } = useTranslation() const { getApiClient } = useApiClient() - const [username, setUsername] = useState('') - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [passwordConfirmation, setPasswordConfirmation] = useState('') - - const [usernameError, setUsernameError] = useState() - const [emailError, setEmailError] = useState() - const [passwordError, setPasswordError] = useState() - const [passwordConfirmationError, setPasswordConfirmationError] = useState< - InputValidationError | undefined - >() - const [registrationError, setRegistrationError] = useState() const [codeInvalid, setCodeInvalid] = useState(false) const [codeExpired, setCodeExpired] = useState(false) const [userInvitation, setUserInvitation] = useState() - const fetchUserInvitation = useCallback(async (code: string) => { - try { - const userInvitationResponse = await getApiClient().userInvitationClient.detail(code) - if (userInvitationResponse.expiresAt <= new Date()) { - setCodeExpired(true) - setCodeInvalid(false) - setUserInvitation(undefined) - - return - } - - setCodeExpired(false) - setCodeInvalid(false) - setUserInvitation(userInvitationResponse) - setEmail(userInvitationResponse.email) - } catch { - setCodeInvalid(true) - setCodeExpired(false) - setUserInvitation(undefined) - } - }, [getApiClient]) - - useEffect(() => { - const code = searchParams.get('code') - if (!code) return - - void fetchUserInvitation(code) - }, [searchParams, fetchUserInvitation]) - - const validateUsername = () => { - if (!username) { - setUsernameError('required') - - return false - } - - setUsernameError(undefined) - - return true - } - - const validateEmail = () => { - if (!email) { - setEmailError('required') - - return false - } - - if (!isValidEmail(email)) { - setEmailError('email') - - return false - } - - setEmailError(undefined) - - return true - } - - const validatePassword = () => { - if (!password) { - setPasswordError('required') - - return false - } - - if (!isValidPassword(password)) { - setPasswordError('password') - - return false - } - - setPasswordError(undefined) - - return true - } - - const validatePasswordConfirmation = () => { - if (!passwordConfirmation) { - setPasswordConfirmationError('required') - - return false - } - - if (!mustMatch(password, passwordConfirmation)) { - setPasswordConfirmationError('mustMatch') - - return false - } - - setPasswordConfirmationError(undefined) - - return true - } - - const validateForm = () => { - // Do not return directly the method calls; - // we need each of them to be called before returning the result - const usernameValid = validateUsername() - const emailValid = validateEmail() - const passwordValid = validatePassword() - const passwordConfirmationValid = validatePasswordConfirmation() - - return usernameValid - && emailValid - && passwordValid - && passwordConfirmationValid - } - - const onCreateAccountClick = async () => { - const isFormValid = validateForm() - if (!isFormValid) return - + const { + register, + handleSubmit, + formState: { errors, touchedFields }, + setValue, + } = useForm({ mode: 'onTouched' }) + const onSubmit: SubmitHandler = async formData => { try { const response = await getApiClient().authenticationClient.register( new RegisterUser({ - email: email.trim(), - username: username.trim(), - password, - passwordConfirmation, + email: formData.email.trim(), + username: formData.username.trim(), + password: formData.password, + passwordConfirmation: formData.confirmPassword, code: userInvitation?.code, }), ) @@ -169,6 +61,38 @@ const Register = () => { } } + const fetchUserInvitation = useCallback( + async (code: string) => { + try { + const userInvitationResponse = await getApiClient().userInvitationClient.detail(code) + if (userInvitationResponse.expiresAt <= new Date()) { + setCodeExpired(true) + setCodeInvalid(false) + setUserInvitation(undefined) + + return + } + + setCodeExpired(false) + setCodeInvalid(false) + setUserInvitation(userInvitationResponse) + setValue('email', userInvitationResponse.email) + } catch { + setCodeInvalid(true) + setCodeExpired(false) + setUserInvitation(undefined) + } + }, + [getApiClient], + ) + + useEffect(() => { + const code = searchParams.get('code') + if (!code) return + + void fetchUserInvitation(code) + }, [searchParams, fetchUserInvitation]) + return ( <> @@ -176,109 +100,110 @@ const Register = () => {

{translate('auth.sign-up-header-text')}

-
+
- + validateUsername()} - onChange={event => setUsername(event.target.value)} - type='text' + {...register('username', { + required: { value: true, message: translate('auth.username-error-required') }, + })} /> - {usernameError && ( + {errors.username && ( - {translate('auth.username-error-required')} + {errors.username.message} )}
-
- + validateEmail()} - onChange={event => setEmail(event.target.value)} + id='email' type='email' - value={email} + {...register('email', { + required: { value: true, message: translate('auth.email-error-required') }, + pattern: { value: emailRegex, message: translate('auth.email-error-format') }, + })} /> - {emailError === 'required' && ( - - {translate('auth.email-error-required')} - - )} - {emailError === 'email' && ( + {errors.email && ( - {translate('auth.email-error-format')} + {errors.email.message} )}
-
validatePassword()} - onChange={event => setPassword(event.target.value)} type='password' + {...register('password', { + required: { value: true, message: translate('auth.password-error-required') }, + pattern: { value: passwordRegex, message: translate('auth.password-error-format') }, + })} /> - {passwordError === 'required' && ( - - {translate('auth.password-error-required')} - - )} - {passwordError === 'password' && ( + {errors.password && ( - {translate('auth.password-error-format')} + {errors.password.message} )}
-
validatePasswordConfirmation()} - onChange={event => setPasswordConfirmation(event.target.value)} type='password' + {...register('confirmPassword', { + required: { + value: true, + message: translate('auth.password-confirmation-error-required'), + }, + validate: { + mustMatch: (value, formValues) => + value === formValues.password + || translate('auth.password-error-must-match'), + }, + })} /> - {passwordConfirmationError === 'required' && ( - - {translate('auth.password-confirmation-error-required')} - - )} - {passwordConfirmationError === 'mustMatch' && ( + {errors.confirmPassword && ( - {translate('auth.password-error-must-match')} + {errors.confirmPassword.message} )}
+ {registrationError && {registrationError}} {codeInvalid && ( @@ -288,15 +213,6 @@ const Register = () => { {translate('auth.invitation-expired')} )} - -
{translate('auth.already-have-an-account')} @@ -305,7 +221,7 @@ const Register = () => {
-
+
) diff --git a/canopeum_frontend/src/utils/validators.ts b/canopeum_frontend/src/utils/validators.ts index b01b7280a..bc9a81de0 100644 --- a/canopeum_frontend/src/utils/validators.ts +++ b/canopeum_frontend/src/utils/validators.ts @@ -1,8 +1,8 @@ -const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[\d!#$%&*?@A-Za-z]{8,}$/u +export const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.{8}).+$/u export const isValidPassword = (input: string) => new RegExp(passwordRegex).test(input) -const emailRegex = /^[^@]+@[^@][^.@]*\.[^@]+$/u +export const emailRegex = /^[^@]+@[^@][^.@]*\.[^@]+$/u export const isValidEmail = (input: string) => new RegExp(emailRegex).test(input)