feat: login and register, otp verify api calls added

This commit is contained in:
مهرزاد قدرتی
2025-08-09 15:46:51 +03:30
parent 284e60fab3
commit cd86254ce1
12 changed files with 403 additions and 59 deletions

38
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"react-country-flag": "^3.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.6.0",
"react-router": "^7.8.0",
"react-virtuoso": "^4.13.0",
"stylis": "^4.3.6",
"stylis-plugin-rtl": "^2.1.1"
@@ -2404,6 +2405,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -3794,6 +3804,28 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz",
"integrity": "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -3940,6 +3972,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -24,6 +24,7 @@
"react-country-flag": "^3.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.6.0",
"react-router": "^7.8.0",
"react-virtuoso": "^4.13.0",
"stylis": "^4.3.6",
"stylis-plugin-rtl": "^2.1.1"

View File

@@ -2,13 +2,20 @@ import { CssBaseline } from '@mui/material';
import './App.css';
import { LanguageManager } from './components/LanguageManager';
import { AuthenticationPage } from './features/authorization/routes/AuthenticationPage';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router';
function App() {
return (
<>
<CssBaseline />
<LanguageManager />
<AuthenticationPage />
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="auth" />} />
<Route path="/auth" element={<AuthenticationPage />} />
</Routes>
</BrowserRouter>
,
</>
);
}

View File

