Merge pull request #45 from rkheftan/fix/auth-bugs

Fix/auth bugs
This commit is contained in:
SajadMRjl
2025-11-29 11:41:49 +03:30
committed by GitHub
27 changed files with 1667 additions and 1621 deletions

2
.env
View File

@@ -1,5 +1,5 @@
VITE_GOOGLE_CLIENT_ID=272098283932-bft2gvlgjn8edopg0lnqjq1i9ekdmipt.apps.googleusercontent.com VITE_GOOGLE_CLIENT_ID=272098283932-bft2gvlgjn8edopg0lnqjq1i9ekdmipt.apps.googleusercontent.com
VITE_DEFUALT_AUTH_RETURN_URL=/setting/profile VITE_DEFAULT_AUTH_RETURN_URL=/setting/profile
VITE_APP_URL=https://accounts.business-harmony.com VITE_APP_URL=https://accounts.business-harmony.com
VITE_API_URL=https://accounts.business-harmony.com/api VITE_API_URL=https://accounts.business-harmony.com/api
VITE_IDENTITY_URL=https://accounts.business-harmony.com/connect/token VITE_IDENTITY_URL=https://accounts.business-harmony.com/connect/token

2469
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,6 @@
"@mui/x-data-grid-premium": "^8.10.0", "@mui/x-data-grid-premium": "^8.10.0",
"@mui/x-date-pickers": "^8.10.0", "@mui/x-date-pickers": "^8.10.0",
"@rkheftan/harmony-ui": "^0.2.89", "@rkheftan/harmony-ui": "^0.2.89",
"@rollup/rollup-darwin-arm64": "^4.46.3",
"axios": "^1.11.0", "axios": "^1.11.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"date-fns-jalali": "^4.0.0-0", "date-fns-jalali": "^4.0.0-0",

View File

@@ -57,10 +57,20 @@ const DigitInput: React.FC<DigitInputProps> = ({
event: KeyboardEvent<HTMLDivElement>, event: KeyboardEvent<HTMLDivElement>,
index: number, index: number,
) => { ) => {
if (event.key === 'Backspace' && code[index]) { if (event.key === 'Backspace') {
event.preventDefault(); event.preventDefault();
if (code[index]) {
// We clear the current value.
handleChange('', index); handleChange('', index);
if (index > 0) inputRefs.current[index - 1]?.focus(); } else if (index > 0) {
// We move focus to the previous input and SELECT its content.
const prevInput = inputRefs.current[index - 1];
if (prevInput) {
prevInput.focus();
prevInput.select();
}
}
} }
}; };

View File

