2
.env
2
.env
@@ -1,5 +1,5 @@
|
||||
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_API_URL=https://accounts.business-harmony.com/api
|
||||
VITE_IDENTITY_URL=https://accounts.business-harmony.com/connect/token
|
||||
|
||||
2473
package-lock.json
generated
2473
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,6 @@
|
||||
"@mui/x-data-grid-premium": "^8.10.0",
|
||||
"@mui/x-date-pickers": "^8.10.0",
|
||||
"@rkheftan/harmony-ui": "^0.2.89",
|
||||
"@rollup/rollup-darwin-arm64": "^4.46.3",
|
||||
"axios": "^1.11.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-jalali": "^4.0.0-0",
|
||||
@@ -59,4 +58,4 @@
|
||||
"typescript-eslint": "^8.34.1",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
"phoneNumberIsInvalid": "Phone number is invalid",
|
||||
"thisFieldIsRequired": "This field is required",
|
||||
"googleAuthenticationFailed": "Login with google failed",
|
||||
"persian": "Persian(Fa)",
|
||||
"english": "English(En)",
|
||||
"persian": "Persian (Fa)",
|
||||
"english": "English (En)",
|
||||
"accountInfo": "Harmony Account - 2025"
|
||||
},
|
||||
"verify": {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"phoneNumberIsInvalid": "شماره وارد شده نامعتبر میباشد",
|
||||
"thisFieldIsRequired": "این فیلد الزامی است",
|
||||
"googleAuthenticationFailed": "ورود با گوگل با خطا مواجه شد",
|
||||
"persian": "فارسی(Fa)",
|
||||
"english": "انگلیسی(En)",
|
||||
"persian": "فارسی (Fa)",
|
||||
"english": "انگلیسی (En)",
|
||||
"accountInfo": "۱۴۰۴-هارمونی اکانت"
|
||||
},
|
||||
"verify": {
|
||||
|
||||
@@ -57,10 +57,20 @@ const DigitInput: React.FC<DigitInputProps> = ({
|
||||
event: KeyboardEvent<HTMLDivElement>,
|
||||
index: number,
|
||||
) => {
|
||||
if (event.key === 'Backspace' && code[index]) {
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
handleChange('', index);
|
||||
if (index > 0) inputRefs.current[index - 1]?.focus();
|
||||
|
||||
if (code[index]) {
|
||||
// We clear the current value.
|
||||
handleChange('', index);
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function ProductsMenu({
|
||||
<Button
|
||||
type="link"
|
||||
href={product.demoLink}
|
||||
target="_blank"
|
||||
variant="text"
|
||||
color="club"
|
||||
endIcon={
|
||||
|
||||
@@ -15,7 +15,7 @@ export const productsData: Product[] = [
|
||||
titleKey: 'products.harmonyClub.title',
|
||||
descriptionKey: 'products.harmonyClub.description',
|
||||
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
|
||||
];
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
SendEmailOtpRequest,
|
||||
SendForgetPassCodeRequest,
|
||||
SendSmsOtpRequest,
|
||||
SendSmsOtpResponse,
|
||||
} from '../types/userTypes';
|
||||
import apiClient from '@/lib/apiClient';
|
||||
|
||||
@@ -39,15 +40,29 @@ export const loginWithPassword = async (body: PasswordLoginRequest) => {
|
||||
};
|
||||
|
||||
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) => {
|
||||
return apiClient.post<ApiResponse>('User/SendEmailOtp', body);
|
||||
};
|
||||
|
||||
export const confirmSmsOtp = async (body: ConfirmSmsOtpRequest) => {
|
||||
return apiClient.post<ConfirmOtpResponse>('User/ConfirmSmsOtp', body);
|
||||
export const sendSmsCodeCompleteUserInforamation = async (
|
||||
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) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface AuthenticationCardProps extends PropsWithChildren {
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// Beacuse in the otp verify there is a element outside of the authentication card
|
||||
export const AuthenticationCard = ({
|
||||
children,
|
||||
maxWidth,
|
||||
|
||||
@@ -31,6 +31,7 @@ export const AuthenticationSteps = (): JSX.Element => {
|
||||
useState<string>('');
|
||||
const [memoryTokenRes, setMemoryTokenRes] = useState<GenerateTokenResponse>();
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [timerValue, setTimerValue] = useState(120);
|
||||
const { login } = useAuth();
|
||||
|
||||
const authFactory: AuthFactory = useMemo(() => {
|
||||
@@ -66,8 +67,13 @@ export const AuthenticationSteps = (): JSX.Element => {
|
||||
return resFactory;
|
||||
}, [searchParams]);
|
||||
|
||||
const handleLoginRegister = (value: string, userStatus: UserStatus) => {
|
||||
const handleLoginRegister = (
|
||||
value: string,
|
||||
userStatus: UserStatus,
|
||||
timerValue: number,
|
||||
) => {
|
||||
setAuthType(isNumeric(value) ? 'phone' : 'email');
|
||||
setTimerValue(timerValue);
|
||||
|
||||
switch (userStatus) {
|
||||
case UserStatus.NotRegistered:
|
||||
@@ -131,7 +137,7 @@ export const AuthenticationSteps = (): JSX.Element => {
|
||||
|
||||
const redirectToReturnUrl = (refreshToken: string) => {
|
||||
if (authFactory.isCurrentApplication()) {
|
||||
navigate(import.meta.env.VITE_DEFUALT_AUTH_RETURN_URL);
|
||||
navigate(import.meta.env.VITE_DEFAULT_AUTH_RETURN_URL);
|
||||
} else {
|
||||
if (authMode === 'register') {
|
||||
navigate(
|
||||
@@ -169,6 +175,7 @@ export const AuthenticationSteps = (): JSX.Element => {
|
||||
authType={authType}
|
||||
value={loginRegisterValue}
|
||||
hasPassword={hasPassword}
|
||||
initialTimerValue={timerValue}
|
||||
onLoginWithPassword={() => setCurrentStep('enterPassword')}
|
||||
/>
|
||||
)}
|
||||
@@ -194,6 +201,7 @@ export const AuthenticationSteps = (): JSX.Element => {
|
||||
setValue={setAddedPhoneNumberValue}
|
||||
email={loginRegisterValue}
|
||||
onCompleteSignUp={() => setCurrentStep('addedPhoneNumberVerify')}
|
||||
setTimerValue={setTimerValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -203,6 +211,7 @@ export const AuthenticationSteps = (): JSX.Element => {
|
||||
onEditValue={() => setCurrentStep('addPhoneNumber')}
|
||||
value={addedPhoneNumberValue}
|
||||
onPhoneNumberVerified={handlePhoneNumberVerified}
|
||||
initialTimerValue={timerValue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,10 +4,14 @@ import { useRef, useState, type ChangeEvent, type Dispatch } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { CountryCodeSelector } from '../../../../components/CountryCodeSelector';
|
||||
import { sendSmsOtp } from '../../api/authorizationAPI';
|
||||
import {
|
||||
sendSmsCodeCompleteUserInforamation,
|
||||
sendSmsOtp,
|
||||
} from '../../api/authorizationAPI';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { replacePersianWithRealNumbers } from '@/utils/replacePersianWithRealNumbers';
|
||||
import { useToast } from '@rkheftan/harmony-ui';
|
||||
|
||||
export interface CompleteSignUpProps {
|
||||
email: string;
|
||||
@@ -16,6 +20,7 @@ export interface CompleteSignUpProps {
|
||||
countryCode: CountryCode;
|
||||
setCountryCode: Dispatch<CountryCode>;
|
||||
onCompleteSignUp: (countryCode: string, value: string) => void;
|
||||
setTimerValue: Dispatch<number>;
|
||||
}
|
||||
|
||||
export const CompleteSignUp = ({
|
||||
@@ -25,6 +30,7 @@ export const CompleteSignUp = ({
|
||||
countryCode,
|
||||
setCountryCode,
|
||||
onCompleteSignUp,
|
||||
setTimerValue,
|
||||
}: CompleteSignUpProps) => {
|
||||
const { t, i18n } = useTranslation('authentication');
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -32,7 +38,10 @@ export const CompleteSignUp = ({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [touched, setTouched] = useState<boolean>(false);
|
||||
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 phoneNumber = parsePhoneNumberFromString(code + phone);
|
||||
@@ -74,8 +83,20 @@ export const CompleteSignUp = ({
|
||||
newValue = newValue.substring(1);
|
||||
setValue(newValue);
|
||||
}
|
||||
await sendSmsCall({ phoneNumber: countryCode + newValue });
|
||||
onCompleteSignUp(countryCode, newValue);
|
||||
const res = await sendSmsCall({ phoneNumber: countryCode + newValue });
|
||||
|
||||
if (!res) return;
|
||||
|
||||
if (res.success) {
|
||||
onCompleteSignUp(countryCode, newValue);
|
||||
setTimerValue(res.totalSecondForCodeToExpire);
|
||||
} else {
|
||||
toast({
|
||||
message: res.message,
|
||||
severity: 'error',
|
||||
});
|
||||
setError(res.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ export const EnterPasswordForm = ({
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{ width: 'auto' }}
|
||||
sx={{ width: 'auto', textTransform: 'none' }}
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
onClick={onEditValue}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
LoginOrSignUpWithGoogleRequest,
|
||||
LoginResult,
|
||||
@@ -16,7 +16,6 @@ import { Box, Button } from '@mui/material';
|
||||
import { Google } from 'iconsax-react';
|
||||
|
||||
interface GoogleAuthenticationV2Props {
|
||||
disabled: boolean;
|
||||
authFactory: AuthFactory;
|
||||
onGoogleAuthenticated: (
|
||||
loginResult: LoginResult,
|
||||
@@ -25,31 +24,20 @@ interface GoogleAuthenticationV2Props {
|
||||
}
|
||||
|
||||
export const GoogleAuthenticationV2 = ({
|
||||
disabled,
|
||||
authFactory,
|
||||
onGoogleAuthenticated,
|
||||
}: GoogleAuthenticationV2Props) => {
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation('authentication');
|
||||
const { loading: loginWithGoogleLoading, execute: loginWithGoogleCall } =
|
||||
useApi(loginOrSignUpWithGoogle);
|
||||
const googleButtonRef = useRef<HTMLDivElement>(null);
|
||||
const { execute: loginWithGoogleCall, loading } = useApi(
|
||||
loginOrSignUpWithGoogle,
|
||||
);
|
||||
const [isGoogleLoaded, setIsGoogleLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeData = {
|
||||
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
|
||||
callback: handleCredentialResponse,
|
||||
};
|
||||
const googleBtnRef = useRef<HTMLDivElement>(null);
|
||||
const renderedRef = useRef(false);
|
||||
|
||||
google.accounts.id.initialize(initializeData);
|
||||
|
||||
google.accounts.id.renderButton(
|
||||
document.getElementById('google-signin-button'),
|
||||
{ theme: 'outline' },
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleCredentialResponse = async (response: { credential: any }) => {
|
||||
const handleCredentialResponse = async (response: any) => {
|
||||
const idToken = response.credential;
|
||||
|
||||
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 = () => {
|
||||
if (googleButtonRef.current) {
|
||||
if (googleBtnRef.current) {
|
||||
const googleCustomRenderedDivs =
|
||||
googleButtonRef.current.querySelectorAll('div');
|
||||
googleBtnRef.current.querySelectorAll('div');
|
||||
|
||||
googleCustomRenderedDivs.forEach((b) => b.click());
|
||||
}
|
||||
@@ -93,20 +124,29 @@ export const GoogleAuthenticationV2 = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ display: 'none !important' }}>
|
||||
<div ref={googleButtonRef} id="google-signin-button"></div>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={disabled}
|
||||
loading={loginWithGoogleLoading}
|
||||
variant="outlined"
|
||||
startIcon={<Icon Component={Google} variant="Bold" />}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
minHeight: 44,
|
||||
}}
|
||||
>
|
||||
{t('loginForm.loginWithGoogle')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
loading={!isGoogleLoaded || loading}
|
||||
variant="outlined"
|
||||
startIcon={<Icon Component={Google} variant="Bold" />}
|
||||
>
|
||||
{t('loginForm.loginWithGoogle')}
|
||||
</Button>
|
||||
<div
|
||||
ref={googleBtnRef}
|
||||
style={{ display: 'none' }}
|
||||
id="google-signin-button"
|
||||
></div>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, Typography, MenuItem, Select, Stack } from '@mui/material';
|
||||
import { Icon } from '@rkheftan/harmony-ui';
|
||||
import { Global } from 'iconsax-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -15,32 +16,45 @@ export default function LanguageAccountBar() {
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
px: 2,
|
||||
py: 1,
|
||||
py: 1.5,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
direction: i18n.language === 'fa' ? 'rtl' : 'ltr',
|
||||
}}
|
||||
>
|
||||
<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
|
||||
size="small"
|
||||
value={i18n.language}
|
||||
onChange={handleChange}
|
||||
variant="standard"
|
||||
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="en">{t('loginForm.english')}</MenuItem>
|
||||
</Select>
|
||||
<Global size={20} style={{ color: '#666' }} />
|
||||
</Stack>
|
||||
|
||||
<Typography sx={{ color: 'text.primary' }} variant="body2">
|
||||
{t('loginForm.accountInfo')}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,11 @@ export interface LoginRegisterFormProps {
|
||||
setCountryCode: Dispatch<CountryCode>;
|
||||
authType: AuthType;
|
||||
setAuthType: Dispatch<AuthType>;
|
||||
onLoginRegisterSubmit: (value: string, userStatus: UserStatus) => void;
|
||||
onLoginRegisterSubmit: (
|
||||
value: string,
|
||||
userStatus: UserStatus,
|
||||
timerValue: number,
|
||||
) => void;
|
||||
onGoogleAuthenticated: (
|
||||
loginResult: LoginResult,
|
||||
tokenResponse: GenerateTokenResponse,
|
||||
@@ -90,17 +94,17 @@ export function LoginRegisterForm({
|
||||
setErrors: boolean = true,
|
||||
): boolean => {
|
||||
if (!value) {
|
||||
if (setErrors) setError(t('loginForm.thisFieldIsRequired'));
|
||||
if (setErrors) setError('loginForm.thisFieldIsRequired');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authType === 'email' && !isEmail(value)) {
|
||||
if (setErrors) setError(t('loginForm.emailIsInvalid'));
|
||||
if (setErrors) setError('loginForm.emailIsInvalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authType === 'phone' && !isPhoneNumber(countryCode, value)) {
|
||||
if (setErrors) setError(t('loginForm.phoneNumberIsInvalid'));
|
||||
if (setErrors) setError('loginForm.phoneNumberIsInvalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -131,7 +135,11 @@ export function LoginRegisterForm({
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
onLoginRegisterSubmit(newValue, res.userStatus);
|
||||
onLoginRegisterSubmit(
|
||||
newValue,
|
||||
res.userStatus,
|
||||
res.totalSecondForOtpToExpire,
|
||||
);
|
||||
} else {
|
||||
toast({ message: res.message, severity: 'error' });
|
||||
}
|
||||
@@ -171,7 +179,7 @@ export function LoginRegisterForm({
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
error={inputError}
|
||||
helperText={inputError ? error : ''}
|
||||
helperText={inputError && t(error || '')}
|
||||
autoFocus
|
||||
slotProps={{
|
||||
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5 } },
|
||||
@@ -207,7 +215,6 @@ export function LoginRegisterForm({
|
||||
<GoogleAuthenticationV2
|
||||
authFactory={authFactory}
|
||||
onGoogleAuthenticated={onGoogleAuthenticated}
|
||||
disabled={userStatusLoading}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -32,6 +32,7 @@ interface OtpVerifyFormProps {
|
||||
) => void;
|
||||
authFactory: AuthFactory;
|
||||
hasPassword: boolean;
|
||||
initialTimerValue: number;
|
||||
onLoginWithPassword: () => void;
|
||||
}
|
||||
|
||||
@@ -44,13 +45,14 @@ export function OtpVerifyForm({
|
||||
onOTPVerified,
|
||||
authFactory,
|
||||
hasPassword,
|
||||
initialTimerValue,
|
||||
onLoginWithPassword,
|
||||
}: OtpVerifyFormProps) {
|
||||
const [otpCode, setOtpCode] = useState<string>('');
|
||||
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
|
||||
const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>();
|
||||
const { t, i18n } = useTranslation('authentication');
|
||||
const [resendTimer, setResendTimer] = useState<number>(120);
|
||||
const [resendTimer, setResendTimer] = useState<number>(initialTimerValue);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
const toast = useToast();
|
||||
const { loading: smsResendLoading, execute: smsResendCall } =
|
||||
@@ -200,7 +202,7 @@ export function OtpVerifyForm({
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{
|
||||
textTransform: 'lowercase',
|
||||
textTransform: 'none',
|
||||
width: 'auto',
|
||||
}}
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
|
||||
@@ -5,16 +5,21 @@ 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 {
|
||||
confirmSmsCodeCompleteUserInforamation,
|
||||
sendSmsCodeCompleteUserInforamation,
|
||||
} from '../../api/authorizationAPI';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { LTRTypography } from '@/components/common/LTRTypography';
|
||||
|
||||
interface VerifyPhoneNumberProps {
|
||||
value: string;
|
||||
countryCode: CountryCode;
|
||||
onEditValue: () => void;
|
||||
onPhoneNumberVerified: () => void;
|
||||
initialTimerValue: number;
|
||||
}
|
||||
|
||||
export function VerifyPhoneNumber({
|
||||
@@ -22,18 +27,21 @@ export function VerifyPhoneNumber({
|
||||
countryCode,
|
||||
onEditValue,
|
||||
onPhoneNumberVerified,
|
||||
initialTimerValue,
|
||||
}: 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 [resendTimer, setResendTimer] = useState<number>(initialTimerValue);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
const toast = useToast();
|
||||
const { loading: smsResendLoading, execute: smsResendCall } =
|
||||
useApi(sendSmsOtp);
|
||||
const { loading: confirmSmsOtpLoading, execute: confirmSmsOtpCall } =
|
||||
useApi(confirmSmsOtp);
|
||||
const { loading: smsResendLoading, execute: smsResendCall } = useApi(
|
||||
sendSmsCodeCompleteUserInforamation,
|
||||
);
|
||||
const { loading: confirmSmsOtpLoading, execute: confirmSmsOtpCall } = useApi(
|
||||
confirmSmsCodeCompleteUserInforamation,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (otpCode.length === 4) {
|
||||
@@ -55,9 +63,10 @@ export function VerifyPhoneNumber({
|
||||
}, [resendTimer]);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -130,7 +139,7 @@ export function VerifyPhoneNumber({
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
onClick={onEditValue}
|
||||
>
|
||||
{countryCode + value}
|
||||
<LTRTypography>{countryCode + value}</LTRTypography>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { CountryCode } from '@/types/commonTypes';
|
||||
import { resetPassword } from '../../api/authorizationAPI';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { LTRTypography } from '@/components/common/LTRTypography';
|
||||
|
||||
export interface ChangePasswordProps {
|
||||
onEditInfo: () => void;
|
||||
@@ -136,7 +137,11 @@ export const ChangePassword = ({
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
onClick={onEditInfo}
|
||||
>
|
||||
{forgetPasswordInfo}
|
||||
<LTRTypography>
|
||||
{infoType === 'email'
|
||||
? forgetPasswordInfo
|
||||
: countryCode + forgetPasswordInfo}
|
||||
</LTRTypography>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Stack, TextField, Typography } from '@mui/material';
|
||||
import { useRef, useState, type Dispatch } from 'react';
|
||||
import { Box, Button, Stack, TextField, Typography } from '@mui/material';
|
||||
import { useRef, useState, type Dispatch, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isNumeric } from '@/utils/regexes/isNumeric';
|
||||
import type { AuthType } from '../../types/authTypes';
|
||||
@@ -48,9 +48,7 @@ export function ForgetPasswordInfo({
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let newValue = event.target.value;
|
||||
newValue = replacePersianWithRealNumbers(newValue);
|
||||
if (newValue.startsWith('09')) {
|
||||
newValue = newValue.substring(1);
|
||||
}
|
||||
|
||||
setForgetPasswordInfo(newValue);
|
||||
|
||||
// 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)) {
|
||||
let newValue = forgetPasswordInfo;
|
||||
|
||||
if (
|
||||
infoType === 'phone' &&
|
||||
countryCode === '+98' &&
|
||||
newValue.startsWith('09')
|
||||
) {
|
||||
newValue = forgetPasswordInfo.substring(1);
|
||||
setForgetPasswordInfo(newValue);
|
||||
}
|
||||
|
||||
const sendCodeRequest: SendForgetPassCodeRequest = {
|
||||
email: infoType === 'email' ? forgetPasswordInfo : undefined,
|
||||
phoneNumber:
|
||||
infoType === 'phone' ? countryCode + forgetPasswordInfo : undefined,
|
||||
phoneNumber: infoType === 'phone' ? countryCode + newValue : undefined,
|
||||
};
|
||||
|
||||
const res = await sendForgetPassCodeCall(sendCodeRequest);
|
||||
|
||||
if (!res) return;
|
||||
@@ -102,6 +113,7 @@ export function ForgetPasswordInfo({
|
||||
message: res.message,
|
||||
severity: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onVerifyOtp(forgetPasswordInfo);
|
||||
@@ -115,49 +127,49 @@ export function ForgetPasswordInfo({
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Stack component="form" onSubmit={handleSubmit} spacing={1}>
|
||||
<Typography variant="h5">
|
||||
{t('forgetPassword.forgetPassword')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t(
|
||||
'forgetPassword.pleaseEnterYourMobileNumberEmailToRecoverYourPassword',
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">
|
||||
{t('forgetPassword.forgetPassword')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t(
|
||||
'forgetPassword.pleaseEnterYourMobileNumberEmailToRecoverYourPassword',
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
ref={textFieldRef}
|
||||
inputRef={inputRef}
|
||||
label={t('loginForm.emailOrPhoneLabel')}
|
||||
value={forgetPasswordInfo}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
error={inputError}
|
||||
helperText={inputError ? error : ''}
|
||||
autoFocus
|
||||
slotProps={{
|
||||
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5 } },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<CountryCodeSelector
|
||||
value={countryCode}
|
||||
onChange={setCountryCode}
|
||||
show={showAdornment}
|
||||
menuAnchor={textFieldRef.current}
|
||||
onCloseFocusRef={inputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4, mb: 8 }}
|
||||
/>
|
||||
<TextField
|
||||
ref={textFieldRef}
|
||||
inputRef={inputRef}
|
||||
label={t('loginForm.emailOrPhoneLabel')}
|
||||
value={forgetPasswordInfo}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
error={inputError}
|
||||
helperText={inputError ? error : ''}
|
||||
autoFocus
|
||||
slotProps={{
|
||||
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5 } },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<CountryCodeSelector
|
||||
value={countryCode}
|
||||
onChange={setCountryCode}
|
||||
show={showAdornment}
|
||||
menuAnchor={textFieldRef.current}
|
||||
onCloseFocusRef={inputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4, mb: 8 }}
|
||||
/>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Button loading={sendForgetPassCodeLoading} type="submit">
|
||||
{t('forgetPassword.confirm')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../api/authorizationAPI';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { LTRTypography } from '@/components/common/LTRTypography';
|
||||
|
||||
interface ForgetPasswordOtpProps {
|
||||
forgetPasswordInfo: string;
|
||||
@@ -150,9 +151,11 @@ export function ForgetPasswordOtp({
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
onClick={onEditInfo}
|
||||
>
|
||||
{infoType === 'phone'
|
||||
? countryCode + forgetPasswordInfo
|
||||
: forgetPasswordInfo}
|
||||
<LTRTypography>
|
||||
{infoType === 'phone'
|
||||
? countryCode + forgetPasswordInfo
|
||||
: forgetPasswordInfo}
|
||||
</LTRTypography>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -110,22 +110,14 @@ export function EmailSection(props: EmailSectionProps) {
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start" sx={{ width: ADORN_W }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: ADORN_W,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
startAdornment:
|
||||
!isVerifyingCode && emailVerified ? (
|
||||
<InputAdornment position="start" sx={{ width: ADORN_W }}>
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
visibility:
|
||||
!isVerifyingCode && emailVerified
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
width: ADORN_W,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
@@ -135,10 +127,9 @@ export function EmailSection(props: EmailSectionProps) {
|
||||
color="success.main"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
</InputAdornment>
|
||||
) : undefined,
|
||||
endAdornment: codeSent ? (
|
||||
<InputAdornment position="end" sx={{ width: ADORN_W }}>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -147,23 +138,19 @@ export function EmailSection(props: EmailSectionProps) {
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ visibility: codeSent ? 'visible' : 'hidden' }}
|
||||
<IconButton
|
||||
onClick={handleEditEmail}
|
||||
disabled={!codeSent}
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleEditEmail}
|
||||
disabled={!codeSent}
|
||||
>
|
||||
<Icon
|
||||
Component={Edit2}
|
||||
color="primary.main"
|
||||
size="medium"
|
||||
/>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Icon
|
||||
Component={Edit2}
|
||||
color="primary.main"
|
||||
size="medium"
|
||||
/>
|
||||
</IconButton>
|
||||
</Box>
|
||||
</InputAdornment>
|
||||
),
|
||||
) : undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ export function AuthenticationPage() {
|
||||
align="center"
|
||||
justify="center"
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
minHeight: '100dvh',
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface GetUserStatusByPhoneNumberOrEmailRequest {
|
||||
|
||||
export interface GetUserStatusByPhoneNumberOrEmailResponse extends ApiResponse {
|
||||
userStatus: UserStatus;
|
||||
totalSecondForOtpToExpire: number;
|
||||
}
|
||||
|
||||
export enum UserStatus {
|
||||
@@ -50,6 +51,10 @@ export interface SendSmsOtpRequest {
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export interface SendSmsOtpResponse extends ApiResponse {
|
||||
totalSecondForCodeToExpire: number;
|
||||
}
|
||||
|
||||
// SendEmailOtp
|
||||
|
||||
export interface SendEmailOtpRequest {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// useAuth.tsx
|
||||
import { AuthContext, type UserInfo } from '@/contexts/AuthContext';
|
||||
import type { GenerateTokenResponse } from '@/features/authentication/api/identityAPI';
|
||||
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 type { GUID } from '@/types/commonTypes';
|
||||
|
||||
@@ -27,157 +32,149 @@ export interface AccessTokenJwtPayload {
|
||||
jti: string;
|
||||
}
|
||||
|
||||
export const ACCESS_TOKEN_KEY: 'access_token' = 'access_token' as const;
|
||||
export const REFRESH_TOKEN_KEY: 'refresh_token' = 'refresh_token' as const;
|
||||
export const EXPIRES_IN_KEY: 'expires_in' = 'expires_in' as const;
|
||||
|
||||
let inMemoryToken: string | null = null;
|
||||
let expiresAt = 0;
|
||||
export const ACCESS_TOKEN_KEY = 'access_token';
|
||||
export const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
export const EXPIRES_IN_KEY = 'expires_in';
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||
const [authFinished, setAuthFinished] = useState<boolean>(false);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(
|
||||
sessionStorage.getItem(ACCESS_TOKEN_KEY),
|
||||
);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
|
||||
// Initialize from sessionStorage (page reload)
|
||||
useEffect(() => {
|
||||
const handleAndSetToken = async () => {
|
||||
const token = sessionStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const refreshPromiseRef = useRef<Promise<string | null> | null>(null);
|
||||
const expiresAtRef = useRef<number>(0);
|
||||
|
||||
if (token) {
|
||||
setUserInfoFromToken(token);
|
||||
await refreshAccessToken();
|
||||
inMemoryToken = token;
|
||||
expiresAt = Number(sessionStorage.getItem(EXPIRES_IN_KEY) || 0);
|
||||
setAccessToken(token);
|
||||
|
||||
setAuthFinished(true);
|
||||
}
|
||||
|
||||
setAuthFinished(true);
|
||||
};
|
||||
|
||||
handleAndSetToken();
|
||||
const extractUserFromToken = useCallback((token: string) => {
|
||||
try {
|
||||
const decoded = jwtDecode<AccessTokenJwtPayload>(token);
|
||||
setUserInfo({
|
||||
picture: decoded.picture,
|
||||
firstName: decoded.given_name,
|
||||
lastName: decoded.family_name,
|
||||
fullName: decoded.name,
|
||||
email: decoded.email,
|
||||
phoneNumber: decoded.phone_number,
|
||||
userID: decoded.sub,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to decode token', e);
|
||||
setUserInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Background refresh
|
||||
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();
|
||||
const logout = useCallback(() => {
|
||||
setAccessToken(null);
|
||||
}
|
||||
setUserInfo(null);
|
||||
expiresAtRef.current = 0;
|
||||
sessionStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}, []);
|
||||
|
||||
function getToken() {
|
||||
return inMemoryToken;
|
||||
}
|
||||
const performRefresh = async (): Promise<string | null> => {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!refreshToken) {
|
||||
logout();
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeRefreshSession: string | null =
|
||||
sessionStorage.getItem('active-refresh');
|
||||
const result = await axios.post<GenerateTokenResponse>(
|
||||
import.meta.env.VITE_IDENTITY_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: import.meta.env.VITE_IDENTITY_CLIENT_ID,
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||||
);
|
||||
|
||||
if (
|
||||
!activeRefreshSession ||
|
||||
(activeRefreshSession && JSON.parse(activeRefreshSession) === false)
|
||||
) {
|
||||
sessionStorage.setItem('active-refresh', JSON.stringify(true));
|
||||
const newAccessToken = result.data.access_token;
|
||||
const newRefreshToken = result.data.refresh_token;
|
||||
|
||||
const result = await axios.post<GenerateTokenResponse>(
|
||||
import.meta.env.VITE_IDENTITY_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: import.meta.env.VITE_IDENTITY_CLIENT_ID, // from your token payload
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
},
|
||||
);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
||||
sessionStorage.setItem(ACCESS_TOKEN_KEY, newAccessToken);
|
||||
|
||||
if (result.data.access_token) {
|
||||
inMemoryToken = result.data.access_token;
|
||||
expiresAt = Date.now() + result.data.expires_in * 1000;
|
||||
expiresAtRef.current = Date.now() + result.data.expires_in * 1000;
|
||||
setAccessToken(newAccessToken);
|
||||
extractUserFromToken(newAccessToken);
|
||||
|
||||
sessionStorage.setItem(ACCESS_TOKEN_KEY, inMemoryToken as string);
|
||||
sessionStorage.setItem(REFRESH_TOKEN_KEY, result.data.refresh_token);
|
||||
sessionStorage.setItem(EXPIRES_IN_KEY, String(expiresAt));
|
||||
|
||||
setAccessToken(inMemoryToken);
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
|
||||
sessionStorage.setItem('active-refresh', JSON.stringify(false));
|
||||
}
|
||||
} catch {
|
||||
return newAccessToken;
|
||||
} catch (error) {
|
||||
console.error('Refresh failed', error);
|
||||
logout();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function setUserInfoFromToken(token: string) {
|
||||
const accessTokenPayload = jwtDecode<AccessTokenJwtPayload>(token);
|
||||
const getOrRefreshAccessToken = async () => {
|
||||
// If we have a valid token in memory, return it
|
||||
if (accessToken && Date.now() < expiresAtRef.current - 60000) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
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,
|
||||
// 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);
|
||||
};
|
||||
|
||||
setUserInfo(userInfo);
|
||||
}
|
||||
initAuth();
|
||||
}, [extractUserFromToken]);
|
||||
|
||||
// INTERVAL CHECK
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
getOrRefreshAccessToken();
|
||||
}, 30_000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
accessToken,
|
||||
getToken,
|
||||
getToken: () => accessToken,
|
||||
login,
|
||||
logout,
|
||||
authFinished,
|
||||
|
||||
@@ -22,9 +22,8 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
Reference in New Issue
Block a user