diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f33a02cd1..9d8f2d043 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,7 +6,7 @@ version: 2 updates: - - package-ecosystem: "devcontainers" - directory: "/" - schedule: - interval: weekly + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/canopeum_deploy.yml b/.github/workflows/canopeum_deploy.yml index 17b7da7f6..fca14f1be 100644 --- a/.github/workflows/canopeum_deploy.yml +++ b/.github/workflows/canopeum_deploy.yml @@ -4,12 +4,12 @@ on: workflow_dispatch: # Allows manual triggers inputs: deploy_frontend: - description: 'Deploy the frontend' + description: "Deploy the frontend" required: true type: boolean default: true deploy_backend: - description: 'Deploy the backend' + description: "Deploy the backend" required: true type: boolean default: true @@ -30,7 +30,7 @@ on: jobs: GetDateTime: runs-on: ubuntu-latest - if: ${{ github.event_name == 'push' || (inputs.deploy_frontend && inputs.deploy_backend) }} + if: ${{ github.event_name == 'push' || (inputs.deploy_frontend && inputs.deploy_backend) }} outputs: DATETIME: ${{ steps.datetime.outputs.DATETIME }} steps: @@ -41,7 +41,7 @@ jobs: BuildFrontend: name: Build & Deploy Frontend runs-on: ubuntu-latest - if: ${{ github.event_name == 'push' || inputs.deploy_frontend }} + if: ${{ github.event_name == 'push' || inputs.deploy_frontend }} needs: [GetDateTime] defaults: run: @@ -70,7 +70,7 @@ jobs: BuildBackend: name: Build & Deploy Backend runs-on: ubuntu-latest - if: ${{ github.event_name == 'push' || inputs.deploy_backend }} + if: ${{ github.event_name == 'push' || inputs.deploy_backend }} needs: [GetDateTime] defaults: run: diff --git a/.vscode/settings.json b/.vscode/settings.json index 48b4fbc09..0fe4d38d6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -112,7 +112,7 @@ } ], "eslint.codeActionsOnSave.rules": [ - // Clearing imports and unused vars because of commented code while testing is annoying + // Clearing imports, unused vars and comments because of commented code while testing is annoying "!unused-imports/no-unused-imports", "!autofix/no-debugger", "!autofix/no-unused-vars", @@ -135,6 +135,9 @@ "editor.defaultFormatter": "vscode.html-language-features" }, "html.format.wrapAttributes": "force-expand-multiline", + // To match markup_fmt with dprint + "html.format.indentInnerHtml": true, + "html.format.extraLiners": "", /* * CSS diff --git a/canopeum_frontend/index.html b/canopeum_frontend/index.html index 783e79f37..92f4c58b5 100644 --- a/canopeum_frontend/index.html +++ b/canopeum_frontend/index.html @@ -1,47 +1,45 @@ - + + + + + + Releaf + + + + + + - - - - - Releaf - - - - - - - - -
- - - + +
+ + 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/components/analytics/OptionQuantitySelector.tsx b/canopeum_frontend/src/components/analytics/OptionQuantitySelector.tsx index 1d08f01eb..c7ed11e83 100644 --- a/canopeum_frontend/src/components/analytics/OptionQuantitySelector.tsx +++ b/canopeum_frontend/src/components/analytics/OptionQuantitySelector.tsx @@ -89,18 +89,18 @@ const OptionQuantitySelector = ( clearOnBlur freeSolo getOptionKey={option => ( - typeof (option) === 'string' + typeof option === 'string' ? option : option.value )} getOptionLabel={option => ( - typeof (option) === 'string' + typeof option === 'string' ? option : option.displayText )} id={id} onChange={(_event, option) => { - if (option === null || typeof (option) === 'string') return + if (option === null || typeof option === 'string') return onSelect(option) }} diff --git a/canopeum_frontend/src/components/inputs/TextExpansion.tsx b/canopeum_frontend/src/components/inputs/TextExpansion.tsx index 991dc19cb..9105a49db 100644 --- a/canopeum_frontend/src/components/inputs/TextExpansion.tsx +++ b/canopeum_frontend/src/components/inputs/TextExpansion.tsx @@ -29,9 +29,7 @@ const TextExpansion = ({ text, maxLength }: TextExpansionProps) => { ) - : ( - text - )} + : text} ) } 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/locale/en/auth.ts b/canopeum_frontend/src/locale/en/auth.ts index 48d10c3a9..0d4dfc23a 100644 --- a/canopeum_frontend/src/locale/en/auth.ts +++ b/canopeum_frontend/src/locale/en/auth.ts @@ -18,6 +18,7 @@ export default { 'password-confirmation-error-required': 'Please re-enter your password', 'password-error-must-match': 'Passwords do not match', 'log-in': 'Log In', + 'remember-me': 'Remember me', 'log-out': 'Log Out', 'log-out-confirmation': 'Are you sure you want to log out?', 'sign-up': 'Sign Up', diff --git a/canopeum_frontend/src/locale/fr/auth.ts b/canopeum_frontend/src/locale/fr/auth.ts index 601e43060..3270a487e 100644 --- a/canopeum_frontend/src/locale/fr/auth.ts +++ b/canopeum_frontend/src/locale/fr/auth.ts @@ -23,6 +23,7 @@ export default { 'log-out': 'Se Déconnecter', 'log-out-confirmation': 'Est-vous certain de vouloir vous déconnecter?', 'sign-up': "S'inscrire", + 'remember-me': 'Se souvenir de moi', 'create-account': 'Créer mon compte', 'already-have-an-account': 'Vous avez déjà un compte?', 'back-to-map': 'Retourner à la carte', diff --git a/canopeum_frontend/src/pages/Login.tsx b/canopeum_frontend/src/pages/Login.tsx index 9aae39df9..5fbda5047 100644 --- a/canopeum_frontend/src/pages/Login.tsx +++ b/canopeum_frontend/src/pages/Login.tsx @@ -1,150 +1,118 @@ +/* eslint-disable react/jsx-props-no-spreading -- Needed for react hook forms */ import { useContext, useState } from 'react' +import { type SubmitHandler, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import AuthPageLayout from '@components/auth/AuthPageLayout' -import Checkbox from '@components/Checkbox' import { AuthenticationContext } from '@components/context/AuthenticationContext' import { appRoutes } from '@constants/routes.constant' +import { formClasses } from '@constants/style' import useApiClient from '@hooks/ApiClientHook' import { LoginUser } from '@services/api' import { storeToken } from '@utils/auth.utils' -import type { InputValidationError } from '@utils/validators' -const Login = () => { - const { authenticate } = useContext(AuthenticationContext) - const { t: translate } = useTranslation() - const { getApiClient } = useApiClient() - - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [rememberMe, setRememberMe] = useState(false) - - const [emailError, setEmailError] = useState() - const [passwordError, setPasswordError] = useState() - - const [loginError, setLoginError] = useState() - - const validateEmail = () => { - if (!email) { - setEmailError('required') - - return false - } - - setEmailError(undefined) - - return true - } - - const validatePassword = () => { - if (!password) { - setPasswordError('required') - - return false - } - - setPasswordError(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 emailValid = validateEmail() - const passwordValid = validatePassword() - - return emailValid - && passwordValid - } +type LoginFormInputs = { + email: string, + password: string, + rememberMe: boolean, +} - const onLoginClick = async () => { - const isFormValid = validateForm() - if (!isFormValid) return +const Login = () => { + const { + register, + handleSubmit, + formState: { errors, touchedFields }, + } = useForm({ mode: 'onTouched' }) + const onSubmit: SubmitHandler = async formData => { try { const response = await getApiClient().authenticationClient.login( new LoginUser({ - email: email.trim(), - password, + email: formData.email.trim(), + password: formData.password, }), ) - storeToken(response.token, rememberMe) + storeToken(response.token, formData.rememberMe) authenticate(response.user) } catch { setLoginError(translate('auth.log-in-error')) } } + const { authenticate } = useContext(AuthenticationContext) + const { t: translate } = useTranslation() + const { getApiClient } = useApiClient() + + const [loginError, setLoginError] = useState() + return (

{translate('auth.log-in-header-text')}

-
+
validateEmail()} - onChange={event => setEmail(event.target.value)} type='email' + {...register('email', { + required: { value: true, message: translate('auth.email-error-required') }, + })} /> - {emailError === 'required' && ( - - {translate('auth.email-error-required')} - - )} + {errors.email && {errors.email.message}}
validatePassword()} - onChange={event => setPassword(event.target.value)} type='password' + {...register('password', { + required: { value: true, message: translate('auth.password-error-required') }, + })} /> - {passwordError === 'required' && ( - - {translate('auth.password-error-required')} - + {errors.password && ( + {errors.password.message} )}
- setRememberMe(isChecked)} - value='remember-me' - > - Remember Me - + {/* TODO: Make a component, this is extracted from the Checkbox */} + {/* component until everything uses React Hook Forms */} +
+ + +
{loginError && {loginError}}
- @@ -156,7 +124,7 @@ const Login = () => { {translate('auth.sign-up')}
-
+
) } diff --git a/canopeum_frontend/src/pages/Register.tsx b/canopeum_frontend/src/pages/Register.tsx index 7144cbc3c..78c985e9e 100644 --- a/canopeum_frontend/src/pages/Register.tsx +++ b/canopeum_frontend/src/pages/Register.tsx @@ -1,4 +1,6 @@ +/* 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' @@ -6,12 +8,20 @@ import AuthPageLayout from '@components/auth/AuthPageLayout' import { AuthenticationContext } from '@components/context/AuthenticationContext' import { SnackbarContext } from '@components/context/SnackbarContext' import { appRoutes } from '@constants/routes.constant' +import { formClasses } from '@constants/style' import useApiClient from '@hooks/ApiClientHook' import useErrorHandling from '@hooks/ErrorHandlingHook' 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() @@ -21,24 +31,40 @@ const Register = () => { const { openAlertSnackbar } = useContext(SnackbarContext) const { getErrorMessage } = useErrorHandling() - 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 { + register, + handleSubmit, + formState: { errors, touchedFields }, + setValue, + } = useForm({ mode: 'onTouched' }) + const onSubmit: SubmitHandler = async formData => { + try { + const response = await getApiClient().authenticationClient.register( + new RegisterUser({ + email: formData.email.trim(), + username: formData.username.trim(), + password: formData.password, + passwordConfirmation: formData.confirmPassword, + code: userInvitation?.code, + }), + ) + + authenticate(response.user) + // By default, do not "remember" the user outside of the browser's session on Registration + // They will get to chose that option the next time they log in + const rememberMe = false + storeToken(response.token, rememberMe) + } catch { + setRegistrationError(translate('auth.sign-up-error')) + } + } + const fetchUserInvitation = useCallback(async (code: string) => { try { const userInvitationResponse = await getApiClient().userInvitationClient.detail(code) @@ -53,7 +79,7 @@ const Register = () => { setCodeExpired(false) setCodeInvalid(false) setUserInvitation(userInvitationResponse) - setEmail(userInvitationResponse.email) + setValue('email', userInvitationResponse.email) } catch { setCodeInvalid(true) setCodeExpired(false) @@ -73,110 +99,12 @@ const Register = () => { ) }, [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 - - try { - const response = await getApiClient().authenticationClient.register( - new RegisterUser({ - email: email.trim(), - username: username.trim(), - password, - passwordConfirmation, - code: userInvitation?.code, - }), - ) + useEffect(() => { + const code = searchParams.get('code') + if (!code) return - authenticate(response.user) - // By default, do not "remember" the user outside of the browser's session on Registration - // They will get to chose that option the next time they log in - const rememberMe = false - storeToken(response.token, rememberMe) - } catch { - setRegistrationError(translate('auth.sign-up-error')) - } - } + void fetchUserInvitation(code) + }, [searchParams, fetchUserInvitation]) return ( @@ -185,109 +113,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' && ( + {errors.password && ( - {translate('auth.password-error-required')} - - )} - {passwordError === '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' && ( + {errors.confirmPassword && ( - {translate('auth.password-confirmation-error-required')} - - )} - {passwordConfirmationError === 'mustMatch' && ( - - {translate('auth.password-error-must-match')} + {errors.confirmPassword.message} )}
+ {registrationError && {registrationError}} {codeInvalid && ( @@ -297,15 +226,6 @@ const Register = () => { {translate('auth.invitation-expired')} )} - -
{translate('auth.already-have-an-account')} @@ -314,7 +234,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) diff --git a/docker-compose.yml b/docker-compose.yml index a3fa750dc..7a61dcef9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.7' +version: "3.7" services: mysql: hostname: mysql @@ -33,7 +33,7 @@ services: MYSQL_PASSWORD_CANOPEUM: /run/secrets/MYSQL_PASSWORD_CANOPEUM SECRET_KEY_DJANGO_CANOPEUM: /run/secrets/SECRET_KEY_DJANGO_CANOPEUM GOOGLE_API_KEY_CANOPEUM: /run/secrets/GOOGLE_API_KEY_CANOPEUM - DEBUG: 'False' + DEBUG: "False" VIRTUAL_HOST: api.canopeum.releaftrees.life LETSENCRYPT_HOST: api.canopeum.releaftrees.life volumes: diff --git a/dprint.json b/dprint.json index d9b85dd78..0ef17b205 100644 --- a/dprint.json +++ b/dprint.json @@ -13,6 +13,8 @@ "**/obj", // capacitor folders "**/*/android/app", - "**/*/ios/App" + "**/*/ios/App", + // TODO: Test and update configs for scss (Malva) + "**/*.scss" ] }