@@ -69,6 +69,7 @@ export default function ProductsMenu({
<Button <Button
type="link" type="link"
href={product.demoLink} href={product.demoLink}
target="_blank"
variant="text" variant="text"
color="club" color="club"
endIcon={ endIcon={

View File

@@ -15,7 +15,7 @@ export const productsData: Product[] = [
titleKey: 'products.harmonyClub.title', titleKey: 'products.harmonyClub.title',
descriptionKey: 'products.harmonyClub.description', descriptionKey: 'products.harmonyClub.description',
LogoComponent: <Logo isIcon boxSx={{ width: 69, height: 55 }} />, // Reference the component LogoComponent: <Logo isIcon boxSx={{ width: 69, height: 55 }} />, // Reference the component
demoLink: '', // FIXME: update this url demoLink: 'https://club.business-harmony.com',
}, },
// add more products here // add more products here
]; ];

View File

@@ -16,6 +16,7 @@ import type {
SendEmailOtpRequest, SendEmailOtpRequest,
SendForgetPassCodeRequest, SendForgetPassCodeRequest,
SendSmsOtpRequest, SendSmsOtpRequest,
SendSmsOtpResponse,
} from '../types/userTypes'; } from '../types/userTypes';
import apiClient from '@/lib/apiClient'; import apiClient from '@/lib/apiClient';
@@ -39,15 +40,29 @@ export const loginWithPassword = async (body: PasswordLoginRequest) => {
}; };
export const sendSmsOtp = async (body: SendSmsOtpRequest) => { export const sendSmsOtp = async (body: SendSmsOtpRequest) => {
return apiClient.post<ApiResponse>('User/SendSmsOtp', body); return apiClient.post<SendSmsOtpResponse>('User/SendSmsOtp', body);
}; };
export const sendEmailOtp = async (body: SendEmailOtpRequest) => { export const sendEmailOtp = async (body: SendEmailOtpRequest) => {
return apiClient.post<ApiResponse>('User/SendEmailOtp', body); return apiClient.post<ApiResponse>('User/SendEmailOtp', body);
}; };
export const confirmSmsOtp = async (body: ConfirmSmsOtpRequest) => { export const sendSmsCodeCompleteUserInforamation = async (
return apiClient.post<ConfirmOtpResponse>('User/ConfirmSmsOtp', body); body: SendSmsOtpRequest,
) => {
return apiClient.post<SendSmsOtpResponse>(
'User/SendSmsCodeCompleteUserInforamation',
body,
);
};
export const confirmSmsCodeCompleteUserInforamation = async (
body: ConfirmSmsOtpRequest,
) => {
return apiClient.post<ConfirmOtpResponse>(
'User/ConfirmSmsCodeCompleteUserInforamation',
body,
);
}; };
export const confirmEmailOtp = async (body: ConfirmEmailOtpRequest) => { export const confirmEmailOtp = async (body: ConfirmEmailOtpRequest) => {

View File

@@ -6,7 +6,6 @@ export interface AuthenticationCardProps extends PropsWithChildren {
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
// Beacuse in the otp verify there is a element outside of the authentication card
export const AuthenticationCard = ({ export const AuthenticationCard = ({
children, children,
maxWidth, maxWidth,

View File

@@ -31,6 +31,7 @@ export const AuthenticationSteps = (): JSX.Element => {
useState<string>(''); useState<string>('');
const [memoryTokenRes, setMemoryTokenRes] = useState<GenerateTokenResponse>(); const [memoryTokenRes, setMemoryTokenRes] = useState<GenerateTokenResponse>();
const [hasPassword, setHasPassword] = useState(false); const [hasPassword, setHasPassword] = useState(false);
const [timerValue, setTimerValue] = useState(120);
const { login } = useAuth(); const { login } = useAuth();
const authFactory: AuthFactory = useMemo(() => { const authFactory: AuthFactory = useMemo(() => {
@@ -66,8 +67,13 @@ export const AuthenticationSteps = (): JSX.Element => {
return resFactory; return resFactory;
}, [searchParams]); }, [searchParams]);
const handleLoginRegister = (value: string, userStatus: UserStatus) => { const handleLoginRegister = (
value: string,
userStatus: UserStatus,
timerValue: number,
) => {
setAuthType(isNumeric(value) ? 'phone' : 'email'); setAuthType(isNumeric(value) ? 'phone' : 'email');
setTimerValue(timerValue);
switch (userStatus) { switch (userStatus) {
case UserStatus.NotRegistered: case UserStatus.NotRegistered:
@@ -131,7 +137,7 @@ export const AuthenticationSteps = (): JSX.Element => {
const redirectToReturnUrl = (refreshToken: string) => { const redirectToReturnUrl = (refreshToken: string) => {
if (authFactory.isCurrentApplication()) { if (authFactory.isCurrentApplication()) {
navigate(import.meta.env.VITE_DEFUALT_AUTH_RETURN_URL); navigate(import.meta.env.VITE_DEFAULT_AUTH_RETURN_URL);
} else { } else {
if (authMode === 'register') { if (authMode === 'register') {
navigate( navigate(
@@ -169,6 +175,7 @@ export const AuthenticationSteps = (): JSX.Element => {
authType={authType} authType={authType}
value={loginRegisterValue} value={loginRegisterValue}
hasPassword={hasPassword} hasPassword={hasPassword}
initialTimerValue={timerValue}
onLoginWithPassword={() => setCurrentStep('enterPassword')} onLoginWithPassword={() => setCurrentStep('enterPassword')}
/> />
)} )}
@@ -194,6 +201,7 @@ export const AuthenticationSteps = (): JSX.Element => {
setValue={setAddedPhoneNumberValue} setValue={setAddedPhoneNumberValue}
email={loginRegisterValue} email={loginRegisterValue}
onCompleteSignUp={() => setCurrentStep('addedPhoneNumberVerify')} onCompleteSignUp={() => setCurrentStep('addedPhoneNumberVerify')}
setTimerValue={setTimerValue}
/> />
)} )}
@@ -203,6 +211,7 @@ export const AuthenticationSteps = (): JSX.Element => {
onEditValue={() => setCurrentStep('addPhoneNumber')} onEditValue={() => setCurrentStep('addPhoneNumber')}
value={addedPhoneNumberValue} value={addedPhoneNumberValue}
onPhoneNumberVerified={handlePhoneNumberVerified} onPhoneNumberVerified={handlePhoneNumberVerified}
initialTimerValue={timerValue}
/> />
)} )}
</> </>

View File

@@ -4,10 +4,14 @@ import { useRef, useState, type ChangeEvent, type Dispatch } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AuthenticationCard } from '../AuthenticationCard'; import { AuthenticationCard } from '../AuthenticationCard';
import { CountryCodeSelector } from '../../../../components/CountryCodeSelector'; import { CountryCodeSelector } from '../../../../components/CountryCodeSelector';
import { sendSmsOtp } from '../../api/authorizationAPI'; import {
sendSmsCodeCompleteUserInforamation,
sendSmsOtp,
} from '../../api/authorizationAPI';
import type { CountryCode } from '@/types/commonTypes'; import type { CountryCode } from '@/types/commonTypes';
import { useApi } from '@/hooks/useApi'; import { useApi } from '@/hooks/useApi';
import { replacePersianWithRealNumbers } from '@/utils/replacePersianWithRealNumbers'; import { replacePersianWithRealNumbers } from '@/utils/replacePersianWithRealNumbers';
import { useToast } from '@rkheftan/harmony-ui';
export interface CompleteSignUpProps { export interface CompleteSignUpProps {
email: string; email: string;
@@ -16,6 +20,7 @@ export interface CompleteSignUpProps {
countryCode: CountryCode; countryCode: CountryCode;
setCountryCode: Dispatch<CountryCode>; setCountryCode: Dispatch<CountryCode>;
onCompleteSignUp: (countryCode: string, value: string) => void; onCompleteSignUp: (countryCode: string, value: string) => void;
setTimerValue: Dispatch<number>;
} }
export const CompleteSignUp = ({ export const CompleteSignUp = ({
@@ -25,6 +30,7 @@ export const CompleteSignUp = ({
countryCode, countryCode,
setCountryCode, setCountryCode,
onCompleteSignUp, onCompleteSignUp,
setTimerValue,
}: CompleteSignUpProps) => { }: CompleteSignUpProps) => {
const { t, i18n } = useTranslation('authentication'); const { t, i18n } = useTranslation('authentication');
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -32,7 +38,10 @@ export const CompleteSignUp = ({
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [touched, setTouched] = useState<boolean>(false); const [touched, setTouched] = useState<boolean>(false);
const inputError: boolean = touched && !!error; const inputError: boolean = touched && !!error;
const { loading: sendSmsLoading, execute: sendSmsCall } = useApi(sendSmsOtp); const { loading: sendSmsLoading, execute: sendSmsCall } = useApi(
sendSmsCodeCompleteUserInforamation,
);
const toast = useToast();
const isPhoneValid = (code: string, phone: string) => { const isPhoneValid = (code: string, phone: string) => {
const phoneNumber = parsePhoneNumberFromString(code + phone); const phoneNumber = parsePhoneNumberFromString(code + phone);
@@ -74,8 +83,20 @@ export const CompleteSignUp = ({
newValue = newValue.substring(1); newValue = newValue.substring(1);
setValue(newValue); setValue(newValue);
} }
await sendSmsCall({ phoneNumber: countryCode + newValue }); const res = await sendSmsCall({ phoneNumber: countryCode + newValue });
if (!res) return;
if (res.success) {
onCompleteSignUp(countryCode, newValue); onCompleteSignUp(countryCode, newValue);
setTimerValue(res.totalSecondForCodeToExpire);
} else {
toast({
message: res.message,
severity: 'error',
});
setError(res.message);
}
} }
}; };

View File

@@ -133,7 +133,7 @@ export const EnterPasswordForm = ({
<Button <Button
variant="outlined" variant="outlined"
size="large" size="large"
sx={{ width: 'auto' }} sx={{ width: 'auto', textTransform: 'none' }}
endIcon={<Icon Component={Edit2} />} endIcon={<Icon Component={Edit2} />}
onClick={onEditValue} onClick={onEditValue}
> >

View File

@@ -1,101 +0,0 @@
import { Button } from '@mui/material';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type {
GoogleCodeClientResponse,
LoginOrSignUpWithGoogleRequest,
LoginResult,
} from '../../types/userTypes';
import { loginOrSignUpWithGoogle } from '../../api/authorizationAPI';
import { Google } from 'iconsax-react';
import { Icon, useToast } from '@rkheftan/harmony-ui';
import { useApi } from '@/hooks/useApi';
import {
generateTokenWithGoogle,
type GenerateTokenResponse,
} from '../../api/identityAPI';
import type { AuthFactory } from '../../types/authTypes';
export interface GoogleAuthenticationProps {
disabled: boolean;
authFactory: AuthFactory;
onGoogleAuthenticated: (
loginResult: LoginResult,
tokenResponse: GenerateTokenResponse,
) => void;
}
/**
* @deprecated use V2 instead
*/
export const GoogleAuthentication = ({
disabled,
authFactory,
onGoogleAuthenticated,
}: GoogleAuthenticationProps) => {
const { t } = useTranslation('authentication');
const { loading: loginWithGoogleLoading, execute: loginWithGoogleCall } =
useApi(loginOrSignUpWithGoogle);
const toast = useToast();
const clientRef = useRef<any>(null);
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.id.initialize({
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
callback: async (resp: GoogleCodeClientResponse) => {
const apiRequest: LoginOrSignUpWithGoogleRequest = {
idToken: resp.id_token,
returnUrl: authFactory.redirectUrl,
};
const res = await loginWithGoogleCall(apiRequest);
if (!res) return;
if (res.success) {
const tokenRes = await generateTokenWithGoogle({
...apiRequest,
client_id: authFactory.clientId,
});
onGoogleAuthenticated(res, tokenRes.data);
} else {
toast({
message: t('loginForm.googleAuthenticationFailed'),
severity: 'error',
});
}
},
});
};
return () => {
document.body.removeChild(script);
};
}, []);
const handleGoogleLogin = () => {
if (clientRef.current) {
clientRef.current.requestCode();
}
};
return (
<Button
type="button"
onClick={handleGoogleLogin}
disabled={disabled}
loading={loginWithGoogleLoading}
variant="outlined"
startIcon={<Icon Component={Google} variant="Bold" />}
>
{t('loginForm.loginWithGoogle')}
</Button>
);
};

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { import type {
LoginOrSignUpWithGoogleRequest, LoginOrSignUpWithGoogleRequest,
LoginResult, LoginResult,
@@ -16,7 +16,6 @@ import { Box, Button } from '@mui/material';
import { Google } from 'iconsax-react'; import { Google } from 'iconsax-react';
interface GoogleAuthenticationV2Props { interface GoogleAuthenticationV2Props {
disabled: boolean;
authFactory: AuthFactory; authFactory: AuthFactory;
onGoogleAuthenticated: ( onGoogleAuthenticated: (
loginResult: LoginResult, loginResult: LoginResult,
@@ -25,31 +24,20 @@ interface GoogleAuthenticationV2Props {
} }
export const GoogleAuthenticationV2 = ({ export const GoogleAuthenticationV2 = ({
disabled,
authFactory, authFactory,
onGoogleAuthenticated, onGoogleAuthenticated,
}: GoogleAuthenticationV2Props) => { }: GoogleAuthenticationV2Props) => {
const toast = useToast(); const toast = useToast();
const { t } = useTranslation('authentication'); const { t } = useTranslation('authentication');
const { loading: loginWithGoogleLoading, execute: loginWithGoogleCall } = const { execute: loginWithGoogleCall, loading } = useApi(
useApi(loginOrSignUpWithGoogle); loginOrSignUpWithGoogle,
const googleButtonRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const initializeData = {
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
callback: handleCredentialResponse,
};
google.accounts.id.initialize(initializeData);
google.accounts.id.renderButton(
document.getElementById('google-signin-button'),
{ theme: 'outline' },
); );
}, []); const [isGoogleLoaded, setIsGoogleLoaded] = useState(false);
const handleCredentialResponse = async (response: { credential: any }) => { const googleBtnRef = useRef<HTMLDivElement>(null);
const renderedRef = useRef(false);
const handleCredentialResponse = async (response: any) => {
const idToken = response.credential; const idToken = response.credential;
try { try {
@@ -82,10 +70,53 @@ export const GoogleAuthenticationV2 = ({
} }
}; };
useEffect(() => {
// Ensure the DOM element exists
if (!googleBtnRef.current || renderedRef.current) return;
// Logic to initialize Google Button
const initializeGoogle = () => {
if (!window.google) return;
setIsGoogleLoaded(true);
google.accounts.id.initialize({
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
callback: handleCredentialResponse,
});
google.accounts.id.renderButton(googleBtnRef.current, {
theme: 'outline', // Matches your MUI 'variant="outlined"'
size: 'large', // Standard button size
type: 'standard', // The standard "Sign in with Google" button
text: 'signin_with', // Text: "Sign in with Google"
shape: 'rectangular', // Matches your MUI button shape
width: '400', // Set a width (Google caps this at 400px)
logo_alignment: 'left',
height: '44',
});
renderedRef.current = true;
};
if (window.google?.accounts) {
initializeGoogle();
} else {
const timer = setInterval(() => {
if (window.google?.accounts) {
initializeGoogle();
clearInterval(timer);
}
}, 500);
return () => clearInterval(timer);
}
}, []);
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
if (googleButtonRef.current) { if (googleBtnRef.current) {
const googleCustomRenderedDivs = const googleCustomRenderedDivs =
googleButtonRef.current.querySelectorAll('div'); googleBtnRef.current.querySelectorAll('div');
googleCustomRenderedDivs.forEach((b) => b.click()); googleCustomRenderedDivs.forEach((b) => b.click());
} }
@@ -93,20 +124,29 @@ export const GoogleAuthenticationV2 = ({
return ( return (
<> <>
<Box sx={{ display: 'none !important' }}> <Box
<div ref={googleButtonRef} id="google-signin-button"></div> sx={{
</Box> width: '100%',
display: 'flex',
justifyContent: 'center',
minHeight: 44,
}}
>
<Button <Button
type="button" type="button"
onClick={handleGoogleLogin} onClick={handleGoogleLogin}
disabled={disabled} loading={!isGoogleLoaded || loading}
loading={loginWithGoogleLoading}
variant="outlined" variant="outlined"
startIcon={<Icon Component={Google} variant="Bold" />} startIcon={<Icon Component={Google} variant="Bold" />}
> >
{t('loginForm.loginWithGoogle')} {t('loginForm.loginWithGoogle')}
</Button> </Button>
<div
ref={googleBtnRef}
style={{ display: 'none' }}
id="google-signin-button"
></div>
</Box>
</> </>
); );
}; };

View File

@@ -1,4 +1,5 @@
import { Box, Typography, MenuItem, Select, Stack } from '@mui/material'; import { Box, Typography, MenuItem, Select, Stack } from '@mui/material';
import { Icon } from '@rkheftan/harmony-ui';
import { Global } from 'iconsax-react'; import { Global } from 'iconsax-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -15,32 +16,45 @@ export default function LanguageAccountBar() {
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
px: 2, px: 2,
py: 1, py: 1.5,
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 0,
direction: i18n.language === 'fa' ? 'rtl' : 'ltr',
}} }}
> >
<Stack direction="row" alignItems="center" spacing={1}> <Stack direction="row" alignItems="center" spacing={1}>
<Typography sx={{ pr: 4, color: 'secondary.light' }} variant="body2">
{t('loginForm.accountInfo')}
</Typography>
<Icon Component={Global} color="action.active" size="small" />
<Select <Select
size="small" size="small"
value={i18n.language} value={i18n.language}
onChange={handleChange} onChange={handleChange}
variant="standard" variant="standard"
disableUnderline disableUnderline
sx={{
'& .MuiSelect-select': {
paddingTop: 0,
paddingBottom: 0,
paddingRight: 2,
fontSize: '0.875rem',
fontWeight: 600,
color: 'action.active',
display: 'flex',
alignItems: 'center',
},
}}
> >
<MenuItem value="fa">{t('loginForm.persian')}</MenuItem> <MenuItem value="fa">{t('loginForm.persian')}</MenuItem>
<MenuItem value="en">{t('loginForm.english')}</MenuItem> <MenuItem value="en">{t('loginForm.english')}</MenuItem>
</Select> </Select>
<Global size={20} style={{ color: '#666' }} />
</Stack> </Stack>
<Typography sx={{ color: 'text.primary' }} variant="body2">
{t('loginForm.accountInfo')}
</Typography>
</Box> </Box>
); );
} }

View File

@@ -24,7 +24,11 @@ export interface LoginRegisterFormProps {
setCountryCode: Dispatch<CountryCode>; setCountryCode: Dispatch<CountryCode>;
authType: AuthType; authType: AuthType;
setAuthType: Dispatch<AuthType>; setAuthType: Dispatch<AuthType>;
onLoginRegisterSubmit: (value: string, userStatus: UserStatus) => void; onLoginRegisterSubmit: (
value: string,
userStatus: UserStatus,
timerValue: number,
) => void;
onGoogleAuthenticated: ( onGoogleAuthenticated: (
loginResult: LoginResult, loginResult: LoginResult,
tokenResponse: GenerateTokenResponse, tokenResponse: GenerateTokenResponse,
@@ -90,17 +94,17 @@ export function LoginRegisterForm({
setErrors: boolean = true, setErrors: boolean = true,
): boolean => { ): boolean => {
if (!value) { if (!value) {
if (setErrors) setError(t('loginForm.thisFieldIsRequired')); if (setErrors) setError('loginForm.thisFieldIsRequired');
return false; return false;
} }
if (authType === 'email' && !isEmail(value)) { if (authType === 'email' && !isEmail(value)) {
if (setErrors) setError(t('loginForm.emailIsInvalid')); if (setErrors) setError('loginForm.emailIsInvalid');
return false; return false;
} }
if (authType === 'phone' && !isPhoneNumber(countryCode, value)) { if (authType === 'phone' && !isPhoneNumber(countryCode, value)) {
if (setErrors) setError(t('loginForm.phoneNumberIsInvalid')); if (setErrors) setError('loginForm.phoneNumberIsInvalid');
return false; return false;
} }
@@ -131,7 +135,11 @@ export function LoginRegisterForm({
} }
if (res.success) { if (res.success) {
onLoginRegisterSubmit(newValue, res.userStatus); onLoginRegisterSubmit(
newValue,
res.userStatus,
res.totalSecondForOtpToExpire,
);
} else { } else {
toast({ message: res.message, severity: 'error' }); toast({ message: res.message, severity: 'error' });
} }
@@ -171,7 +179,7 @@ export function LoginRegisterForm({
onChange={handleInputChange} onChange={handleInputChange}
onBlur={handleBlur} onBlur={handleBlur}
error={inputError} error={inputError}
helperText={inputError ? error : ''} helperText={inputError && t(error || '')}
autoFocus autoFocus
slotProps={{ slotProps={{
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5 } }, htmlInput: { dir: 'auto', sx: { lineHeight: 1.5 } },
@@ -207,7 +215,6 @@ export function LoginRegisterForm({
<GoogleAuthenticationV2 <GoogleAuthenticationV2
authFactory={authFactory} authFactory={authFactory}
onGoogleAuthenticated={onGoogleAuthenticated} onGoogleAuthenticated={onGoogleAuthenticated}
disabled={userStatusLoading}
/> />
</Stack> </Stack>
</Box> </Box>

View File

@@ -32,6 +32,7 @@ interface OtpVerifyFormProps {
) => void; ) => void;
authFactory: AuthFactory; authFactory: AuthFactory;
hasPassword: boolean; hasPassword: boolean;
initialTimerValue: number;
onLoginWithPassword: () => void; onLoginWithPassword: () => void;
} }
@@ -44,13 +45,14 @@ export function OtpVerifyForm({
onOTPVerified, onOTPVerified,
authFactory, authFactory,
hasPassword, hasPassword,
initialTimerValue,
onLoginWithPassword, onLoginWithPassword,
}: OtpVerifyFormProps) { }: OtpVerifyFormProps) {
const [otpCode, setOtpCode] = useState<string>(''); const [otpCode, setOtpCode] = useState<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false); const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>(); const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>();
const { t, i18n } = useTranslation('authentication'); const { t, i18n } = useTranslation('authentication');
const [resendTimer, setResendTimer] = useState<number>(120); const [resendTimer, setResendTimer] = useState<number>(initialTimerValue);
const [canResend, setCanResend] = useState(false); const [canResend, setCanResend] = useState(false);
const toast = useToast(); const toast = useToast();
const { loading: smsResendLoading, execute: smsResendCall } = const { loading: smsResendLoading, execute: smsResendCall } =
@@ -200,7 +202,7 @@ export function OtpVerifyForm({
variant="outlined" variant="outlined"
size="large" size="large"
sx={{ sx={{
textTransform: 'lowercase', textTransform: 'none',
width: 'auto', width: 'auto',
}} }}
endIcon={<Icon Component={Edit2} />} endIcon={<Icon Component={Edit2} />}

View File

@@ -5,16 +5,21 @@ import DigitInput from '@/components/DigitsInput';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { AuthenticationCard } from '../AuthenticationCard'; import { AuthenticationCard } from '../AuthenticationCard';
import type { ConfirmSmsOtpRequest } from '../../types/userTypes'; import type { ConfirmSmsOtpRequest } from '../../types/userTypes';
import { confirmSmsOtp, sendSmsOtp } from '../../api/authorizationAPI'; import {
confirmSmsCodeCompleteUserInforamation,
sendSmsCodeCompleteUserInforamation,
} from '../../api/authorizationAPI';
import type { CountryCode } from '@/types/commonTypes'; import type { CountryCode } from '@/types/commonTypes';
import { Icon, useToast } from '@rkheftan/harmony-ui'; import { Icon, useToast } from '@rkheftan/harmony-ui';
import { useApi } from '@/hooks/useApi'; import { useApi } from '@/hooks/useApi';
import { LTRTypography } from '@/components/common/LTRTypography';
interface VerifyPhoneNumberProps { interface VerifyPhoneNumberProps {
value: string; value: string;
countryCode: CountryCode; countryCode: CountryCode;
onEditValue: () => void; onEditValue: () => void;
onPhoneNumberVerified: () => void; onPhoneNumberVerified: () => void;
initialTimerValue: number;
} }
export function VerifyPhoneNumber({ export function VerifyPhoneNumber({
@@ -22,18 +27,21 @@ export function VerifyPhoneNumber({
countryCode, countryCode,
onEditValue, onEditValue,
onPhoneNumberVerified, onPhoneNumberVerified,
initialTimerValue,
}: VerifyPhoneNumberProps) { }: VerifyPhoneNumberProps) {
const [otpCode, setOtpCode] = useState<string>(''); const [otpCode, setOtpCode] = useState<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false); const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>(); const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>();
const { t } = useTranslation('authentication'); const { t } = useTranslation('authentication');
const [resendTimer, setResendTimer] = useState<number>(120); const [resendTimer, setResendTimer] = useState<number>(initialTimerValue);
const [canResend, setCanResend] = useState(false); const [canResend, setCanResend] = useState(false);
const toast = useToast(); const toast = useToast();
const { loading: smsResendLoading, execute: smsResendCall } = const { loading: smsResendLoading, execute: smsResendCall } = useApi(
useApi(sendSmsOtp); sendSmsCodeCompleteUserInforamation,
const { loading: confirmSmsOtpLoading, execute: confirmSmsOtpCall } = );
useApi(confirmSmsOtp); const { loading: confirmSmsOtpLoading, execute: confirmSmsOtpCall } = useApi(
confirmSmsCodeCompleteUserInforamation,
);
useEffect(() => { useEffect(() => {
if (otpCode.length === 4) { if (otpCode.length === 4) {
@@ -55,9 +63,10 @@ export function VerifyPhoneNumber({
}, [resendTimer]); }, [resendTimer]);
const handleResendOTPCode = async () => { const handleResendOTPCode = async () => {
await smsResendCall({ phoneNumber: countryCode + value }); const res = await smsResendCall({ phoneNumber: countryCode + value });
setResendTimer(120); if (!res) return;
setResendTimer(res.totalSecondForCodeToExpire);
setCanResend(false); setCanResend(false);
}; };
@@ -130,7 +139,7 @@ export function VerifyPhoneNumber({
endIcon={<Icon Component={Edit2} />} endIcon={<Icon Component={Edit2} />}
onClick={onEditValue} onClick={onEditValue}
> >
{countryCode + value} <LTRTypography>{countryCode + value}</LTRTypography>
</Button> </Button>
</Box> </Box>

View File

@@ -20,6 +20,7 @@ import type { CountryCode } from '@/types/commonTypes';
import { resetPassword } from '../../api/authorizationAPI'; import { resetPassword } from '../../api/authorizationAPI';
import { Icon, useToast } from '@rkheftan/harmony-ui'; import { Icon, useToast } from '@rkheftan/harmony-ui';
import { useApi } from '@/hooks/useApi'; import { useApi } from '@/hooks/useApi';
import { LTRTypography } from '@/components/common/LTRTypography';
export interface ChangePasswordProps { export interface ChangePasswordProps {
onEditInfo: () => void; onEditInfo: () => void;
@@ -136,7 +137,11 @@ export const ChangePassword = ({
endIcon={<Icon Component={Edit2} />} endIcon={<Icon Component={Edit2} />}
onClick={onEditInfo} onClick={onEditInfo}
> >
{forgetPasswordInfo} <LTRTypography>
{infoType === 'email'
? forgetPasswordInfo
: countryCode + forgetPasswordInfo}
</LTRTypography>
</Button> </Button>
</Box> </Box>

View File

@@ -1,5 +1,5 @@
import { Button, Stack, TextField, Typography } from '@mui/material'; import { Box, Button, Stack, TextField, Typography } from '@mui/material';
import { useRef, useState, type Dispatch } from 'react'; import { useRef, useState, type Dispatch, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { isNumeric } from '@/utils/regexes/isNumeric'; import { isNumeric } from '@/utils/regexes/isNumeric';
import type { AuthType } from '../../types/authTypes'; import type { AuthType } from '../../types/authTypes';
@@ -48,9 +48,7 @@ export function ForgetPasswordInfo({
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
let newValue = event.target.value; let newValue = event.target.value;
newValue = replacePersianWithRealNumbers(newValue); newValue = replacePersianWithRealNumbers(newValue);
if (newValue.startsWith('09')) {
newValue = newValue.substring(1);
}
setForgetPasswordInfo(newValue); setForgetPasswordInfo(newValue);
// If the new value contains only digits (or is empty), it's a phone number // If the new value contains only digits (or is empty), it's a phone number
@@ -86,13 +84,26 @@ export function ForgetPasswordInfo({
} }
}; };
const handleSubmit = async () => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (validateInput(forgetPasswordInfo, infoType, false)) { if (validateInput(forgetPasswordInfo, infoType, false)) {
let newValue = forgetPasswordInfo;
if (
infoType === 'phone' &&
countryCode === '+98' &&
newValue.startsWith('09')
) {
newValue = forgetPasswordInfo.substring(1);
setForgetPasswordInfo(newValue);
}
const sendCodeRequest: SendForgetPassCodeRequest = { const sendCodeRequest: SendForgetPassCodeRequest = {
email: infoType === 'email' ? forgetPasswordInfo : undefined, email: infoType === 'email' ? forgetPasswordInfo : undefined,
phoneNumber: phoneNumber: infoType === 'phone' ? countryCode + newValue : undefined,
infoType === 'phone' ? countryCode + forgetPasswordInfo : undefined,
}; };
const res = await sendForgetPassCodeCall(sendCodeRequest); const res = await sendForgetPassCodeCall(sendCodeRequest);
if (!res) return; if (!res) return;
@@ -102,6 +113,7 @@ export function ForgetPasswordInfo({
message: res.message, message: res.message,
severity: 'error', severity: 'error',
}); });
return;
} }
onVerifyOtp(forgetPasswordInfo); onVerifyOtp(forgetPasswordInfo);
@@ -115,7 +127,8 @@ export function ForgetPasswordInfo({
return ( return (
<AuthenticationCard> <AuthenticationCard>
<Stack component="form" onSubmit={handleSubmit} spacing={1}> <Box component="form" onSubmit={handleSubmit}>
<Stack spacing={1}>
<Typography variant="h5"> <Typography variant="h5">
{t('forgetPassword.forgetPassword')} {t('forgetPassword.forgetPassword')}
</Typography> </Typography>
@@ -153,11 +166,10 @@ export function ForgetPasswordInfo({
sx={{ my: 4, mb: 8 }} sx={{ my: 4, mb: 8 }}
/> />
<Stack spacing={2}>
<Button loading={sendForgetPassCodeLoading} type="submit"> <Button loading={sendForgetPassCodeLoading} type="submit">
{t('forgetPassword.confirm')} {t('forgetPassword.confirm')}
</Button> </Button>
</Stack> </Box>
</AuthenticationCard> </AuthenticationCard>
); );
} }

View File

@@ -16,6 +16,7 @@ import {
} from '../../api/authorizationAPI'; } from '../../api/authorizationAPI';
import { Icon, useToast } from '@rkheftan/harmony-ui'; import { Icon, useToast } from '@rkheftan/harmony-ui';
import { useApi } from '@/hooks/useApi'; import { useApi } from '@/hooks/useApi';
import { LTRTypography } from '@/components/common/LTRTypography';
interface ForgetPasswordOtpProps { interface ForgetPasswordOtpProps {
forgetPasswordInfo: string; forgetPasswordInfo: string;
@@ -150,9 +151,11 @@ export function ForgetPasswordOtp({
endIcon={<Icon Component={Edit2} />} endIcon={<Icon Component={Edit2} />}
onClick={onEditInfo} onClick={onEditInfo}
> >
<LTRTypography>
{infoType === 'phone' {infoType === 'phone'
? countryCode + forgetPasswordInfo ? countryCode + forgetPasswordInfo
: forgetPasswordInfo} : forgetPasswordInfo}
</LTRTypography>
</Button> </Button>
</Box> </Box>

View File

@@ -110,7 +110,8 @@ export function EmailSection(props: EmailSectionProps) {
}} }}
slotProps={{ slotProps={{
input: { input: {
startAdornment: ( startAdornment:
!isVerifyingCode && emailVerified ? (
<InputAdornment position="start" sx={{ width: ADORN_W }}> <InputAdornment position="start" sx={{ width: ADORN_W }}>
<Box <Box
sx={{ sx={{
@@ -118,15 +119,6 @@ export function EmailSection(props: EmailSectionProps) {
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
}} }}
>
<Box
sx={{
alignItems: 'center',
visibility:
!isVerifyingCode && emailVerified
? 'visible'
: 'hidden',
}}
> >
<Icon <Icon
Component={TickCircle} Component={TickCircle}
@@ -135,10 +127,9 @@ export function EmailSection(props: EmailSectionProps) {
color="success.main" color="success.main"
/> />
</Box> </Box>
</Box>
</InputAdornment> </InputAdornment>
), ) : undefined,
endAdornment: ( endAdornment: codeSent ? (
<InputAdornment position="end" sx={{ width: ADORN_W }}> <InputAdornment position="end" sx={{ width: ADORN_W }}>
<Box <Box
sx={{ sx={{
@@ -146,9 +137,6 @@ export function EmailSection(props: EmailSectionProps) {
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
}} }}
>
<Box
sx={{ visibility: codeSent ? 'visible' : 'hidden' }}
> >
<IconButton <IconButton
onClick={handleEditEmail} onClick={handleEditEmail}
@@ -161,9 +149,8 @@ export function EmailSection(props: EmailSectionProps) {
/> />
</IconButton> </IconButton>
</Box> </Box>
</Box>
</InputAdornment> </InputAdornment>
), ) : undefined,
}, },
}} }}
/> />

View File

@@ -9,7 +9,7 @@ export function AuthenticationPage() {
align="center" align="center"
justify="center" justify="center"
sx={{ sx={{
minHeight: '100vh', minHeight: '100dvh',
gap: 3, gap: 3,
}} }}
> >

View File

@@ -10,6 +10,7 @@ export interface GetUserStatusByPhoneNumberOrEmailRequest {
export interface GetUserStatusByPhoneNumberOrEmailResponse extends ApiResponse { export interface GetUserStatusByPhoneNumberOrEmailResponse extends ApiResponse {
userStatus: UserStatus; userStatus: UserStatus;
totalSecondForOtpToExpire: number;
} }
export enum UserStatus { export enum UserStatus {
@@ -50,6 +51,10 @@ export interface SendSmsOtpRequest {
phoneNumber: string; phoneNumber: string;
} }
export interface SendSmsOtpResponse extends ApiResponse {
totalSecondForCodeToExpire: number;
}
// SendEmailOtp // SendEmailOtp
export interface SendEmailOtpRequest { export interface SendEmailOtpRequest {

View File

@@ -1,8 +1,13 @@
// useAuth.tsx
import { AuthContext, type UserInfo } from '@/contexts/AuthContext'; import { AuthContext, type UserInfo } from '@/contexts/AuthContext';
import type { GenerateTokenResponse } from '@/features/authentication/api/identityAPI'; import type { GenerateTokenResponse } from '@/features/authentication/api/identityAPI';
import axios from 'axios'; import axios from 'axios';
import { useEffect, useState, type ReactNode } from 'react'; import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { jwtDecode } from 'jwt-decode'; import { jwtDecode } from 'jwt-decode';
import type { GUID } from '@/types/commonTypes'; import type { GUID } from '@/types/commonTypes';
@@ -27,157 +32,149 @@ export interface AccessTokenJwtPayload {
jti: string; jti: string;
} }
export const ACCESS_TOKEN_KEY: 'access_token' = 'access_token' as const; export const ACCESS_TOKEN_KEY = 'access_token';
export const REFRESH_TOKEN_KEY: 'refresh_token' = 'refresh_token' as const; export const REFRESH_TOKEN_KEY = 'refresh_token';
export const EXPIRES_IN_KEY: 'expires_in' = 'expires_in' as const; export const EXPIRES_IN_KEY = 'expires_in';
let inMemoryToken: string | null = null;
let expiresAt = 0;
export const AuthProvider = ({ children }: { children: ReactNode }) => { export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null); const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [authFinished, setAuthFinished] = useState<boolean>(false); const [authFinished, setAuthFinished] = useState<boolean>(false);
const [accessToken, setAccessToken] = useState<string | null>( const [accessToken, setAccessToken] = useState<string | null>(null);
sessionStorage.getItem(ACCESS_TOKEN_KEY),
);
// Initialize from sessionStorage (page reload) const refreshPromiseRef = useRef<Promise<string | null> | null>(null);
useEffect(() => { const expiresAtRef = useRef<number>(0);
const handleAndSetToken = async () => {
const token = sessionStorage.getItem(ACCESS_TOKEN_KEY);
if (token) { const extractUserFromToken = useCallback((token: string) => {
setUserInfoFromToken(token); try {
await refreshAccessToken(); const decoded = jwtDecode<AccessTokenJwtPayload>(token);
inMemoryToken = token; setUserInfo({
expiresAt = Number(sessionStorage.getItem(EXPIRES_IN_KEY) || 0); picture: decoded.picture,
setAccessToken(token); firstName: decoded.given_name,
lastName: decoded.family_name,
setAuthFinished(true); fullName: decoded.name,
email: decoded.email,
phoneNumber: decoded.phone_number,
userID: decoded.sub,
});
} catch (e) {
console.error('Failed to decode token', e);
setUserInfo(null);
} }
setAuthFinished(true);
};
handleAndSetToken();
}, []); }, []);
// Background refresh const logout = useCallback(() => {
useEffect(() => {
const handleRefreshTokenIfNeeded = async () => {
if (!inMemoryToken) return;
if (Date.now() < expiresAt - 60_000) return; // still valid (buffer)
await refreshAccessToken();
setAccessToken(inMemoryToken);
};
const interval = setInterval(async () => {
await handleRefreshTokenIfNeeded();
}, 30_000);
return () => clearInterval(interval);
}, []);
function login(tokens: {
access_token: string;
refresh_token: string;
expires_in: number;
}) {
setUserInfoFromToken(tokens.access_token);
inMemoryToken = tokens.access_token;
expiresAt = Date.now() + tokens.expires_in * 1000;
sessionStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token);
sessionStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
sessionStorage.setItem(EXPIRES_IN_KEY, String(expiresAt));
setAccessToken(tokens.access_token);
}
function logout() {
inMemoryToken = null;
expiresAt = 0;
sessionStorage.clear();
setAccessToken(null); setAccessToken(null);
} setUserInfo(null);
expiresAtRef.current = 0;
sessionStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}, []);
function getToken() { const performRefresh = async (): Promise<string | null> => {
return inMemoryToken; const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
}
async function refreshAccessToken() {
const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_KEY);
if (!refreshToken) { if (!refreshToken) {
logout(); logout();
return; return null;
} }
try { try {
const activeRefreshSession: string | null =
sessionStorage.getItem('active-refresh');
if (
!activeRefreshSession ||
(activeRefreshSession && JSON.parse(activeRefreshSession) === false)
) {
sessionStorage.setItem('active-refresh', JSON.stringify(true));
const result = await axios.post<GenerateTokenResponse>( const result = await axios.post<GenerateTokenResponse>(
import.meta.env.VITE_IDENTITY_URL, import.meta.env.VITE_IDENTITY_URL,
new URLSearchParams({ new URLSearchParams({
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: refreshToken, refresh_token: refreshToken,
client_id: import.meta.env.VITE_IDENTITY_CLIENT_ID, // from your token payload client_id: import.meta.env.VITE_IDENTITY_CLIENT_ID,
}), }),
{ { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
); );
if (result.data.access_token) { const newAccessToken = result.data.access_token;
inMemoryToken = result.data.access_token; const newRefreshToken = result.data.refresh_token;
expiresAt = Date.now() + result.data.expires_in * 1000;
sessionStorage.setItem(ACCESS_TOKEN_KEY, inMemoryToken as string); localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
sessionStorage.setItem(REFRESH_TOKEN_KEY, result.data.refresh_token); sessionStorage.setItem(ACCESS_TOKEN_KEY, newAccessToken);
sessionStorage.setItem(EXPIRES_IN_KEY, String(expiresAt));
setAccessToken(inMemoryToken); expiresAtRef.current = Date.now() + result.data.expires_in * 1000;
} else { setAccessToken(newAccessToken);
extractUserFromToken(newAccessToken);
return newAccessToken;
} catch (error) {
console.error('Refresh failed', error);
logout(); logout();
return null;
} }
sessionStorage.setItem('active-refresh', JSON.stringify(false));
}
} catch {
logout();
}
}
function setUserInfoFromToken(token: string) {
const accessTokenPayload = jwtDecode<AccessTokenJwtPayload>(token);
const userInfo: UserInfo = {
picture: accessTokenPayload.picture,
firstName: accessTokenPayload.given_name,
lastName: accessTokenPayload.family_name,
fullName: accessTokenPayload.name,
email: accessTokenPayload.email,
phoneNumber: accessTokenPayload.phone_number,
userID: accessTokenPayload.sub,
}; };
setUserInfo(userInfo); const getOrRefreshAccessToken = async () => {
// If we have a valid token in memory, return it
if (accessToken && Date.now() < expiresAtRef.current - 60000) {
return accessToken;
} }
// If a refresh is already happening, return that existing promise
if (refreshPromiseRef.current) {
return refreshPromiseRef.current;
}
// Otherwise, start a new refresh
refreshPromiseRef.current = performRefresh().finally(() => {
refreshPromiseRef.current = null;
});
return refreshPromiseRef.current;
};
const login = (tokens: {
access_token: string;
refresh_token: string;
expires_in: number;
}) => {
setAccessToken(tokens.access_token);
extractUserFromToken(tokens.access_token);
expiresAtRef.current = Date.now() + tokens.expires_in * 1000;
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
sessionStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token);
};
// INITIALIZATION
useEffect(() => {
const initAuth = async () => {
const persistedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
const persistedAccessToken = sessionStorage.getItem(ACCESS_TOKEN_KEY);
if (persistedRefreshToken) {
if (persistedAccessToken) {
setAccessToken(persistedAccessToken);
extractUserFromToken(persistedAccessToken);
}
await getOrRefreshAccessToken();
}
setAuthFinished(true);
};
initAuth();
}, [extractUserFromToken]);
// INTERVAL CHECK
useEffect(() => {
if (!accessToken) return;
const intervalId = setInterval(() => {
getOrRefreshAccessToken();
}, 30_000);
return () => clearInterval(intervalId);
}, [accessToken]);
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
accessToken, accessToken,
getToken, getToken: () => accessToken,
login, login,
logout, logout,
authFinished, authFinished,

View File

@@ -22,9 +22,8 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
/* Path Aliases */ /* Path Aliases */
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"] "include": ["src"]