From 4000b132f6f43b975bf5b7b3f4b6d70714c036d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=92scar=20Casajuana?= Date: Thu, 7 Nov 2024 17:23:46 +0100 Subject: [PATCH 1/4] Password recovery flow - Renamed some components for consistency - Changed forgot route to `account/password` to reuse it for the reset (via `account/password/reset`) - Edited the ForgotPasswordForm component to properly make expected API calls - Created new ResetPasswordForm component to handle the password reset behavior - Added optional email param to signin form to autofill it when trying to recover your password - Created elements for both password reset and password forgot, moving the expected logic there - Done the same for SignIn (creating elements/account/signin) - InputBasic was not accepting additional props - Added required routes Pending: error handling --- ...gotPassword.tsx => PasswordForgotForm.tsx} | 41 +++++--- src/components/Auth/PasswordResetForm.tsx | 99 +++++++++++++++++++ src/components/Auth/SignIn.tsx | 19 ++-- src/components/Auth/api.ts | 2 + src/components/Layout/InputBasic.tsx | 11 +-- src/elements/account/password/index.tsx | 20 ++++ src/elements/account/password/reset.tsx | 29 ++++++ src/elements/account/signin.tsx | 20 ++++ src/router/routes/auth.tsx | 21 ++-- src/router/routes/index.ts | 3 +- src/theme/index.ts | 6 +- 11 files changed, 230 insertions(+), 41 deletions(-) rename src/components/Auth/{ForgotPassword.tsx => PasswordForgotForm.tsx} (55%) create mode 100644 src/components/Auth/PasswordResetForm.tsx create mode 100644 src/elements/account/password/index.tsx create mode 100644 src/elements/account/password/reset.tsx create mode 100644 src/elements/account/signin.tsx diff --git a/src/components/Auth/ForgotPassword.tsx b/src/components/Auth/PasswordForgotForm.tsx similarity index 55% rename from src/components/Auth/ForgotPassword.tsx rename to src/components/Auth/PasswordForgotForm.tsx index 67660bbc..21705052 100644 --- a/src/components/Auth/ForgotPassword.tsx +++ b/src/components/Auth/PasswordForgotForm.tsx @@ -1,28 +1,45 @@ import { Button, Flex, Text } from '@chakra-ui/react' -import { useEffect } from 'react' +import { useMutation } from '@tanstack/react-query' import { FormProvider, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { NavLink, useOutletContext } from 'react-router-dom' -import { AuthOutletContextType } from '~elements/LayoutAuth' +import { NavLink, useNavigate } from 'react-router-dom' import { Routes } from '~src/router/routes' import InputBasic from '../Layout/InputBasic' +import { api, ApiEndpoints } from './api' -function ForgotPassword() { +type ForgotPasswordFormValues = { + email: string +} + +const PasswordForgotForm: React.FC = () => { const { t } = useTranslation() - const { setTitle, setSubTitle } = useOutletContext() + const navigate = useNavigate() - const methods = useForm({ + const methods = useForm({ defaultValues: { email: '', }, }) - useEffect(() => { - setTitle(t('forgot_password_title')) - setSubTitle(t('forgot_password_subtitle')) - }, []) + // Mutation for password recovery using bearedFetch and ApiEndpoints + const passwordRecoveryMutation = useMutation({ + mutationFn: ({ email }: ForgotPasswordFormValues) => + api(ApiEndpoints.PasswordRecovery, { + method: 'POST', + body: { email }, + }), + }) - const onSubmit = () => {} + const onSubmit = (data: ForgotPasswordFormValues) => + passwordRecoveryMutation.mutate(data, { + onSuccess: () => { + navigate(`${Routes.auth.passwordReset}?email=${encodeURIComponent(data.email)}`) + }, + onError: (error) => { + // we actually should not have errors except for internal server errors + methods.setError('email', { type: 'manual', message: error.message }) + }, + }) return ( <> @@ -54,4 +71,4 @@ function ForgotPassword() { ) } -export default ForgotPassword +export default PasswordForgotForm diff --git a/src/components/Auth/PasswordResetForm.tsx b/src/components/Auth/PasswordResetForm.tsx new file mode 100644 index 00000000..d30d5bbe --- /dev/null +++ b/src/components/Auth/PasswordResetForm.tsx @@ -0,0 +1,99 @@ +import { Alert, AlertIcon, Button, Flex } from '@chakra-ui/react' +import { useMutation } from '@tanstack/react-query' +import { FormProvider, useForm } from 'react-hook-form' +import { Trans, useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import InputPassword from '~components/Layout/InputPassword' +import { Routes } from '~src/router/routes' +import InputBasic from '../Layout/InputBasic' +import { api, ApiEndpoints } from './api' + +type PasswordResetFormProps = { + code?: string + email?: string +} + +type PasswordResetFormValues = { + code: string + email: string + newPassword: string + confirmPassword: string +} + +const PasswordResetForm: React.FC = ({ code, email }) => { + const { t } = useTranslation() + const navigate = useNavigate() + const methods = useForm({ + defaultValues: { + code: code || '', + email: email || '', + newPassword: '', + confirmPassword: '', + }, + }) + + const resetPasswordMutation = useMutation({ + mutationFn: ({ email, code, newPassword }: PasswordResetFormValues) => + api(ApiEndpoints.PasswordReset, { + method: 'POST', + body: { email, code, newPassword }, + }), + }) + + const onSubmit = (data: PasswordResetFormValues) => { + resetPasswordMutation.mutate(data, { + onSuccess: () => { + navigate(Routes.auth.signIn) + }, + }) + } + + return ( + + + + + + value === methods.getValues('newPassword') || t('passwords_do_not_match'), + }} + /> + {resetPasswordMutation.error && ( + + + {resetPasswordMutation.error.message} + + )} + + + + ) +} + +export default PasswordResetForm diff --git a/src/components/Auth/SignIn.tsx b/src/components/Auth/SignIn.tsx index fc14750e..95a8db19 100644 --- a/src/components/Auth/SignIn.tsx +++ b/src/components/Auth/SignIn.tsx @@ -1,16 +1,15 @@ import { Button, Flex, Text, useToast } from '@chakra-ui/react' import { useMutation } from '@tanstack/react-query' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { NavLink, useNavigate, useOutletContext } from 'react-router-dom' +import { NavLink, useNavigate } from 'react-router-dom' import { api, ApiEndpoints, UnverifiedApiError } from '~components/Auth/api' import { ILoginParams } from '~components/Auth/authQueries' import { useAuth } from '~components/Auth/useAuth' import { VerifyAccountNeeded } from '~components/Auth/Verify' import FormSubmitMessage from '~components/Layout/FormSubmitMessage' import InputPassword from '~components/Layout/InputPassword' -import { AuthOutletContextType } from '~elements/LayoutAuth' import { Routes } from '~src/router/routes' import CustomCheckbox from '../Layout/CheckboxCustom' import InputBasic from '../Layout/InputBasic' @@ -40,19 +39,15 @@ const useResendVerificationCode = () => }, }) -const SignIn = () => { +const SignIn = ({ email: emailProp }: { email?: string }) => { const { t } = useTranslation() const toast = useToast() const navigate = useNavigate() - const { setTitle, setSubTitle } = useOutletContext() - const methods = useForm() + const methods = useForm({ + defaultValues: { email: emailProp }, + }) const { handleSubmit, watch } = methods - const email = watch('email') - - useEffect(() => { - setTitle(t('signin_title')) - setSubTitle(t('signin_subtitle')) - }, []) + const email = watch('email', emailProp) const { login: { mutateAsync: login, isError, error }, diff --git a/src/components/Auth/api.ts b/src/components/Auth/api.ts index 8b71dffb..d4ae980a 100644 --- a/src/components/Auth/api.ts +++ b/src/components/Auth/api.ts @@ -4,6 +4,8 @@ export enum ApiEndpoints { Login = 'auth/login', Me = 'users/me', Password = 'users/password', + PasswordRecovery = 'users/password/recovery', + PasswordReset = 'users/password/reset', Refresh = 'auth/refresh', Register = 'users', Organizations = 'organizations', diff --git a/src/components/Layout/InputBasic.tsx b/src/components/Layout/InputBasic.tsx index c8b96126..96cfb052 100644 --- a/src/components/Layout/InputBasic.tsx +++ b/src/components/Layout/InputBasic.tsx @@ -1,9 +1,8 @@ -import { FormControl, FormErrorMessage, FormLabel, Input } from '@chakra-ui/react' -import { useState } from 'react' +import { FormControl, FormErrorMessage, FormLabel, Input, InputProps } from '@chakra-ui/react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -export interface InputBasicProps { +export interface InputBasicProps extends InputProps { formValue: string label: string placeholder?: string @@ -19,11 +18,9 @@ const InputBasic = ({ type = 'text', required = false, validation = {}, + ...props }: InputBasicProps) => { const { t } = useTranslation() - const [show, setShow] = useState(false) - const handleClick = () => setShow(!show) - const { register, formState: { errors }, @@ -39,7 +36,7 @@ const InputBasic = ({ return ( {label && {label}} - + {errorMessage || 'Error performing the operation'} ) diff --git a/src/elements/account/password/index.tsx b/src/elements/account/password/index.tsx new file mode 100644 index 00000000..f2fae9fd --- /dev/null +++ b/src/elements/account/password/index.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useOutletContext } from 'react-router-dom' +import PasswordForgotForm from '~components/Auth/PasswordForgotForm' +import { AuthOutletContextType } from '~elements/LayoutAuth' + +const PasswordForgot = () => { + const { t } = useTranslation() + const { setTitle, setSubTitle } = useOutletContext() + + // Set layout title and subtitle + useEffect(() => { + setTitle(t('forgot_password_title')) + setSubTitle(t('forgot_password_subtitle')) + }, [setTitle, setSubTitle, t]) + + return +} + +export default PasswordForgot diff --git a/src/elements/account/password/reset.tsx b/src/elements/account/password/reset.tsx new file mode 100644 index 00000000..c3ed0ce3 --- /dev/null +++ b/src/elements/account/password/reset.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useOutletContext, useSearchParams } from 'react-router-dom' +import PasswordResetForm from '~components/Auth/PasswordResetForm' +import { AuthOutletContextType } from '~elements/LayoutAuth' + +const PasswordReset = () => { + const [searchParams] = useSearchParams() + const { t } = useTranslation() + const { setTitle, setSubTitle } = useOutletContext() + + const code = searchParams.get('code') || '' + const email = searchParams.get('email') || '' + + // Set layout title and subtitle + useEffect(() => { + setTitle(t('password_reset.title', { defaultValue: 'Password reset' })) + setSubTitle( + t('password_reset.subtitle', { + defaultValue: + "If your email is an existant account, you'll receive an email with a code to reset your account.", + }) + ) + }, [setTitle, setSubTitle, t]) + + return +} + +export default PasswordReset diff --git a/src/elements/account/signin.tsx b/src/elements/account/signin.tsx new file mode 100644 index 00000000..b03164eb --- /dev/null +++ b/src/elements/account/signin.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useOutletContext } from 'react-router-dom' +import SignIn from '~components/Auth/SignIn' +import { AuthOutletContextType } from '~elements/LayoutAuth' + +const Signin = () => { + const { t } = useTranslation() + const { setTitle, setSubTitle } = useOutletContext() + + // Set layout title and subtitle + useEffect(() => { + setTitle(t('signin_title')) + setSubTitle(t('signin_subtitle')) + }, []) + + return +} + +export default Signin diff --git a/src/router/routes/auth.tsx b/src/router/routes/auth.tsx index 8190b930..f38524c2 100644 --- a/src/router/routes/auth.tsx +++ b/src/router/routes/auth.tsx @@ -5,10 +5,11 @@ import { Routes } from '.' import { SuspenseLoader } from '../SuspenseLoader' const NonLoggedRoute = lazy(() => import('../NonLoggedRoute')) -const SignIn = lazy(() => import('~components/Auth/SignIn')) +const Signin = lazy(() => import('~elements/account/signin')) const SignUp = lazy(() => import('~components/Auth/SignUp')) const Verify = lazy(() => import('~components/Auth/Verify')) -const ForgotPassword = lazy(() => import('~components/Auth/ForgotPassword')) +const PasswordForgot = lazy(() => import('~elements/account/password')) +const PasswordReset = lazy(() => import('~elements/account/password/reset')) const AuthElements = [ { @@ -20,7 +21,7 @@ const AuthElements = [ path: Routes.auth.signIn, element: ( - + ), }, @@ -32,19 +33,27 @@ const AuthElements = [ ), }, + { + path: Routes.auth.verify, + element: ( + + + + ), + }, { path: Routes.auth.recovery, element: ( - + ), }, { - path: Routes.auth.verify, + path: Routes.auth.passwordReset, element: ( - + ), }, diff --git a/src/router/routes/index.ts b/src/router/routes/index.ts index c04eecea..8e5a8d77 100644 --- a/src/router/routes/index.ts +++ b/src/router/routes/index.ts @@ -3,8 +3,9 @@ export const Routes = { auth: { signIn: '/account/signin', signUp: '/account/signup', - recovery: '/account/recovery', + recovery: '/account/password', verify: '/account/verify', + passwordReset: '/account/password/reset', }, calculator: '/calculator', dashboard: { diff --git a/src/theme/index.ts b/src/theme/index.ts index 580d4a77..ed0dc68e 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -1,4 +1,4 @@ -import { ColorMode, extendTheme, textDecoration } from '@chakra-ui/react' +import { ColorMode, extendTheme } from '@chakra-ui/react' import { darkTheme, lightTheme } from '@rainbow-me/rainbowkit' import { theme as vtheme } from '@vocdoni/chakra-components' import { breakpoints } from './breakpoints' @@ -14,15 +14,15 @@ import { Input } from './components/input' import { Link } from './components/link' import { Modal } from './components/modal' import { Pagination } from './components/pagination' +import { ElectionQuestions } from './components/Questions' import { Radio } from './components/radio' +import { ElectionResults } from './components/results' import { Stepper } from './components/stepper' import { Tabs } from './components/Tabs' import { Text } from './components/text' import { Textarea } from './components/textarea' import { editor } from './editor' import { spacing } from './space' -import { ElectionQuestions } from './components/Questions' -import { ElectionResults } from './components/results' export const theme = extendTheme(vtheme, { config: { From a7516982b7d6bdd9454f1f9331438234a717c01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=92scar=20Casajuana?= Date: Fri, 8 Nov 2024 16:16:47 +0100 Subject: [PATCH 2/4] Only append Authorization header if bearer is set --- src/components/Auth/useAuthProvider.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Auth/useAuthProvider.ts b/src/components/Auth/useAuthProvider.ts index a168162f..24f22fe7 100644 --- a/src/components/Auth/useAuthProvider.ts +++ b/src/components/Auth/useAuthProvider.ts @@ -62,7 +62,9 @@ export const useAuthProvider = () => { logout() throw new Error('No bearer token') } - headers.append('Authorization', `Bearer ${bearer}`) + if (bearer) { + headers.append('Authorization', `Bearer ${bearer}`) + } return api(path, { headers, ...params }).catch((e) => { if (e instanceof UnauthorizedApiError) { return api(ApiEndpoints.Refresh, { headers, method: 'POST' }) From aefafb05b4080b34a5af4e5fe0e67bbcd1b7675e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=92scar=20Casajuana?= Date: Tue, 12 Nov 2024 15:45:26 +0100 Subject: [PATCH 3/4] Minor translation change --- src/elements/account/password/reset.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/account/password/reset.tsx b/src/elements/account/password/reset.tsx index c3ed0ce3..ed497f50 100644 --- a/src/elements/account/password/reset.tsx +++ b/src/elements/account/password/reset.tsx @@ -18,7 +18,7 @@ const PasswordReset = () => { setSubTitle( t('password_reset.subtitle', { defaultValue: - "If your email is an existant account, you'll receive an email with a code to reset your account.", + "If your email corresponds to an existing account, you'll receive an email with a code to reset your password.", }) ) }, [setTitle, setSubTitle, t]) From 6f26fe6d9c3f1203c646b36e6fea376af23e9006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=92scar=20Casajuana?= Date: Wed, 13 Nov 2024 13:27:26 +0100 Subject: [PATCH 4/4] Remove unnecessary throw in bearedFetch --- src/components/Auth/useAuthProvider.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/Auth/useAuthProvider.ts b/src/components/Auth/useAuthProvider.ts index 24f22fe7..afb6d1e3 100644 --- a/src/components/Auth/useAuthProvider.ts +++ b/src/components/Auth/useAuthProvider.ts @@ -58,10 +58,6 @@ export const useAuthProvider = () => { const bearedFetch = useCallback( (path: string, { headers = new Headers({}), ...params }: ApiParams = {}) => { - if (!bearer) { - logout() - throw new Error('No bearer token') - } if (bearer) { headers.append('Authorization', `Bearer ${bearer}`) }