feat: forget password steps and pages added

This commit is contained in:
2025-08-08 00:46:05 +03:30
parent 9191ea31fa
commit a2afdddf04
14 changed files with 665 additions and 4 deletions

View File

@@ -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"
}
}

View File

@@ -35,5 +35,21 @@
"loginPassword": "رمز ورود",
"loginWithOneTimeCode": "ورود با کد یکبار مصرف",
"iForgotMyPassword": "رمز ورودم را فراموش کردم"
},
"forgetPassword": {
"forgetPassword": "فراموشی رمز",
"pleaseEnterYourMobileNumberEmailToRecoverYourPassword": "لطفا برای بازیابی رمز عبور شماره موبایل/ایمیل خود را وارد کنید.",
"anEmailContainingARecoveryCodeHasBeenSentToThisEmailAddress": "یک ایمیل حاوی کد بازیابی به این ایمیل ارسال شد",
"anCodeContainingARecoveryCodeHasBeenSentToThisPhoneNumber": "یک کد بازیابی به این شماره ارسال شد",
"confirm": "تایید",
"changePassword": "تغییر رمز عبور",
"createANewPassword": "یک رمز عبور جدید ایجاد کنید",
"newPassword": "رمز عبور جدید",
"includingANumber": "شامل عدد",
"atLeast8Characters": "حداقل ۸ حرف",
"containsAnUppercaseAndLowercaseLetter": "شامل یک حرف بزرگ و کوچک",
"ContainsASymbol": "شامل علامت (!@#$%&*^)",
"confirmPassword": "تکرار رمز عبور",
"passwordChangedSuccessfully": "رمز عبور با موفقیت تغییر یافت"
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<string>('');
const [confirmPassValue, setConfirmPassValue] = useState<string>('');
const [inputTouched, setInputTouched] = useState<boolean>(false);
const [confirmInputTouched, setConfirmInputTouched] =
useState<boolean>(false);
const [showPassword, setShowPassword] = useState<boolean>(false);
const [showConfirmPassword, setShowConfirmPassword] =
useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const confirmInputRef = useRef<HTMLInputElement>(null);
const [changePasswordLoading, setChangePasswordLoading] =
useState<boolean>(false);
const [changePasswordStatus, setChangePasswordStatus] = useState<
'success' | 'failed'
>();
const [changePassAlertOpen, setChangePassAlertOpen] =
useState<boolean>(false);
const [changePassFailedMessage, setChangePassFailedMessage] =
useState<string>('');
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 (
<AuthenticationCard>
<Toast
open={changePassAlertOpen}
onClose={() => setChangePassAlertOpen(false)}
color={changePasswordStatus === 'failed' ? 'error' : 'success'}
>
{changePasswordStatus === 'failed'
? changePassFailedMessage
: t('forgetPassword.passwordChangedSuccessfully')}
</Toast>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
mb: 0.5,
}}
>
<Typography variant="h5">
{t('forgetPassword.changePassword')}
</Typography>
<Button
variant="outlined"
size="large"
sx={{ textTransform: 'lowercase', width: 'auto' }}
endIcon={<Edit2 />}
onClick={onEditInfo}
>
{forgettedPasswordInfo}
</Button>
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 2 }}>
{t('forgetPassword.createANewPassword')}
</Typography>
<TextField
label={t('forgetPassword.newPassword')}
type={showPassword ? 'text' : 'password'}
value={passValue}
inputRef={inputRef}
onChange={(e) => 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 && (
<TickCircle variant="Bold" color={theme.palette.success.main} />
),
endAdornment: passValue ? (
<IconButton
color="primary"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <Eye /> : <EyeSlash />}
</IconButton>
) : (
''
),
},
}}
sx={{ mt: 4 }}
/>
{!isValidPassword(passValue) && (
<Stack spacing={1} sx={{ mt: 2 }}>
{passwordValidationRules.map((rule) => (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<TickCircle
variant={rule.validator(passValue) ? 'Bold' : 'Linear'}
color={
rule.validator(passValue)
? theme.palette.success.main
: theme.palette.primary.light
}
/>
<Typography variant="body2">{rule.title}</Typography>
</Stack>
))}
</Stack>
)}
<TextField
label={t('forgetPassword.confirmPassword')}
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassValue}
inputRef={confirmInputRef}
onChange={(e) => 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 && (
<TickCircle variant="Bold" color={theme.palette.success.main} />
),
endAdornment: confirmPassValue ? (
<IconButton
color="primary"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showPassword ? <Eye /> : <EyeSlash />}
</IconButton>
) : (
''
),
},
}}
sx={{ my: 4 }}
/>
<Stack spacing={1}>
<Button loading={changePasswordLoading} onClick={handleSubmit}>
{t('forgetPassword.confirm')}
</Button>
</Stack>
</AuthenticationCard>
);
};

View File