@@ -1,6 +1,7 @@
import type { ApiResponse } from '@/types/apiResponse';
import type { FetchPromise } from '@/types/fetchPromise';
import type {
CompleteUserInformationRequest,
ConfirmEmailOtpRequest,
ConfirmForgetPassCodeRequest,
ConfirmOtpResponse,
@@ -18,7 +19,7 @@ import type {
SendSmsOtpRequest,
} from '../types/userTypes';
const API_URL = 'https://account.business-harmony.com/api/';
const API_URL = 'https://account.business-harmony.com/api';
export const fetchRequest = <T = ApiResponse>(
url: string,
@@ -91,6 +92,12 @@ export const loginOrSignUpWithGoogle = async (
);
};
export const completeUserInformation = async (
body: CompleteUserInformationRequest,
) => {
return fetchRequest<ApiResponse>('User/CompleteUserInformation', body);
};
export const logOut = async () => {
return fetchRequest<ApiResponse>('User/LogOut', {});
};

View File

@@ -5,61 +5,79 @@ import { OtpVerifyForm } from './OtpVerifyForm';
import { isNumeric } from '@/utils/regexes/isNumeric';
import { CompleteSignUp } from './CompleteSignUp';
import { EnterPasswordForm } from './EnterPasswordForm';
import { getUserStatusByPhoneNumberOrEmail } from '../../api/authorizationAPI';
import { UserStatus } from '../../types/userTypes';
import type { CountryCode } from '@/types/commonTypes';
import { VerifyPhoneNumber } from './VerifyPhoneNumber';
export const AuthenticationSteps = (): JSX.Element => {
const [authMode, setAuthMode] = useState<AuthMode>('register');
const [authType, setAuthType] = useState<AuthType>('phone');
const [currentStep, setCurrentStep] = useState<
| 'emailOrPassword'
| 'emailOrPhone'
| 'verify'
| 'enterPassword'
| 'addPhoneNumber'
| 'addedPhoneNumberVerify'
>('emailOrPassword');
>('emailOrPhone');
const [loginRegisterValue, setLoginRegisterValue] = useState<string>('');
const [countryCode, setCountryCode] = useState<CountryCode>('+98');
const [addedPhoneNumberValue, setAddedPhoneNumberValue] =
useState<string>('');
const handleLoginRegister = (value: string) => {
setLoginRegisterValue(value);
const handleLoginRegister = (value: string, userStatus: UserStatus) => {
setAuthType(isNumeric(value) ? 'phone' : 'email');
// TODO: after api: send to password if it has account and has password
if (true) {
setCurrentStep('enterPassword');
} else {
setCurrentStep('verify');
switch (userStatus) {
case UserStatus.NotRegistered:
setAuthMode('register');
setCurrentStep('verify');
break;
case UserStatus.RegisteredWithoutPassword:
setAuthMode('login');
setCurrentStep('verify');
break;
case UserStatus.RegisteredWithPassword:
setAuthMode('login');
setCurrentStep('enterPassword');
break;
}
};
const handleOTPVerfied = (otpCode: string) => {
if (authMode === 'register' && authType === 'email') {
setCurrentStep('addPhoneNumber');
}
const handleOTPVerfied = (registeredWithoutPhoneNumber: boolean = false) => {
// if (registeredWithoutPhoneNumber) {
// setCurrentStep('addPhoneNumber');
// }
};
const handleEditValue = () => {
setCurrentStep('emailOrPassword');
setCurrentStep('emailOrPhone');
};
const handleCompleteSignUp = (countryCode: string, value: string) => {
setCurrentStep('addedPhoneNumberVerify');
};
const handleCompleteSignUpOTPVerified = (otpCode: string) => {
console.log(otpCode);
const handleCompleteSignUpOTPVerified = () => {
console.log('phoneNumberVerified');
};
const handleCompleteSignUpEditValue = () => {
setCurrentStep('emailOrPassword');
setCurrentStep('emailOrPhone');
};
const handleLoggedInWithPassowrd = () => {};
return (
<>
{currentStep === 'emailOrPassword' && (
{currentStep === 'emailOrPhone' && (
<LoginRegisterForm
countryCode={countryCode}
setCountryCode={setCountryCode}
loginRegisterValue={loginRegisterValue}
setLoginRegisterValue={setLoginRegisterValue}
authType={authType}
@@ -70,6 +88,7 @@ export const AuthenticationSteps = (): JSX.Element => {
{currentStep === 'verify' && (
<OtpVerifyForm
countryCode={countryCode}
onOTPVerified={handleOTPVerfied}
onEditValue={handleEditValue}
authMode={authMode}
@@ -97,12 +116,11 @@ export const AuthenticationSteps = (): JSX.Element => {
)}
{currentStep === 'addedPhoneNumberVerify' && (
<OtpVerifyForm
onOTPVerified={handleCompleteSignUpOTPVerified}
<VerifyPhoneNumber
countryCode={countryCode}
onEditValue={handleCompleteSignUpEditValue}
authMode="login"
authType="phone"
value={addedPhoneNumberValue}
onPhoneNumberVerified={handleCompleteSignUpOTPVerified}
/>
)}
</>

View File

@@ -15,29 +15,38 @@ import { isEmail } from '@/utils/regexes/isEmail';
import parsePhoneNumberFromString from 'libphonenumber-js';
import { AuthenticationCard } from '../AuthenticationCard';
import { CountryCodeSelector } from '../CountryCodeSelector';
import type { UserStatus } from '../../types/userTypes';
import { getUserStatusByPhoneNumberOrEmail } from '../../api/authorizationAPI';
import { Toast } from '@/components/Toast';
import type { CountryCode } from '@/types/commonTypes';
export interface LoginRegisterFormProps {
loginRegisterValue: string;
setLoginRegisterValue: Dispatch<string>;
countryCode: CountryCode;
setCountryCode: Dispatch<CountryCode>;
authType: AuthType;
setAuthType: Dispatch<AuthType>;
onLoginRegisterSubmit: (value: string) => void;
onLoginRegisterSubmit: (value: string, userStatus: UserStatus) => void;
}
export function LoginRegisterForm({
loginRegisterValue,
setLoginRegisterValue,
countryCode,
setCountryCode,
authType,
setAuthType,
onLoginRegisterSubmit,
}: LoginRegisterFormProps) {
const [checkStatusLoading, setCheckStatusLoading] = useState<boolean>(false);
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 [errorMessage, setErrorMessage] = useState<string>();
const inputError: boolean = touched && !!error;
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -91,9 +100,22 @@ export function LoginRegisterForm({
return true;
};
const handleSubmit = () => {
const handleSubmit = async () => {
if (isInputValid(loginRegisterValue, authType)) {
onLoginRegisterSubmit(loginRegisterValue);
setCheckStatusLoading(true);
const result = await getUserStatusByPhoneNumberOrEmail({
phoneNumber:
authType === 'phone' ? countryCode + loginRegisterValue : undefined,
email: authType === 'email' ? loginRegisterValue : undefined,
});
const jsonResult = await result.json();
if (jsonResult.success) {
onLoginRegisterSubmit(loginRegisterValue, jsonResult.userStatus);
} else {
setErrorMessage(jsonResult.message);
}
setCheckStatusLoading(false);
} else {
inputRef.current?.focus();
validateInput(loginRegisterValue, authType);
@@ -104,6 +126,14 @@ export function LoginRegisterForm({
return (
<AuthenticationCard>
<Toast
color="error"
onClose={() => setErrorMessage(undefined)}
open={!!errorMessage}
>
{errorMessage}
</Toast>
<Stack spacing={1}>
<Typography variant="h5">{t('loginForm.title')}</Typography>
<Typography variant="body2" color="text.secondary">
@@ -139,8 +169,14 @@ export function LoginRegisterForm({
/>
<Stack spacing={2}>
<Button onClick={handleSubmit}>{t('loginForm.submitButton')}</Button>
<Button variant="outlined" startIcon={<Google variant="Bold" />}>
<Button loading={checkStatusLoading} onClick={handleSubmit}>
{t('loginForm.submitButton')}
</Button>
<Button
disabled={checkStatusLoading}
variant="outlined"
startIcon={<Google variant="Bold" />}
>
{t('loginForm.loginWithGoogle')}
</Button>
</Stack>

View File

@@ -6,25 +6,37 @@ import type { AuthMode, AuthType } from '../../types/authTypes';
import { useEffect, useState } from 'react';
import { Toast } from '@/components/Toast';
import { AuthenticationCard } from '../AuthenticationCard';
import type { LoginRequest } from '../../types/userTypes';
import { useSearchParams } from 'react-router';
import {
loginOrSignUpWithOtp,
sendEmailOtp,
sendSmsOtp,
} from '../../api/authorizationAPI';
import type { CountryCode } from '@/types/commonTypes';
interface OtpVerifyFormProps {
value: string;
countryCode: CountryCode;
authType: AuthType;
authMode: AuthMode;
onEditValue: () => void;
onOTPVerified: (otpCode: string) => void;
onOTPVerified: (registeredWithoutPhoneNumber: boolean) => void;
}
export function OtpVerifyForm({
value,
countryCode,
authType,
authMode,
onEditValue,
onOTPVerified,
}: OtpVerifyFormProps) {
const [searchParams] = useSearchParams();
const [otpCode, setOtpCode] = useState<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [verifyStatus, setVerifyStatus] = useState<'success' | 'failed'>();
const [errorMessage, setErrorMessage] = useState<string>();
const [verifyStatusLoading, setVerifyStatusLoading] =
useState<boolean>(false);
const [verifyAlertOpen, setVerifyAlertOpen] = useState<boolean>(false);
@@ -46,18 +58,18 @@ export function OtpVerifyForm({
return () => clearInterval(interval);
}, [resendTimer]);
const handleResendOTPCode = () => {
const handleResendOTPCode = async () => {
setResendLoading(true);
// TODO: Call API here instead of settimeout
if (authType === 'phone') {
await sendSmsOtp({ phoneNumber: countryCode + value });
} else {
await sendEmailOtp({ email: value });
}
setTimeout(() => {
console.log('resended');
setResendTimer(120);
setCanResend(false);
setResendLoading(false);
}, 1000);
setResendTimer(120);
setCanResend(false);
setResendLoading(false);
};
const formatTime = (seconds: number) => {
@@ -72,7 +84,7 @@ export function OtpVerifyForm({
setOtpCode(formattedValue);
};
const handleVerifyOTP = () => {
const handleVerifyOTP = async () => {
if (!otpCode || otpCode.length < 4) {
setOtpDigitInvalid(true);
} else {
@@ -80,12 +92,26 @@ export function OtpVerifyForm({
setVerifyStatusLoading(true);
// Change setTimeout to api call
setTimeout(() => {
setVerifyAlertOpen(true);
const loginRequest: LoginRequest = {
otpCode: otpCode,
phoneNumber: authType === 'phone' ? countryCode + value : undefined,
email: authType === 'email' ? value : undefined,
returnUrl: searchParams.get('returnUrl') ?? '/',
};
const result = await loginOrSignUpWithOtp(loginRequest);
const jsonRes = await result.json();
if (jsonRes.success) {
setVerifyStatus('success');
onOTPVerified(otpCode);
setVerifyStatusLoading(false);
}, 1000);
onOTPVerified(jsonRes.registeredWithOutPhoneNumber);
} else {
setVerifyStatus('failed');
setErrorMessage(jsonRes.message);
}
setVerifyAlertOpen(true);
setVerifyStatusLoading(false);
}
};
@@ -113,7 +139,7 @@ export function OtpVerifyForm({
const verifyAlertMessage = (): string => {
if (verifyStatus === 'failed') {
return t('verify.theVerificationCodeIsIncorrect');
return errorMessage ?? t('verify.theVerificationCodeIsIncorrect');
} else if (verifyStatus === 'success' && authMode === 'register') {
return t('verify.youHaveSuccessfullySignedIn');
} else if (verifyStatus === 'success' && authMode === 'login') {
@@ -153,7 +179,7 @@ export function OtpVerifyForm({
endIcon={<Edit2 />}
onClick={onEditValue}
>
{value}
{authType === 'phone' ? countryCode + value : value}
</Button>
</Box>

View File

@@ -0,0 +1,191 @@
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/authTypes';
import { useEffect, useState } from 'react';
import { Toast } from '@/components/Toast';
import { AuthenticationCard } from '../AuthenticationCard';
import type { LoginRequest } from '../../types/userTypes';
import { useSearchParams } from 'react-router';
import {
loginOrSignUpWithOtp,
sendEmailOtp,
sendSmsOtp,
} from '../../api/authorizationAPI';
import type { CountryCode } from '@/types/commonTypes';
interface VerifyPhoneNumberProps {
value: string;
countryCode: CountryCode;
onEditValue: () => void;
onPhoneNumberVerified: () => void;
}
export function VerifyPhoneNumber({
value,
countryCode,
onEditValue,
onPhoneNumberVerified,
}: VerifyPhoneNumberProps) {
const [searchParams] = useSearchParams();
const [otpCode, setOtpCode] = useState<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [verifyStatus, setVerifyStatus] = useState<'success' | 'failed'>();
const [errorMessage, setErrorMessage] = useState<string>();
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 = async () => {
setResendLoading(true);
await sendSmsOtp({ phoneNumber: countryCode + value });
setResendTimer(120);
setCanResend(false);
setResendLoading(false);
};
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 = async () => {
if (!otpCode || otpCode.length < 4) {
setOtpDigitInvalid(true);
} else {
setOtpDigitInvalid(false);
setVerifyStatusLoading(true);
// Change setTimeout to api call
// const loginRequest: LoginRequest = {
// otpCode: otpCode,
// phoneNumber: authType === 'phone' ? countryCode + value : undefined,
// email: authType === 'email' ? value : undefined,
// returnUrl: searchParams.get('returnUrl') ?? '/',
// };
// const result = await loginOrSignUpWithOtp(loginRequest);
// const jsonRes = await result.json();
// if (jsonRes.success) {
// setVerifyStatus('success');
// onOTPVerified(jsonRes.registeredWithOutPhoneNumber);
// } else {
// setVerifyStatus('failed');
// setErrorMessage(jsonRes.message);
// }
setVerifyAlertOpen(true);
setVerifyStatusLoading(false);
}
};
const verifyAlertMessage = (): string => {
if (verifyStatus === 'failed') {
return errorMessage ?? t('verify.theVerificationCodeIsIncorrect');
} else {
return t('verify.youHaveSuccessfullyLoggedIn');
}
};
return (
<Stack alignItems="center">
<AuthenticationCard>
<Toast
open={verifyAlertOpen}
onClose={() => setVerifyAlertOpen(false)}
color={verifyStatus === 'failed' ? 'error' : 'success'}
>
{verifyAlertMessage()}
</Toast>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
mb: 0.5,
}}
>
<Typography variant="h5">{t('verify.verify')}</Typography>
<Button
variant="outlined"
size="large"
sx={{ textTransform: 'lowercase', width: 'auto' }}
endIcon={<Edit2 />}
onClick={onEditValue}
>
{countryCode + value}
</Button>
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{t(
'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt',
)}
</Typography>
<DigitInput
error={otpDigitInvalid || verifyStatus === 'failed'}
success={verifyStatus === 'success'}
onChange={(value) => handleDigitInputChange(value as string[])}
/>
<Button onClick={handleVerifyOTP} loading={verifyStatusLoading}>
{t('verify.confirmAndLogin')}
</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

@@ -15,10 +15,11 @@ import ReactCountryFlag from 'react-country-flag';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import { countries, type Country } from '../data/countries';
import type { CountryCode } from '@/types/commonTypes';
interface CountryCodeSelectorProps {
show: boolean;
value: string;
onChange: (newValue: string) => void;
value: CountryCode;
onChange: (newValue: CountryCode) => void;
menuAnchor: HTMLElement | null;
onCloseFocusRef: RefObject<HTMLInputElement | null>;
}

View File

@@ -1,13 +1,9 @@
export interface Country {
code: string;
label: string;
phone: string;
}
import type { CountryCode } from '@/types/commonTypes';
export interface Country {
code: string;
label: string;
phone: string;
phone: CountryCode;
}
export const countries: readonly Country[] = [

View File

@@ -14,9 +14,9 @@ export interface GetUserStatusByPhoneNumberOrEmailResponse extends ApiResponse {
export enum UserStatus {
None = 0,
Value1 = 1,
Value2 = 2,
Value3 = 3,
RegisteredWithPassword = 1,
RegisteredWithoutPassword = 2,
NotRegistered = 3,
}
// LoginOrSignUpWithOtp
@@ -104,3 +104,24 @@ export interface LoginOrSignUpWithGoogleResponse extends ApiResponse {
completedUserInformation: boolean;
returnUrl: string;
}
// CompleteUserInformation
export interface CompleteUserInformationRequest {
firstName?: string;
lastName?: string;
gender?: Gender;
nationalCode?: string;
savePassword?: boolean;
password?: string;
saveEmail?: boolean;
email?: string;
birthDate?: string;
countryCode?: string;
userId?: GUID;
}
export enum Gender {
Male = 1,
Female = 2,
}

View File

@@ -1 +1,3 @@
export type GUID = `${string}-${string}-${string}-${string}-${string}`;
export type CountryCode = `+${number}`;