feat: layout padding and scroll added
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { useState, type JSX } from 'react';
|
||||
import { LoginRegisterForm } from './LoginRegiserForm';
|
||||
import type { AuthMode, AuthStep, AuthType } from '../../types/authTypes';
|
||||
import { OtpVerifyForm } from './OtpVerifyForm';
|
||||
import { isNumeric } from '@/utils/regexes/isNumeric';
|
||||
import { CompleteSignUp } from './CompleteSignUp';
|
||||
import { EnterPasswordForm } from './EnterPasswordForm';
|
||||
import { UserStatus } from '../../types/userTypes';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { VerifyPhoneNumber } from './VerifyPhoneNumber';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export const AuthenticationSteps = (): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const authReturnUrl: string | null = searchParams.get('returnUrl');
|
||||
const authReturnUrlOrDefault: string =
|
||||
authReturnUrl ?? import.meta.env.VITE_DEFUALT_AUTH_RETURN_URL;
|
||||
const [authMode, setAuthMode] = useState<AuthMode>('register');
|
||||
const [authType, setAuthType] = useState<AuthType>('phone');
|
||||
const [currentStep, setCurrentStep] = useState<AuthStep>('emailOrPhone');
|
||||
const [loginRegisterValue, setLoginRegisterValue] = useState<string>('');
|
||||
const [countryCode, setCountryCode] = useState<CountryCode>('+98');
|
||||
const [addPhoneCountryCode, setAddPhoneCountryCode] =
|
||||
useState<CountryCode>('+98');
|
||||
const [addedPhoneNumberValue, setAddedPhoneNumberValue] =
|
||||
useState<string>('');
|
||||
|
||||
const handleLoginRegister = (value: string, userStatus: UserStatus) => {
|
||||
setAuthType(isNumeric(value) ? 'phone' : 'email');
|
||||
|
||||
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 handleUserLoggedIn = () => {
|
||||
redirectToReturnUrl();
|
||||
};
|
||||
|
||||
const handleConfrimPhoneNumber = () => {
|
||||
setCurrentStep('addPhoneNumber');
|
||||
};
|
||||
|
||||
const handlePhoneNumberVerified = () => {
|
||||
if (authReturnUrl) {
|
||||
navigate(`/signup?returnUrl=${authReturnUrl}`);
|
||||
} else {
|
||||
navigate(`/signup`);
|
||||
}
|
||||
};
|
||||
|
||||
const redirectToReturnUrl = () => {
|
||||
if (!authReturnUrl) {
|
||||
navigate(import.meta.env.VITE_DEFUALT_AUTH_RETURN_URL);
|
||||
} else {
|
||||
if (authMode === 'register') {
|
||||
navigate(`/account-created?returnUrl=${authReturnUrl}`);
|
||||
} else {
|
||||
location.href = authReturnUrl;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentStep === 'emailOrPhone' && (
|
||||
<LoginRegisterForm
|
||||
authReturnUrl={authReturnUrlOrDefault}
|
||||
onGoogleAuthenticated={handleUserLoggedIn}
|
||||
countryCode={countryCode}
|
||||
setCountryCode={setCountryCode}
|
||||
loginRegisterValue={loginRegisterValue}
|
||||
setLoginRegisterValue={setLoginRegisterValue}
|
||||
authType={authType}
|
||||
setAuthType={setAuthType}
|
||||
onLoginRegisterSubmit={handleLoginRegister}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'verify' && (
|
||||
<OtpVerifyForm
|
||||
onVerifyPhoneNumber={handleConfrimPhoneNumber}
|
||||
authReturnUrl={authReturnUrlOrDefault}
|
||||
countryCode={countryCode}
|
||||
onOTPVerified={handleUserLoggedIn}
|
||||
onEditValue={() => setCurrentStep('emailOrPhone')}
|
||||
authMode={authMode}
|
||||
authType={authType}
|
||||
value={loginRegisterValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'enterPassword' && (
|
||||
<EnterPasswordForm
|
||||
authReturnUrl={authReturnUrlOrDefault}
|
||||
loginRegisterValue={loginRegisterValue}
|
||||
countryCode={countryCode}
|
||||
authType={authType}
|
||||
onLoggedIn={handleUserLoggedIn}
|
||||
onEditValue={() => setCurrentStep('emailOrPhone')}
|
||||
onLoginWithOTP={() => setCurrentStep('verify')}
|
||||
emailOrPhone={loginRegisterValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'addPhoneNumber' && (
|
||||
<CompleteSignUp
|
||||
countryCode={addPhoneCountryCode}
|
||||
setCountryCode={setAddPhoneCountryCode}
|
||||
value={addedPhoneNumberValue}
|
||||
setValue={setAddedPhoneNumberValue}
|
||||
email={loginRegisterValue}
|
||||
onCompleteSignUp={() => setCurrentStep('addedPhoneNumberVerify')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'addedPhoneNumberVerify' && (
|
||||
<VerifyPhoneNumber
|
||||
countryCode={countryCode}
|
||||
onEditValue={() => setCurrentStep('addPhoneNumber')}
|
||||
value={addedPhoneNumberValue}
|
||||
onPhoneNumberVerified={handlePhoneNumberVerified}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Button, TextField, Typography } from '@mui/material';
|
||||
import parsePhoneNumberFromString from 'libphonenumber-js';
|
||||
import { useRef, useState, type Dispatch } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { CountryCodeSelector } from '../CountryCodeSelector';
|
||||
import { sendSmsOtp } from '../../api/authorizationAPI';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
|
||||
export interface CompleteSignUpProps {
|
||||
email: string;
|
||||
value: string;
|
||||
setValue: Dispatch<string>;
|
||||
countryCode: CountryCode;
|
||||
setCountryCode: Dispatch<CountryCode>;
|
||||
onCompleteSignUp: (countryCode: string, value: string) => void;
|
||||
}
|
||||
|
||||
export const CompleteSignUp = ({
|
||||
email,
|
||||
value,
|
||||
setValue,
|
||||
countryCode,
|
||||
setCountryCode,
|
||||
onCompleteSignUp,
|
||||
}: CompleteSignUpProps) => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const [error, setError] = useState<string>();
|
||||
const textFieldRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [touched, setTouched] = useState<boolean>(false);
|
||||
const inputError: boolean = touched && !!error;
|
||||
const { loading: sendSmsLoading, execute: sendSmsCall } = useApi(sendSmsOtp);
|
||||
|
||||
const isPhoneValid = (code: string, phone: string) => {
|
||||
const phoneNumber = parsePhoneNumberFromString(code + phone);
|
||||
|
||||
return phoneNumber && phoneNumber.isValid();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setTouched(true);
|
||||
|
||||
handleValueError();
|
||||
};
|
||||
|
||||
const handleValueError = () => {
|
||||
if (!value) {
|
||||
setError(t('loginForm.thisFieldIsRequired'));
|
||||
}
|
||||
if (!isPhoneValid(countryCode, value)) {
|
||||
setError(t('loginForm.phoneNumberIsInvalid'));
|
||||
} else {
|
||||
setError(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteSignUp = async () => {
|
||||
handleValueError();
|
||||
|
||||
if (!value || !isPhoneValid(countryCode, value)) {
|
||||
inputRef.current?.focus();
|
||||
} else {
|
||||
await sendSmsCall({ phoneNumber: countryCode + value });
|
||||
onCompleteSignUp(countryCode, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Typography variant="h5" sx={{ mb: 0.5 }}>
|
||||
{t('completeSignUp.completeSignUp')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 2 }}>
|
||||
{t(
|
||||
'completeSignUp.emailHasBeenSuccessfullyVerifiedPleaseEnterYourContactNumberToContinue',
|
||||
{ email },
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
ref={textFieldRef}
|
||||
inputRef={inputRef}
|
||||
label={t('completeSignUp.phoneNumber')}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
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={true}
|
||||
menuAnchor={textFieldRef.current}
|
||||
onCloseFocusRef={inputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4 }}
|
||||
/>
|
||||
|
||||
<Button loading={sendSmsLoading} onClick={handleCompleteSignUp}>
|
||||
{t('verify.confirmAndContinue')}
|
||||
</Button>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { ArrowLeft, Edit2, Eye, EyeSlash } from 'iconsax-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { AuthType } from '../../types/authTypes';
|
||||
import type { CountryCode, GUID } from '@/types/commonTypes';
|
||||
import {
|
||||
loginWithPassword,
|
||||
sendEmailOtp,
|
||||
sendSmsOtp,
|
||||
} from '../../api/authorizationAPI';
|
||||
import type { PasswordLoginRequest } from '../../types/userTypes';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { generateTokenWithPassword } from '../../api/identityAPI';
|
||||
|
||||
export interface EnterPasswordFormProps {
|
||||
onEditValue: () => void;
|
||||
onLoginWithOTP: () => void;
|
||||
onLoggedIn: (userId: GUID) => void;
|
||||
emailOrPhone: string;
|
||||
authType: AuthType;
|
||||
loginRegisterValue: string;
|
||||
countryCode: CountryCode;
|
||||
authReturnUrl: string;
|
||||
}
|
||||
|
||||
export const EnterPasswordForm = ({
|
||||
onEditValue,
|
||||
onLoginWithOTP,
|
||||
onLoggedIn,
|
||||
emailOrPhone,
|
||||
authType,
|
||||
loginRegisterValue,
|
||||
countryCode,
|
||||
authReturnUrl,
|
||||
}: EnterPasswordFormProps) => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const [passValue, setPassValue] = useState<string>('');
|
||||
const [inputTouched, setInputTouched] = useState<boolean>(false);
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
const { loading: smsResendLoading, execute: smsResendCall } =
|
||||
useApi(sendSmsOtp);
|
||||
const { loading: emailResendLoading, execute: emailResendCall } =
|
||||
useApi(sendEmailOtp);
|
||||
const { loading: loginWithPassLoading, execute: loginWithPassCall } =
|
||||
useApi(loginWithPassword);
|
||||
const auth = useAuth();
|
||||
|
||||
const handleBlur = () => {
|
||||
setInputTouched(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!passValue) {
|
||||
inputRef.current?.focus();
|
||||
} else {
|
||||
const apiRequest: PasswordLoginRequest = {
|
||||
phoneNumber:
|
||||
authType === 'phone' ? countryCode + loginRegisterValue : undefined,
|
||||
email: authType === 'email' ? loginRegisterValue : undefined,
|
||||
password: passValue,
|
||||
returnUrl: authReturnUrl,
|
||||
};
|
||||
const res = await loginWithPassCall(apiRequest);
|
||||
|
||||
if (!res) return;
|
||||
|
||||
if (res.success) {
|
||||
const tokenRes = await generateTokenWithPassword({
|
||||
username: apiRequest.email ?? (apiRequest.phoneNumber as string),
|
||||
password: apiRequest.password,
|
||||
});
|
||||
auth.login({
|
||||
...tokenRes.data,
|
||||
});
|
||||
|
||||
onLoggedIn(res.userId);
|
||||
toast({
|
||||
message: t('verify.youHaveSuccessfullyLoggedIn'),
|
||||
severity: 'success',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
message: res.message,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginWithOtp = async () => {
|
||||
if (authType === 'phone') {
|
||||
await smsResendCall({ phoneNumber: countryCode + loginRegisterValue });
|
||||
} else {
|
||||
await emailResendCall({ email: loginRegisterValue });
|
||||
}
|
||||
|
||||
onLoginWithOTP();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">
|
||||
{t('enterPassword.loginWithPassword')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{ width: 'auto' }}
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
onClick={onEditValue}
|
||||
>
|
||||
{emailOrPhone}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 2 }}>
|
||||
{t('enterPassword.enterThePasswordYouSetForYourAccount')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('enterPassword.loginPassword')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={passValue}
|
||||
inputRef={inputRef}
|
||||
onChange={(e) => setPassValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
error={!passValue && inputTouched}
|
||||
helperText={
|
||||
!passValue && inputTouched ? t('loginForm.thisFieldIsRequired') : ''
|
||||
}
|
||||
autoFocus
|
||||
slotProps={{
|
||||
htmlInput: { sx: { lineHeight: 1.5 } },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<Icon Component={Eye} />
|
||||
) : (
|
||||
<Icon Component={EyeSlash} />
|
||||
)}
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleLoginWithOtp}
|
||||
sx={{ width: 'auto', mb: 2 }}
|
||||
variant="text"
|
||||
loading={emailResendLoading || smsResendLoading}
|
||||
endIcon={<Icon Component={ArrowLeft} />}
|
||||
>
|
||||
{t('enterPassword.loginWithOneTimeCode')}
|
||||
</Button>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Button loading={loginWithPassLoading} onClick={handleSubmit}>
|
||||
{t('verify.confirmAndLogin')}
|
||||
</Button>
|
||||
<Link to="/forget-password">
|
||||
<Button variant="text">{t('enterPassword.iForgotMyPassword')}</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Button } from '@mui/material';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
GoogleCodeClientResponse,
|
||||
LoginOrSignUpWithGoogleRequest,
|
||||
} from '../../types/userTypes';
|
||||
import { loginOrSignUpWithGoogle } from '../../api/authorizationAPI';
|
||||
import type { GUID } from '@/types/commonTypes';
|
||||
import { Google } from 'iconsax-react';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { generateTokenWithGoogle } from '../../api/identityAPI';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export interface GoogleAuthenticationProps {
|
||||
disabled: boolean;
|
||||
authReturnUrl: string;
|
||||
onGoogleAuthenticated: (userId: GUID) => void;
|
||||
}
|
||||
|
||||
export const GoogleAuthentication = ({
|
||||
disabled,
|
||||
authReturnUrl,
|
||||
onGoogleAuthenticated,
|
||||
}: GoogleAuthenticationProps) => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const { loading: loginWithGoogleLoading, execute: loginWithGoogleCall } =
|
||||
useApi(loginOrSignUpWithGoogle);
|
||||
const toast = useToast();
|
||||
const clientRef = useRef<any>(null);
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://accounts.google.com/gsi/client';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
script.onload = () => {
|
||||
clientRef.current = google.accounts.oauth2.initCodeClient({
|
||||
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
|
||||
scope: 'openid email profile',
|
||||
ux_mode: 'popup',
|
||||
response_type: 'code',
|
||||
callback: async (resp: GoogleCodeClientResponse) => {
|
||||
const apiRequest: LoginOrSignUpWithGoogleRequest = {
|
||||
idToken: resp.id_token,
|
||||
returnUrl: authReturnUrl,
|
||||
};
|
||||
const res = await loginWithGoogleCall(apiRequest);
|
||||
|
||||
if (!res) return;
|
||||
|
||||
if (res.success) {
|
||||
const tokenRes = await generateTokenWithGoogle(apiRequest);
|
||||
auth.login({
|
||||
...tokenRes.data,
|
||||
});
|
||||
|
||||
onGoogleAuthenticated(res.userId);
|
||||
} else {
|
||||
toast({
|
||||
message: t('loginForm.googleAuthenticationFailed'),
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.requestCode();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={disabled}
|
||||
loading={loginWithGoogleLoading}
|
||||
variant="outlined"
|
||||
startIcon={<Icon Component={Google} variant="Bold" />}
|
||||
>
|
||||
{t('loginForm.loginWithGoogle')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Button, Stack, TextField, Typography } from '@mui/material';
|
||||
import { useRef, useState, type Dispatch } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isNumeric } from '@/utils/regexes/isNumeric';
|
||||
import type { AuthType } from '../../types/authTypes';
|
||||
import { isEmail } from '@/utils/regexes/isEmail';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { CountryCodeSelector } from '../CountryCodeSelector';
|
||||
import type { UserStatus } from '../../types/userTypes';
|
||||
import { getUserStatusByPhoneNumberOrEmail } from '../../api/authorizationAPI';
|
||||
import type { CountryCode, GUID } from '@/types/commonTypes';
|
||||
import { GoogleAuthentication } from './GoogleAuthentication';
|
||||
import { isPhoneNumber } from '@/utils/regexes/isValidPhoneNumber';
|
||||
import { useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
|
||||
export interface LoginRegisterFormProps {
|
||||
loginRegisterValue: string;
|
||||
setLoginRegisterValue: Dispatch<string>;
|
||||
countryCode: CountryCode;
|
||||
setCountryCode: Dispatch<CountryCode>;
|
||||
authType: AuthType;
|
||||
setAuthType: Dispatch<AuthType>;
|
||||
onLoginRegisterSubmit: (value: string, userStatus: UserStatus) => void;
|
||||
authReturnUrl: string;
|
||||
onGoogleAuthenticated: (userId: GUID) => void;
|
||||
}
|
||||
|
||||
export function LoginRegisterForm({
|
||||
loginRegisterValue,
|
||||
setLoginRegisterValue,
|
||||
countryCode,
|
||||
setCountryCode,
|
||||
authType,
|
||||
setAuthType,
|
||||
onLoginRegisterSubmit,
|
||||
authReturnUrl,
|
||||
onGoogleAuthenticated,
|
||||
}: LoginRegisterFormProps) {
|
||||
const { t } = useTranslation('authentication');
|
||||
const textFieldRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState<string>();
|
||||
const [touched, setTouched] = useState<boolean>(false);
|
||||
const inputError: boolean = touched && !!error;
|
||||
const toast = useToast();
|
||||
const { loading: userStatusLoading, execute: execUserStatus } = useApi(
|
||||
getUserStatusByPhoneNumberOrEmail,
|
||||
);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
setLoginRegisterValue(newValue);
|
||||
|
||||
// If the new value contains only digits (or is empty), it's a phone number
|
||||
if (isNumeric(newValue)) {
|
||||
setAuthType('phone');
|
||||
} else {
|
||||
setAuthType('email');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setTouched(true);
|
||||
validateInput(loginRegisterValue, authType);
|
||||
};
|
||||
|
||||
const validateInput = (
|
||||
value: string,
|
||||
authType: AuthType,
|
||||
setErrors: boolean = true,
|
||||
): boolean => {
|
||||
if (!value) {
|
||||
if (setErrors) setError(t('loginForm.thisFieldIsRequired'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authType === 'email' && !isEmail(value)) {
|
||||
if (setErrors) setError(t('loginForm.emailIsInvalid'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authType === 'phone' && !isPhoneNumber(countryCode, value)) {
|
||||
if (setErrors) setError(t('loginForm.phoneNumberIsInvalid'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (setErrors) setError(undefined);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (validateInput(loginRegisterValue, authType, false)) {
|
||||
const res = await execUserStatus({
|
||||
phoneNumber:
|
||||
authType === 'phone' ? countryCode + loginRegisterValue : undefined,
|
||||
email: authType === 'email' ? loginRegisterValue : undefined,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
onLoginRegisterSubmit(loginRegisterValue, res.userStatus);
|
||||
} else {
|
||||
toast({ message: res.message, severity: 'error' });
|
||||
}
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
validateInput(loginRegisterValue, authType);
|
||||
}
|
||||
};
|
||||
|
||||
const showAdornment = authType === 'phone' && loginRegisterValue.length > 0;
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">{t('loginForm.title')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('loginForm.description')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
ref={textFieldRef}
|
||||
inputRef={inputRef}
|
||||
label={t('loginForm.emailOrPhoneLabel')}
|
||||
value={loginRegisterValue}
|
||||
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 }}
|
||||
/>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Button loading={userStatusLoading} onClick={handleSubmit}>
|
||||
{t('loginForm.submitButton')}
|
||||
</Button>
|
||||
|
||||
<GoogleAuthentication
|
||||
authReturnUrl={authReturnUrl}
|
||||
onGoogleAuthenticated={onGoogleAuthenticated}
|
||||
disabled={userStatusLoading}
|
||||
/>
|
||||
</Stack>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { Edit2 } from 'iconsax-react';
|
||||
import DigitInput from '@/components/DigitsInput';
|
||||
import type { AuthMode, AuthType } from '../../types/authTypes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import type { LoginRequest } from '../../types/userTypes';
|
||||
import {
|
||||
loginOrSignUpWithOtp,
|
||||
sendEmailOtp,
|
||||
sendSmsOtp,
|
||||
} from '../../api/authorizationAPI';
|
||||
import type { CountryCode, GUID } from '@/types/commonTypes';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { generateTokenWithOtp } from '../../api/identityAPI';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface OtpVerifyFormProps {
|
||||
value: string;
|
||||
countryCode: CountryCode;
|
||||
authType: AuthType;
|
||||
authMode: AuthMode;
|
||||
onEditValue: () => void;
|
||||
onOTPVerified: (userId: GUID) => void;
|
||||
onVerifyPhoneNumber: (userId: GUID) => void;
|
||||
authReturnUrl: string;
|
||||
}
|
||||
|
||||
export function OtpVerifyForm({
|
||||
value,
|
||||
countryCode,
|
||||
authType,
|
||||
authMode,
|
||||
onEditValue,
|
||||
onOTPVerified,
|
||||
onVerifyPhoneNumber,
|
||||
authReturnUrl,
|
||||
}: OtpVerifyFormProps) {
|
||||
const [otpCode, setOtpCode] = useState<string>('');
|
||||
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
|
||||
const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>();
|
||||
const { t } = useTranslation('authentication');
|
||||
const [resendTimer, setResendTimer] = useState<number>(120);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
const toast = useToast();
|
||||
const { loading: smsResendLoading, execute: smsResendCall } =
|
||||
useApi(sendSmsOtp);
|
||||
const { loading: emailResendLoading, execute: emailResendCall } =
|
||||
useApi(sendEmailOtp);
|
||||
const { loading: loginSignUpLoading, execute: loginSignUpCall } =
|
||||
useApi(loginOrSignUpWithOtp);
|
||||
const auth = useAuth();
|
||||
|
||||
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 () => {
|
||||
if (authType === 'phone') {
|
||||
await smsResendCall({ phoneNumber: countryCode + value });
|
||||
} else {
|
||||
await emailResendCall({ email: value });
|
||||
}
|
||||
|
||||
setResendTimer(120);
|
||||
setCanResend(false);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = seconds % 60;
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleVerifyOTP = () => {
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
setOtpDigitInvalid(true);
|
||||
} else {
|
||||
handleLoginOrSignUp();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginOrSignUp = async () => {
|
||||
setOtpDigitInvalid(false);
|
||||
|
||||
const loginRequest: LoginRequest = {
|
||||
otpCode: otpCode,
|
||||
phoneNumber: authType === 'phone' ? countryCode + value : undefined,
|
||||
email: authType === 'email' ? value : undefined,
|
||||
returnUrl: authReturnUrl,
|
||||
};
|
||||
const res = await loginSignUpCall(loginRequest);
|
||||
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
setIsStatusSuccess(true);
|
||||
|
||||
const tokenRes = await generateTokenWithOtp({
|
||||
email: loginRequest.email,
|
||||
phonenumber: loginRequest.phoneNumber,
|
||||
otp: loginRequest.otpCode,
|
||||
});
|
||||
auth.login({
|
||||
...tokenRes.data,
|
||||
});
|
||||
|
||||
if (res.registeredWithOutPhoneNumber) {
|
||||
onVerifyPhoneNumber(res.userId);
|
||||
} else {
|
||||
onOTPVerified(res.userId);
|
||||
}
|
||||
|
||||
toast({
|
||||
message:
|
||||
authMode === 'login'
|
||||
? t('verify.youHaveSuccessfullyLoggedIn')
|
||||
: t('verify.youHaveSuccessfullySignedIn'),
|
||||
severity: 'success',
|
||||
});
|
||||
} else {
|
||||
setIsStatusSuccess(false);
|
||||
|
||||
toast({
|
||||
message: res.message,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const otpMessageFn = (): string => {
|
||||
if (authType === 'phone' && authMode === 'login') {
|
||||
return t(
|
||||
'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt',
|
||||
);
|
||||
} else if (authType === 'phone' && authMode === 'register') {
|
||||
return t(
|
||||
'verify.thereIsNoAccountWithThisNumberA4DigitVerificationCodeHasBeenSentToThisNumberToCreateANewAccount',
|
||||
);
|
||||
} else if (authType === 'email' && authMode === 'login') {
|
||||
return t(
|
||||
'verify.a4digitVerificationCodeHasBeenSentToYourEmailAddressPleaseEnterIt',
|
||||
);
|
||||
} else if (authType === 'email' && authMode === 'register') {
|
||||
return t(
|
||||
'verify.thereIsNoAccountWithThisEmailAddressA4DigitVerificationCodeHasBeenSentToThisEmailAddressToCreateANewAccount',
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const otpMessage = otpMessageFn();
|
||||
|
||||
return (
|
||||
<Stack alignItems="center">
|
||||
<AuthenticationCard>
|
||||
<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={<Icon Component={Edit2} />}
|
||||
onClick={onEditValue}
|
||||
>
|
||||
{authType === 'phone' ? countryCode + value : value}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
|
||||
{otpMessage}
|
||||
</Typography>
|
||||
|
||||
<DigitInput
|
||||
error={otpDigitInvalid || isStatusSuccess === false}
|
||||
success={isStatusSuccess === true}
|
||||
onChange={(value) => setOtpCode(value)}
|
||||
/>
|
||||
|
||||
<Button onClick={handleVerifyOTP} loading={loginSignUpLoading}>
|
||||
{authMode === 'register'
|
||||
? t('verify.confirmAndContinue')
|
||||
: 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={smsResendLoading || emailResendLoading}
|
||||
sx={{ width: 'auto' }}
|
||||
onClick={canResend ? handleResendOTPCode : undefined}
|
||||
>
|
||||
{canResend && t('verify.resendCode')}
|
||||
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { Edit2 } from 'iconsax-react';
|
||||
import DigitInput from '@/components/DigitsInput';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import type { ConfirmSmsOtpRequest } from '../../types/userTypes';
|
||||
import { confirmSmsOtp, sendSmsOtp } from '../../api/authorizationAPI';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
|
||||
interface VerifyPhoneNumberProps {
|
||||
value: string;
|
||||
countryCode: CountryCode;
|
||||
onEditValue: () => void;
|
||||
onPhoneNumberVerified: () => void;
|
||||
}
|
||||
|
||||
export function VerifyPhoneNumber({
|
||||
value,
|
||||
countryCode,
|
||||
onEditValue,
|
||||
onPhoneNumberVerified,
|
||||
}: VerifyPhoneNumberProps) {
|
||||
const [otpCode, setOtpCode] = useState<string>('');
|
||||
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
|
||||
const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>();
|
||||
const { t } = useTranslation('authentication');
|
||||
const [resendTimer, setResendTimer] = useState<number>(120);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
const toast = useToast();
|
||||
const { loading: smsResendLoading, execute: smsResendCall } =
|
||||
useApi(sendSmsOtp);
|
||||
const { loading: confirmSmsOtpLoading, execute: confirmSmsOtpCall } =
|
||||
useApi(confirmSmsOtp);
|
||||
|
||||
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 () => {
|
||||
await smsResendCall({ phoneNumber: countryCode + value });
|
||||
|
||||
setResendTimer(120);
|
||||
setCanResend(false);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = seconds % 60;
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleVerifyOTP = async () => {
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
setOtpDigitInvalid(true);
|
||||
} else {
|
||||
setOtpDigitInvalid(false);
|
||||
|
||||
const confirmSmsOtpRequest: ConfirmSmsOtpRequest = {
|
||||
otpCode: otpCode,
|
||||
phoneNumber: countryCode + value,
|
||||
};
|
||||
const res = await confirmSmsOtpCall(confirmSmsOtpRequest);
|
||||
|
||||
if (!res) return;
|
||||
|
||||
if (res.success) {
|
||||
setIsStatusSuccess(true);
|
||||
toast({
|
||||
message: t('verify.youHaveSuccessfullyLoggedIn'),
|
||||
severity: 'success',
|
||||
});
|
||||
onPhoneNumberVerified();
|
||||
} else {
|
||||
setIsStatusSuccess(false);
|
||||
toast({
|
||||
message: res.message ?? t('verify.theVerificationCodeIsIncorrect'),
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="center">
|
||||
<AuthenticationCard>
|
||||
<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={<Icon Component={Edit2} />}
|
||||
onClick={onEditValue}
|
||||
>
|
||||
{countryCode + value}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
|
||||
{t(
|
||||
'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt',
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<DigitInput
|
||||
error={otpDigitInvalid || isStatusSuccess === false}
|
||||
success={isStatusSuccess === true}
|
||||
onChange={(value) => setOtpCode(value)}
|
||||
/>
|
||||
|
||||
<Button onClick={handleVerifyOTP} loading={confirmSmsOtpLoading}>
|
||||
{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={smsResendLoading}
|
||||
sx={{ width: 'auto' }}
|
||||
onClick={canResend ? handleResendOTPCode : undefined}
|
||||
>
|
||||
{canResend && t('verify.resendCode')}
|
||||
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user