@@ -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<string>('');
const [infoType, setInfoType] = useState<AuthType>('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' && (
<ForgettedPasswordInfo
infoType={infoType}
setInfoType={setInfoType}
forgettedPasswordInfo={forgettedPasswordInfo}
setForgettedPasswordInfo={setForgettedPasswordInfo}
onSendOtp={handleSendForgetPassOtp}
/>
)}
{forgetPassCurrentStep === 'verifyOtp' && (
<ForgetPasswordOtp
infoType={infoType}
onEditInfo={handleEditInfo}
forgettedPasswordInfo={forgettedPasswordInfo}
onOTPVerified={handleOtpVerified}
/>
)}
{forgetPassCurrentStep === 'setPassword' && (
<ChangePassword
onEditInfo={handleEditInfo}
forgettedPasswordInfo={forgettedPasswordInfo}
onPasswordChanged={handlePasswordChanged}
/>
)}
</>
);
};

View File

@@ -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<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [verifyStatus, setVerifyStatus] = useState<'failed' | 'success'>();
const [verifyStatusLoading, setVerifyStatusLoading] =
useState<boolean>(false);
const [verifyAlertOpen, setVerifyAlertOpen] = useState<boolean>(false);
const { t } = useTranslation('authentication');
const [resendTimer, setResendTimer] = useState<number>(120);
const [canResend, setCanResend] = useState(false);
const [resendLoading, setResendLoading] = useState<boolean>(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 (
<Stack alignItems="center">
<AuthenticationCard>
<Toast
open={verifyAlertOpen}
onClose={() => setVerifyAlertOpen(false)}
color={'error'}
>
{t('verify.theVerificationCodeIsIncorrect')}
</Toast>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
mb: 0.5,
}}
>
<Typography variant="h5">
{t('forgetPassword.forgetPassword')}
</Typography>
<Button
variant="outlined"
size="large"
sx={{ textTransform: 'lowercase', width: 'auto' }}
endIcon={<Edit2 />}
onClick={onEditInfo}
>
{forgettedPasswordInfo}
</Button>
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{infoType === 'email'
? t(
'forgetPassword.anEmailContainingARecoveryCodeHasBeenSentToThisEmailAddress',
)
: t(
'forgetPassword.anCodeContainingARecoveryCodeHasBeenSentToThisPhoneNumber',
)}
</Typography>
<DigitInput
error={otpDigitInvalid || verifyStatus === 'failed'}
success={verifyStatus === 'success'}
onChange={(value) => handleDigitInputChange(value as string[])}
/>
<Button onClick={handleVerifyOTP} loading={verifyStatusLoading}>
{t('forgetPassword.confirm')}
</Button>
</AuthenticationCard>
<Stack
direction="row"
sx={{
justifyContent: 'center',
alignItems: 'center',
mt: 1.5,
}}
>
<Typography variant="body1">{t('verify.resendCodeIn')}</Typography>
<Button
variant="text"
loading={resendLoading}
sx={{ width: 'auto' }}
onClick={canResend ? handleResendOTPCode : undefined}
>
{canResend && t('verify.resendCode')}
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
</Button>
</Stack>
</Stack>
);
}

View File

@@ -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<string>;
infoType: AuthType;
setInfoType: Dispatch<AuthType>;
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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dir = i18n.dir();
const [error, setError] = useState<string>();
const [touched, setTouched] = useState<boolean>(false);
const inputError: boolean = touched && !!error;
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<AuthenticationCard>
<Stack spacing={1}>
<Typography variant="h5">
{t('forgetPassword.forgetPassword')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t(
'forgetPassword.pleaseEnterYourMobileNumberEmailToRecoverYourPassword',
)}
</Typography>
</Stack>
<TextField
ref={textFieldRef}
inputRef={inputRef}
label={t('loginForm.emailOrPhoneLabel')}
value={forgettedPasswordInfo}
onChange={handleInputChange}
onBlur={handleBlur}
error={inputError}
helperText={inputError ? error : ''}
autoFocus
slotProps={{
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5, paddingX: 0 } },
input: {
endAdornment: (
<CountryCodeSelector
value={countryCode}
onChange={setCountryCode}
show={showAdornment}
menuAnchor={textFieldRef.current}
onCloseFocusRef={inputRef}
/>
),
},
}}
sx={{ my: 4, mb: 8 }}
/>
<Stack spacing={2}>
<Button onClick={handleSubmit}>{t('forgetPassword.confirm')}</Button>
</Stack>
</AuthenticationCard>
);
}

View File

@@ -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() {
}}
>
<Logo />
<AuthenticationSteps />
<ForgetPasswordContainer />
</FlexBox>
);
}

View File

@@ -0,0 +1 @@
export const containsNumber = (value: string) => /\d/.test(value);

View File

@@ -0,0 +1 @@
export const containsSymbol = (value: string) => /[!@#$%&*\^]/.test(value);

View File

@@ -0,0 +1,5 @@
export const hasUpperAndLowerLetter = (value: string) => {
const hasUpper = /[A-Z]/.test(value);
const hasLower = /[a-z]/.test(value);
return hasUpper && hasLower;
};

View File

@@ -0,0 +1 @@
export const least8Chars = (value: string) => value.length >= 8;