Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Password recovery flow #831

Merged
merged 4 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<AuthOutletContextType>()
const navigate = useNavigate()

const methods = useForm({
const methods = useForm<ForgotPasswordFormValues>({
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 (
<>
Expand Down Expand Up @@ -54,4 +71,4 @@ function ForgotPassword() {
)
}

export default ForgotPassword
export default PasswordForgotForm
99 changes: 99 additions & 0 deletions src/components/Auth/PasswordResetForm.tsx
Original file line number Diff line number Diff line change
@@ -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<PasswordResetFormProps> = ({ code, email }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const methods = useForm<PasswordResetFormValues>({
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 (
<FormProvider {...methods}>
<Flex as='form' onSubmit={methods.handleSubmit(onSubmit)} flexDirection='column' gap={6}>
<InputBasic
formValue='email'
label={t('email')}
placeholder={t('email_placeholder', { defaultValue: '[email protected]' })}
type='email'
required
isDisabled={!!email}
/>
<InputBasic
formValue='code'
label={t('verification_code', { defaultValue: 'Verification Code' })}
placeholder={t('verification_code_placeholder', { defaultValue: 'Enter the verification code' })}
type='text'
required
isDisabled={!!code}
/>
<InputPassword
formValue='newPassword'
label={t('new_password', { defaultValue: 'New Password' })}
placeholder={t('new_password_placeholder', { defaultValue: 'Enter your new password' })}
required
/>
<InputPassword
formValue='confirmPassword'
label={t('confirm_password', { defaultValue: 'Confirm Password' })}
placeholder={t('confirm_password_placeholder', { defaultValue: 'Confirm your new password' })}
required
validation={{
validate: (value) => value === methods.getValues('newPassword') || t('passwords_do_not_match'),
}}
/>
{resetPasswordMutation.error && (
<Alert status='error'>
<AlertIcon />
{resetPasswordMutation.error.message}
</Alert>
)}
<Button type='submit' fontSize='sm' variant='brand' fontWeight='500' w='100%' h={50}>
<Trans i18nKey='reset_password_button'>Reset Password</Trans>
</Button>
</Flex>
</FormProvider>
)
}

export default PasswordResetForm
19 changes: 7 additions & 12 deletions src/components/Auth/SignIn.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<AuthOutletContextType>()
const methods = useForm<FormData>()
const methods = useForm<FormData>({
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 },
Expand Down
2 changes: 2 additions & 0 deletions src/components/Auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 2 additions & 4 deletions src/components/Auth/useAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,9 @@ export const useAuthProvider = () => {

const bearedFetch = useCallback(
<T>(path: string, { headers = new Headers({}), ...params }: ApiParams = {}) => {
if (!bearer) {
logout()
throw new Error('No bearer token')
if (bearer) {
elboletaire marked this conversation as resolved.
Show resolved Hide resolved
headers.append('Authorization', `Bearer ${bearer}`)
}
headers.append('Authorization', `Bearer ${bearer}`)
return api<T>(path, { headers, ...params }).catch((e) => {
if (e instanceof UnauthorizedApiError) {
return api<LoginResponse>(ApiEndpoints.Refresh, { headers, method: 'POST' })
Expand Down
11 changes: 4 additions & 7 deletions src/components/Layout/InputBasic.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 },
Expand All @@ -39,7 +36,7 @@ const InputBasic = ({
return (
<FormControl isInvalid={!!errors[formValue]} isRequired={required}>
{label && <FormLabel variant='process-create-title-sm'>{label}</FormLabel>}
<Input {...register(formValue, validationRules)} type={type} placeholder={placeholder} />
<Input {...register(formValue, validationRules)} type={type} placeholder={placeholder} {...props} />
<FormErrorMessage mt={2}>{errorMessage || 'Error performing the operation'}</FormErrorMessage>
</FormControl>
)
Expand Down
20 changes: 20 additions & 0 deletions src/elements/account/password/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useEffect } from 'react'
emmdim marked this conversation as resolved.
Show resolved Hide resolved
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<AuthOutletContextType>()

// Set layout title and subtitle
useEffect(() => {
setTitle(t('forgot_password_title'))
setSubTitle(t('forgot_password_subtitle'))
}, [setTitle, setSubTitle, t])

return <PasswordForgotForm />
}

export default PasswordForgot
29 changes: 29 additions & 0 deletions src/elements/account/password/reset.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthOutletContextType>()

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 corresponds to an existing account, you'll receive an email with a code to reset your password.",
})
)
}, [setTitle, setSubTitle, t])

return <PasswordResetForm code={code} email={email} />
}

export default PasswordReset
20 changes: 20 additions & 0 deletions src/elements/account/signin.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthOutletContextType>()

// Set layout title and subtitle
useEffect(() => {
setTitle(t('signin_title'))
setSubTitle(t('signin_subtitle'))
}, [])

return <SignIn />
}

export default Signin
21 changes: 15 additions & 6 deletions src/router/routes/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand All @@ -20,7 +21,7 @@ const AuthElements = [
path: Routes.auth.signIn,
element: (
<SuspenseLoader>
<SignIn />
<Signin />
</SuspenseLoader>
),
},
Expand All @@ -32,19 +33,27 @@ const AuthElements = [
</SuspenseLoader>
),
},
{
path: Routes.auth.verify,
element: (
<SuspenseLoader>
<Verify />
</SuspenseLoader>
),
},
{
path: Routes.auth.recovery,
element: (
<SuspenseLoader>
<ForgotPassword />
<PasswordForgot />
</SuspenseLoader>
),
},
{
path: Routes.auth.verify,
path: Routes.auth.passwordReset,
element: (
<SuspenseLoader>
<Verify />
<PasswordReset />
</SuspenseLoader>
),
},
Expand Down
Loading
Loading