From a2afdddf04bdf6d12a78ecaaea5db51363726bb8 Mon Sep 17 00:00:00 2001 From: mehrzadghdev Date: Fri, 8 Aug 2025 00:46:05 +0330 Subject: [PATCH] feat: forget password steps and pages added --- public/locales/en/authentication.json | 16 ++ public/locales/fa/authentication.json | 16 ++ .../AuthenticationSteps/CompleteSignUp.tsx | 2 +- .../AuthenticationSteps/LoginRegiserForm.tsx | 2 +- .../CountryCodeSelector.tsx | 2 +- .../ForgetPassword/ChangePassword.tsx | 239 ++++++++++++++++++ .../ForgetPasswordContainer.tsx | 62 +++++ .../ForgetPassword/ForgetPasswordOtp.tsx | 168 ++++++++++++ .../ForgetPassword/ForgettedPasswordInfo.tsx | 151 +++++++++++ .../routes/AuthenticationPage.tsx | 3 +- src/utils/regexes/containsNumber.tsx | 1 + src/utils/regexes/containsSymbol.tsx | 1 + src/utils/regexes/hasUpperAndLowerLetter.tsx | 5 + src/utils/regexes/least8Chars.tsx | 1 + 14 files changed, 665 insertions(+), 4 deletions(-) rename src/features/authentication/components/{AuthenticationSteps => }/CountryCodeSelector.tsx (99%) create mode 100644 src/features/authentication/components/ForgetPassword/ChangePassword.tsx create mode 100644 src/features/authentication/components/ForgetPassword/ForgetPasswordContainer.tsx create mode 100644 src/features/authentication/components/ForgetPassword/ForgetPasswordOtp.tsx create mode 100644 src/features/authentication/components/ForgetPassword/ForgettedPasswordInfo.tsx create mode 100644 src/utils/regexes/containsNumber.tsx create mode 100644 src/utils/regexes/containsSymbol.tsx create mode 100644 src/utils/regexes/hasUpperAndLowerLetter.tsx create mode 100644 src/utils/regexes/least8Chars.tsx diff --git a/public/locales/en/authentication.json b/public/locales/en/authentication.json index 2414a85..6633ea7 100644 --- a/public/locales/en/authentication.json +++ b/public/locales/en/authentication.json @@ -33,5 +33,21 @@ "loginPassword": "Login password", "loginWithOneTimeCode": "Login with one-time code", "iForgotMyPassword": "I forgot my password." + }, + "forgetPassword": { + "forgetPassword": "Forget password", + "pleaseEnterYourMobileNumberEmailToRecoverYourPassword": "Please enter your mobile number/email to recover your password.", + "anEmailContainingARecoveryCodeHasBeenSentToThisEmailAddress": "An email containing a recovery code has been sent to this email address.", + "anCodeContainingARecoveryCodeHasBeenSentToThisPhoneNumber": "An recovery code has been sent to this phone number.", + "confirm": "Confirm", + "changePassword": "Change password", + "createANewPassword": "Create a new password", + "newPassword": "New password", + "includingANumber": "Including a number", + "atLeast8Characters": "At least 8 characters", + "containsAnUppercaseAndLowercaseLetter": "Contains an uppercase and lowercase letter", + "ContainsASymbol": "Contains the symbol (!@#$%&*^)", + "confirmPassword": "Confirm password", + "passwordChangedSuccessfully": "Password changed successfully" } } diff --git a/public/locales/fa/authentication.json b/public/locales/fa/authentication.json index ff6f92c..bfb8610 100644 --- a/public/locales/fa/authentication.json +++ b/public/locales/fa/authentication.json @@ -35,5 +35,21 @@ "loginPassword": "رمز ورود", "loginWithOneTimeCode": "ورود با کد یکبار مصرف", "iForgotMyPassword": "رمز ورودم را فراموش کردم" + }, + "forgetPassword": { + "forgetPassword": "فراموشی رمز", + "pleaseEnterYourMobileNumberEmailToRecoverYourPassword": "لطفا برای بازیابی رمز عبور شماره موبایل/ایمیل خود را وارد کنید.", + "anEmailContainingARecoveryCodeHasBeenSentToThisEmailAddress": "یک ایمیل حاوی کد بازیابی به این ایمیل ارسال شد", + "anCodeContainingARecoveryCodeHasBeenSentToThisPhoneNumber": "یک کد بازیابی به این شماره ارسال شد", + "confirm": "تایید", + "changePassword": "تغییر رمز عبور", + "createANewPassword": "یک رمز عبور جدید ایجاد کنید", + "newPassword": "رمز عبور جدید", + "includingANumber": "شامل عدد", + "atLeast8Characters": "حداقل ۸ حرف", + "containsAnUppercaseAndLowercaseLetter": "شامل یک حرف بزرگ و کوچک", + "ContainsASymbol": "شامل علامت (!@#$%&*^)", + "confirmPassword": "تکرار رمز عبور", + "passwordChangedSuccessfully": "رمز عبور با موفقیت تغییر یافت" } } diff --git a/src/features/authentication/components/AuthenticationSteps/CompleteSignUp.tsx b/src/features/authentication/components/AuthenticationSteps/CompleteSignUp.tsx index bdd2f7d..74a5cb3 100644 --- a/src/features/authentication/components/AuthenticationSteps/CompleteSignUp.tsx +++ b/src/features/authentication/components/AuthenticationSteps/CompleteSignUp.tsx @@ -2,8 +2,8 @@ import { Box, Button, Paper, TextField, Typography } from '@mui/material'; import parsePhoneNumberFromString from 'libphonenumber-js'; import React, { useRef, useState, type Dispatch } from 'react'; import { useTranslation } from 'react-i18next'; -import { CountryCodeSelector } from './CountryCodeSelector'; import { AuthenticationCard } from '../AuthenticationCard'; +import { CountryCodeSelector } from '../CountryCodeSelector'; export interface CompleteSignUpProps { email: string; diff --git a/src/features/authentication/components/AuthenticationSteps/LoginRegiserForm.tsx b/src/features/authentication/components/AuthenticationSteps/LoginRegiserForm.tsx index 1c28e08..df79514 100644 --- a/src/features/authentication/components/AuthenticationSteps/LoginRegiserForm.tsx +++ b/src/features/authentication/components/AuthenticationSteps/LoginRegiserForm.tsx @@ -8,13 +8,13 @@ import { } from '@mui/material'; import { useRef, useState, type Dispatch } from 'react'; import { useTranslation } from 'react-i18next'; -import { CountryCodeSelector } from './CountryCodeSelector'; import { Google } from 'iconsax-reactjs'; import { isNumeric } from '@/utils/regexes/isNumeric'; import type { AuthMode, AuthType } from '../../types/auth-types'; import { isEmail } from '@/utils/regexes/isEmail'; import parsePhoneNumberFromString from 'libphonenumber-js'; import { AuthenticationCard } from '../AuthenticationCard'; +import { CountryCodeSelector } from '../CountryCodeSelector'; export interface LoginRegisterFormProps { loginRegisterValue: string; diff --git a/src/features/authentication/components/AuthenticationSteps/CountryCodeSelector.tsx b/src/features/authentication/components/CountryCodeSelector.tsx similarity index 99% rename from src/features/authentication/components/AuthenticationSteps/CountryCodeSelector.tsx rename to src/features/authentication/components/CountryCodeSelector.tsx index dde39a4..427bc8a 100644 --- a/src/features/authentication/components/AuthenticationSteps/CountryCodeSelector.tsx +++ b/src/features/authentication/components/CountryCodeSelector.tsx @@ -10,11 +10,11 @@ import { Typography, } from '@mui/material'; import { useMemo, useRef, useState, type RefObject } from 'react'; -import { countries, type Country } from '../../data/countries'; import { ArrowDown2 } from 'iconsax-reactjs'; import ReactCountryFlag from 'react-country-flag'; import { useTranslation } from 'react-i18next'; import { Virtuoso } from 'react-virtuoso'; +import { countries, type Country } from '../data/countries'; interface CountryCodeSelectorProps { show: boolean; value: string; diff --git a/src/features/authentication/components/ForgetPassword/ChangePassword.tsx b/src/features/authentication/components/ForgetPassword/ChangePassword.tsx new file mode 100644 index 0000000..a83732b --- /dev/null +++ b/src/features/authentication/components/ForgetPassword/ChangePassword.tsx @@ -0,0 +1,239 @@ +import React, { useRef, useState } from 'react'; +import { AuthenticationCard } from '../AuthenticationCard'; +import { + ArrowLeft, + Edit2, + Eye, + EyeSlash, + MaskLeft, + TickCircle, +} from 'iconsax-reactjs'; +import { + Box, + Button, + IconButton, + Stack, + TextField, + Typography, + useTheme, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Toast } from '@/components/Toast'; +import { containsNumber } from '@/utils/regexes/containsNumber'; +import { containsSymbol } from '@/utils/regexes/containsSymbol'; +import { least8Chars } from '@/utils/regexes/least8Chars'; +import { hasUpperAndLowerLetter } from '@/utils/regexes/hasUpperAndLowerLetter'; + +export interface ChangePasswordProps { + onEditInfo: () => void; + onPasswordChanged: () => void; + forgettedPasswordInfo: string; +} + +export const ChangePassword = ({ + onEditInfo, + onPasswordChanged, + forgettedPasswordInfo, +}: ChangePasswordProps) => { + const theme = useTheme(); + const { t } = useTranslation('authentication'); + const [passValue, setPassValue] = useState(''); + const [confirmPassValue, setConfirmPassValue] = useState(''); + const [inputTouched, setInputTouched] = useState(false); + const [confirmInputTouched, setConfirmInputTouched] = + useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = + useState(false); + const inputRef = useRef(null); + const confirmInputRef = useRef(null); + const [changePasswordLoading, setChangePasswordLoading] = + useState(false); + const [changePasswordStatus, setChangePasswordStatus] = useState< + 'success' | 'failed' + >(); + const [changePassAlertOpen, setChangePassAlertOpen] = + useState(false); + const [changePassFailedMessage, setChangePassFailedMessage] = + useState(''); + + const passwordValidationRules = [ + { title: t('forgetPassword.includingANumber'), validator: containsNumber }, + { title: t('forgetPassword.atLeast8Characters'), validator: least8Chars }, + { + title: t('forgetPassword.containsAnUppercaseAndLowercaseLetter'), + validator: hasUpperAndLowerLetter, + }, + { title: t('forgetPassword.ContainsASymbol'), validator: containsSymbol }, + ]; + + const handleBlur = () => { + setInputTouched(true); + }; + + const handleConfirmPassBlur = () => { + setConfirmInputTouched(true); + }; + + const handleSubmit = () => { + if (!passValue || !isValidPassword(passValue)) { + setInputTouched(true); + inputRef.current?.focus(); + } else if (passValue !== confirmPassValue) { + setConfirmInputTouched(true); + confirmInputRef.current?.focus(); + } else { + setChangePasswordLoading(true); + + // Change setTimeout to api call + setTimeout(() => { + setChangePassAlertOpen(true); + // setLoginStatus('success'); + // setLoginStatus('failed'); + // setLoginFailedMessage('رمز عبور اشتباه میباشد'); + onPasswordChanged(); + setChangePasswordLoading(false); + }, 1000); + } + }; + + const isValidPassword = (value: string) => { + return ( + containsNumber(value) && + containsSymbol(value) && + least8Chars(value) && + hasUpperAndLowerLetter(value) + ); + }; + + return ( + + setChangePassAlertOpen(false)} + color={changePasswordStatus === 'failed' ? 'error' : 'success'} + > + {changePasswordStatus === 'failed' + ? changePassFailedMessage + : t('forgetPassword.passwordChangedSuccessfully')} + + + + + {t('forgetPassword.changePassword')} + + + + + + + {t('forgetPassword.createANewPassword')} + + + setPassValue(e.target.value)} + onBlur={handleBlur} + error={inputTouched && !isValidPassword(passValue)} + autoFocus + slotProps={{ + htmlInput: { sx: { lineHeight: 1.5, paddingInlineStart: 1 } }, + input: { + startAdornment: confirmPassValue && + isValidPassword(passValue) && + passValue === confirmPassValue && ( + + ), + endAdornment: passValue ? ( + setShowPassword(!showPassword)} + > + {showPassword ? : } + + ) : ( + '' + ), + }, + }} + sx={{ mt: 4 }} + /> + + {!isValidPassword(passValue) && ( + + {passwordValidationRules.map((rule) => ( + + + + {rule.title} + + ))} + + )} + + setConfirmPassValue(e.target.value)} + onBlur={handleConfirmPassBlur} + error={confirmInputTouched && confirmPassValue !== passValue} + slotProps={{ + htmlInput: { sx: { lineHeight: 1.5, paddingInlineStart: 1 } }, + input: { + startAdornment: confirmPassValue && + isValidPassword(passValue) && + passValue === confirmPassValue && ( + + ), + endAdornment: confirmPassValue ? ( + setShowConfirmPassword(!showConfirmPassword)} + > + {showPassword ? : } + + ) : ( + '' + ), + }, + }} + sx={{ my: 4 }} + /> + + + + + + ); +}; diff --git a/src/features/authentication/components/ForgetPassword/ForgetPasswordContainer.tsx b/src/features/authentication/components/ForgetPassword/ForgetPasswordContainer.tsx new file mode 100644 index 0000000..d95d294 --- /dev/null +++ b/src/features/authentication/components/ForgetPassword/ForgetPasswordContainer.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import type { AuthType } from '../../types/auth-types'; +import { ForgettedPasswordInfo } from './ForgettedPasswordInfo'; +import { ForgetPasswordOtp } from './ForgetPasswordOtp'; +import { ChangePassword } from './ChangePassword'; + +export const ForgetPasswordContainer = () => { + const [forgetPassCurrentStep, setForgetPassCurrentStep] = useState< + 'enterInfo' | 'verifyOtp' | 'setPassword' + >('enterInfo'); + const [forgettedPasswordInfo, setForgettedPasswordInfo] = + useState(''); + const [infoType, setInfoType] = useState('email'); + + const handleSendForgetPassOtp = (value: string) => { + console.log(value); + setForgetPassCurrentStep('verifyOtp'); + }; + + const handleEditInfo = () => { + setForgetPassCurrentStep('enterInfo'); + }; + + const handleOtpVerified = () => { + setForgetPassCurrentStep('setPassword'); + }; + + const handlePasswordChanged = () => { + console.log('changingPasswordTo'); + }; + + return ( + <> + {forgetPassCurrentStep === 'enterInfo' && ( + + )} + + {forgetPassCurrentStep === 'verifyOtp' && ( + + )} + + {forgetPassCurrentStep === 'setPassword' && ( + + )} + + ); +}; diff --git a/src/features/authentication/components/ForgetPassword/ForgetPasswordOtp.tsx b/src/features/authentication/components/ForgetPassword/ForgetPasswordOtp.tsx new file mode 100644 index 0000000..bb67a5f --- /dev/null +++ b/src/features/authentication/components/ForgetPassword/ForgetPasswordOtp.tsx @@ -0,0 +1,168 @@ +import { useTranslation } from 'react-i18next'; +import { Alert, Box, Button, Snackbar, Stack, Typography } from '@mui/material'; +import { Edit2 } from 'iconsax-reactjs'; +import DigitInput from '@/components/components/DigitsInput'; +import type { AuthMode, AuthType } from '../../types/auth-types'; +import { useEffect, useState } from 'react'; +import { Toast } from '@/components/Toast'; +import { AuthenticationCard } from '../AuthenticationCard'; + +interface ForgetPasswordOtpProps { + forgettedPasswordInfo: string; + infoType: AuthType; + onEditInfo: () => void; + onOTPVerified: (otpCode: string) => void; +} + +export function ForgetPasswordOtp({ + forgettedPasswordInfo, + infoType, + onEditInfo, + onOTPVerified, +}: ForgetPasswordOtpProps) { + const [otpCode, setOtpCode] = useState(''); + const [otpDigitInvalid, setOtpDigitInvalid] = useState(false); + const [verifyStatus, setVerifyStatus] = useState<'failed' | 'success'>(); + const [verifyStatusLoading, setVerifyStatusLoading] = + useState(false); + const [verifyAlertOpen, setVerifyAlertOpen] = useState(false); + const { t } = useTranslation('authentication'); + const [resendTimer, setResendTimer] = useState(120); + const [canResend, setCanResend] = useState(false); + const [resendLoading, setResendLoading] = useState(false); + + useEffect(() => { + let interval: NodeJS.Timeout; + if (resendTimer > 0) { + interval = setInterval(() => { + setResendTimer((prev) => prev - 1); + }, 1000); + } else { + setCanResend(true); + } + + return () => clearInterval(interval); + }, [resendTimer]); + + const handleResendOTPCode = () => { + setResendLoading(true); + + // TODO: Call API here instead of settimeout + + setTimeout(() => { + console.log('resended'); + + setResendTimer(120); + setCanResend(false); + setResendLoading(false); + }, 1000); + }; + + const formatTime = (seconds: number) => { + const min = Math.floor(seconds / 60); + const sec = seconds % 60; + return `${min}:${sec.toString().padStart(2, '0')}`; + }; + + const handleDigitInputChange = (value: string[]) => { + const formattedValue = value.filter((char) => char !== '').join(''); + + setOtpCode(formattedValue); + }; + + const handleVerifyOTP = () => { + if (!otpCode || otpCode.length < 4) { + setOtpDigitInvalid(true); + } else { + setOtpDigitInvalid(false); + setVerifyStatusLoading(true); + + // Change setTimeout to api call + setTimeout(() => { + setVerifyAlertOpen(false); + onOTPVerified(otpCode); + setVerifyStatusLoading(false); + }, 1000); + } + }; + + return ( + + + setVerifyAlertOpen(false)} + color={'error'} + > + {t('verify.theVerificationCodeIsIncorrect')} + + + + + {t('forgetPassword.forgetPassword')} + + + + + + + {infoType === 'email' + ? t( + 'forgetPassword.anEmailContainingARecoveryCodeHasBeenSentToThisEmailAddress', + ) + : t( + 'forgetPassword.anCodeContainingARecoveryCodeHasBeenSentToThisPhoneNumber', + )} + + + handleDigitInputChange(value as string[])} + /> + + + + + + {t('verify.resendCodeIn')} + + + + + ); +} diff --git a/src/features/authentication/components/ForgetPassword/ForgettedPasswordInfo.tsx b/src/features/authentication/components/ForgetPassword/ForgettedPasswordInfo.tsx new file mode 100644 index 0000000..64f05e5 --- /dev/null +++ b/src/features/authentication/components/ForgetPassword/ForgettedPasswordInfo.tsx @@ -0,0 +1,151 @@ +import { + Box, + Button, + Paper, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { useRef, useState, type Dispatch } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Google } from 'iconsax-reactjs'; +import { isNumeric } from '@/utils/regexes/isNumeric'; +import type { AuthMode, AuthType } from '../../types/auth-types'; +import { isEmail } from '@/utils/regexes/isEmail'; +import parsePhoneNumberFromString from 'libphonenumber-js'; +import { AuthenticationCard } from '../AuthenticationCard'; +import { CountryCodeSelector } from '../CountryCodeSelector'; + +export interface ForgettedPasswordInfoProps { + forgettedPasswordInfo: string; + setForgettedPasswordInfo: Dispatch; + infoType: AuthType; + setInfoType: Dispatch; + onSendOtp: (value: string) => void; +} + +export function ForgettedPasswordInfo({ + forgettedPasswordInfo, + setForgettedPasswordInfo, + infoType, + setInfoType, + onSendOtp, +}: ForgettedPasswordInfoProps) { + const { t, i18n } = useTranslation('authentication'); + const [countryCode, setCountryCode] = useState('+98'); + const textFieldRef = useRef(null); + const inputRef = useRef(null); + const dir = i18n.dir(); + const [error, setError] = useState(); + const [touched, setTouched] = useState(false); + const inputError: boolean = touched && !!error; + + const handleInputChange = (event: React.ChangeEvent) => { + const newValue = event.target.value; + setForgettedPasswordInfo(newValue); + + // If the new value contains only digits (or is empty), it's a phone number + if (isNumeric(newValue)) { + setInfoType('phone'); + } else { + setInfoType('email'); + } + }; + + const handleBlur = () => { + setTouched(true); + validateInput(forgettedPasswordInfo, infoType); + }; + + const validateInput = (value: string, authType: AuthType) => { + if (!value) { + setError(t('loginForm.thisFieldIsRequired')); + } else if (authType === 'email' && !isEmail(value)) { + setError(t('loginForm.emailIsInvalid')); + } else if (authType === 'phone' && !isPhoneValid(countryCode, value)) { + setError(t('loginForm.phoneNumberIsInvalid')); + } else { + setError(undefined); + } + }; + + const isPhoneValid = (code: string, phone: string) => { + const phoneNumber = parsePhoneNumberFromString(code + phone); + + return phoneNumber && phoneNumber.isValid(); + }; + + const isInputValid = (value: string, authType: AuthType): boolean => { + if (!value) { + return false; + } + + if (authType === 'email' && !isEmail(value)) { + return false; + } + + if (authType === 'phone' && !isPhoneValid(countryCode, value)) { + return false; + } + + return true; + }; + + const handleSubmit = () => { + if (isInputValid(forgettedPasswordInfo, infoType)) { + onSendOtp(forgettedPasswordInfo); + } else { + inputRef.current?.focus(); + validateInput(forgettedPasswordInfo, infoType); + } + }; + + const showAdornment = + infoType === 'phone' && forgettedPasswordInfo.length > 0; + + return ( + + + + {t('forgetPassword.forgetPassword')} + + + {t( + 'forgetPassword.pleaseEnterYourMobileNumberEmailToRecoverYourPassword', + )} + + + + + ), + }, + }} + sx={{ my: 4, mb: 8 }} + /> + + + + + + ); +} diff --git a/src/features/authentication/routes/AuthenticationPage.tsx b/src/features/authentication/routes/AuthenticationPage.tsx index 7cad998..871d641 100644 --- a/src/features/authentication/routes/AuthenticationPage.tsx +++ b/src/features/authentication/routes/AuthenticationPage.tsx @@ -3,6 +3,7 @@ import Logo from '@/components/Logo'; import { Paper } from '@mui/material'; import { useState } from 'react'; import { AuthenticationSteps } from '../components/AuthenticationSteps/AuthenticationSteps'; +import { ForgetPasswordContainer } from '../components/ForgetPassword/ForgetPasswordContainer'; export function AuthenticationPage() { return ( @@ -16,7 +17,7 @@ export function AuthenticationPage() { }} > - + ); } diff --git a/src/utils/regexes/containsNumber.tsx b/src/utils/regexes/containsNumber.tsx new file mode 100644 index 0000000..5ccefc8 --- /dev/null +++ b/src/utils/regexes/containsNumber.tsx @@ -0,0 +1 @@ +export const containsNumber = (value: string) => /\d/.test(value); diff --git a/src/utils/regexes/containsSymbol.tsx b/src/utils/regexes/containsSymbol.tsx new file mode 100644 index 0000000..c3ac510 --- /dev/null +++ b/src/utils/regexes/containsSymbol.tsx @@ -0,0 +1 @@ +export const containsSymbol = (value: string) => /[!@#$%&*\^]/.test(value); diff --git a/src/utils/regexes/hasUpperAndLowerLetter.tsx b/src/utils/regexes/hasUpperAndLowerLetter.tsx new file mode 100644 index 0000000..a29c262 --- /dev/null +++ b/src/utils/regexes/hasUpperAndLowerLetter.tsx @@ -0,0 +1,5 @@ +export const hasUpperAndLowerLetter = (value: string) => { + const hasUpper = /[A-Z]/.test(value); + const hasLower = /[a-z]/.test(value); + return hasUpper && hasLower; +}; diff --git a/src/utils/regexes/least8Chars.tsx b/src/utils/regexes/least8Chars.tsx new file mode 100644 index 0000000..c4c4c8a --- /dev/null +++ b/src/utils/regexes/least8Chars.tsx @@ -0,0 +1 @@ +export const least8Chars = (value: string) => value.length >= 8;