Merge pull request #21 from rkheftan/chore/google-authentication

Chore/google authentication
This commit is contained in:
SajadMRjl
2025-08-18 02:24:11 +03:30
committed by GitHub
14 changed files with 188 additions and 95 deletions

2
.env
View File

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

10
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"iconsax-react": "^0.0.8",
"jwt-decode": "^4.0.0",
"libphonenumber-js": "^1.12.10",
"react": "^19.1.0",
"react-country-flag": "^3.1.0",
@@ -4344,6 +4345,15 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View File

@@ -27,6 +27,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"iconsax-react": "^0.0.8",
"jwt-decode": "^4.0.0",
"libphonenumber-js": "^1.12.10",
"react": "^19.1.0",
"react-country-flag": "^3.1.0",

View File

@@ -3,15 +3,23 @@ import './App.css';
import { LanguageManager } from '@/components/LanguageManager';
import { RouterProvider } from 'react-router-dom';
import { router } from '@/routes';
import { useAuth } from './hooks/useAuth';
import { Loading } from './components/Loading';
function App() {
return (
<>
<CssBaseline />
<LanguageManager />
<RouterProvider router={router} />
</>
);
const { authFinished } = useAuth();
if (authFinished) {
return (
<>
<CssBaseline />
<LanguageManager />
<RouterProvider router={router} />
</>
);
}
return <Loading />;
}
export default App;

View File

@@ -1,11 +1,10 @@
import { Box, IconButton, Skeleton, Typography } from '@mui/material';
import { Icon } from '@rkheftan/harmony-ui';
import { More } from 'iconsax-react';
import type { User } from './type';
import type { UserInfo } from '@/contexts/AuthContext';
interface HeaderProps {
user: User;
loading: boolean;
user: UserInfo;
}
export const Header: React.FC<HeaderProps> = ({ user, loading }) => {
@@ -20,15 +19,9 @@ export const Header: React.FC<HeaderProps> = ({ user, loading }) => {
}}
>
<Box>
<Typography variant="body1">
{loading ? (
<Skeleton variant="text" />
) : (
user.firstName + ' ' + user.lastName
)}
</Typography>
<Typography variant="body1">{user.fullName}</Typography>
<Typography variant="body2" color="textSecondary">
{loading ? <Skeleton variant="text" /> : user.phoneNumber}
{user.phoneNumber ?? user.email}
</Typography>
</Box>

View File

@@ -6,9 +6,7 @@ import { Box, useMediaQuery, useTheme } from '@mui/material';
import { Header } from './Header';
import { useState } from 'react';
import { Toolbar } from './Toolbar';
import { useApi } from '@/hooks/useApi';
import { fetchProfile } from '@/features/profile/api/settingsApi';
import type { User } from './type';
import { useAuth } from '@/hooks/useAuth';
export const Layout = () => {
const navItemConfigs = buildNavItems(appRoutes);
@@ -16,7 +14,7 @@ export const Layout = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [sideNavOpen, setSideNavOpen] = useState(false);
const { data, loading } = useApi(fetchProfile, { immediate: true });
const { userInfo } = useAuth();
return (
<Box
@@ -44,13 +42,7 @@ export const Layout = () => {
isMobile={isMobile}
sideNavOpen={sideNavOpen}
setSideNavOpen={setSideNavOpen}
loading={loading}
user={{
firstName: data?.firstName || '',
lastName: data?.lastName || '',
phoneNumber: data?.phoneNumber || '',
profileUrl: data?.profileImageUrl,
}}
user={userInfo}
/>
<Box sx={{ flex: 1, overflowY: 'auto' }}>
<Outlet />
@@ -60,32 +52,8 @@ export const Layout = () => {
<SideNav
open={sideNavOpen}
onClose={() => setSideNavOpen(false)}
header={
isMobile ? undefined : (
<Header
loading={loading}
user={{
firstName: data?.firstName || '',
lastName: data?.lastName || '',
phoneNumber: data?.phoneNumber || '',
profileUrl: data?.profileImageUrl,
}}
/>
)
}
footer={
isMobile ? (
<Header
loading={loading}
user={{
firstName: data?.firstName || '',
lastName: data?.lastName || '',
phoneNumber: data?.phoneNumber || '',
profileUrl: data?.profileImageUrl,
}}
/>
) : undefined
}
header={isMobile ? undefined : <Header user={userInfo} />}
footer={isMobile ? <Header user={userInfo} /> : undefined}
navConfig={navItemConfigs}
activePath={location.pathname + location.hash}
selectedVariant="textOnly"

View File

@@ -8,15 +8,13 @@ import {
import { Icon } from '@rkheftan/harmony-ui';
import { HambergerMenu, Menu } from 'iconsax-react';
import type { Dispatch, SetStateAction } from 'react';
import type { User } from './type';
import Logo from '../Logo';
import type { UserInfo } from '@/contexts/AuthContext';
interface ToolbarProps {
sideNavOpen: boolean;
setSideNavOpen: Dispatch<SetStateAction<boolean>>;
isMobile: boolean;
user: User;
loading: boolean;
user: UserInfo;
}
export const Toolbar: React.FC<ToolbarProps> = ({
@@ -60,17 +58,14 @@ export const Toolbar: React.FC<ToolbarProps> = ({
<Box
sx={{ display: 'flex', height: '100%', alignItems: 'center', gap: 1 }}
>
{isMobile &&
(loading ? (
<Skeleton variant="circular" />
) : (
<Avatar
sx={{ width: 32, height: 32, fontSize: '14px' }}
src={user.profileUrl}
>
{user.firstName.charAt(0) + ' ' + user.lastName.charAt(0)}
</Avatar>
))}
{isMobile && (
<Avatar
sx={{ width: 32, height: 32, fontSize: '14px' }}
src={user.picture}
>
{user.firstName.charAt(0) + ' ' + user.lastName.charAt(0)}
</Avatar>
)}
<IconButton>
<Icon Component={Menu} variant="Bold" />
</IconButton>

View File

@@ -1,8 +0,0 @@
// TODO: this type file is temporary and should replace it with the actual use type and value when api is ready
export interface User {
firstName: string;
lastName: string;
phoneNumber: string;
profileUrl?: string;
}

View File

@@ -0,0 +1,19 @@
import { CircularProgress, Stack } from '@mui/material';
import React from 'react';
export const Loading = () => {
return (
<Stack
sx={{
alignItems: 'center',
justifyContent: 'center',
backgroundColor: (t) => t.palette.background.default,
position: 'fixed',
inset: '0',
zIndex: (t) => t.zIndex.tooltip + 1,
}}
>
<CircularProgress size={50} />
</Stack>
);
};

View File

@@ -1,5 +1,16 @@
import type { GUID } from '@/types/commonTypes';
import { createContext } from 'react';
export interface UserInfo {
picture: string;
firstName: string;
lastName: string;
fullName: string;
email: string;
phoneNumber: string;
userID: GUID;
}
type AuthContextType = {
accessToken: string | null;
getToken: () => string | null;
@@ -9,6 +20,8 @@ type AuthContextType = {
expires_in: number;
}) => void;
logout: () => void;
authFinished: boolean;
userInfo: UserInfo;
};
export const AuthContext = createContext<AuthContextType | undefined>(

View File

@@ -59,3 +59,25 @@ export const generateTokenWithOtp = (request: GenerateTokenWithOTP) => {
},
);
};
export interface GenerateTokenWithGoogle {
idToken: string;
}
export const generateTokenWithGoogle = (request: GenerateTokenWithGoogle) => {
const body = new URLSearchParams();
body.set('grant_type', 'google');
body.set('client_id', import.meta.env.VITE_IDENTITY_CLIENT_ID);
body.set('scope', import.meta.env.VITE_IDENTITY_SCOPE);
body.set('idtoken', request.idToken);
return apiClient.post<GenerateTokenResponse>(
import.meta.env.VITE_IDENTITY_URL,
body.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
};

View File

@@ -1,12 +1,17 @@
import { Button } from '@mui/material';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { GoogleCodeClientResponse } from '../../types/userTypes';
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;
@@ -24,6 +29,7 @@ export const GoogleAuthentication = ({
useApi(loginOrSignUpWithGoogle);
const toast = useToast();
const clientRef = useRef<any>(null);
const auth = useAuth();
useEffect(() => {
const script = document.createElement('script');
@@ -37,16 +43,22 @@ export const GoogleAuthentication = ({
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
scope: 'openid email profile',
ux_mode: 'popup',
response_type: 'id_token',
response_type: 'code',
callback: async (resp: GoogleCodeClientResponse) => {
const res = await loginWithGoogleCall({
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({

View File

@@ -1,8 +1,31 @@
// useAuth.tsx
import { AuthContext } from '@/contexts/AuthContext';
import { AuthContext, type UserInfo } from '@/contexts/AuthContext';
import type { GenerateTokenResponse } from '@/features/authorization/api/identityAPI';
import axios from 'axios';
import { useEffect, useState, type ReactNode } from 'react';
import { jwtDecode } from 'jwt-decode';
import type { GUID } from '@/types/commonTypes';
export interface AccessTokenJwtPayload {
iss: string;
nbf: number;
iat: number;
exp: number;
scope: string[];
amr: string[];
client_id: string;
sub: GUID;
auth_time: number;
idp: string;
picture: string;
given_name: string;
family_name: string;
email: string;
name: string;
phone_number: string;
session: GUID;
jti: string;
}
export const ACCESS_TOKEN_KEY: 'access_token' = 'access_token' as const;
export const REFRESH_TOKEN_KEY: 'refresh_token' = 'refresh_token' as const;
@@ -12,20 +35,31 @@ let inMemoryToken: string | null = null;
let expiresAt = 0;
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),
);
// Initialize from sessionStorage (page reload)
useEffect(() => {
const token = sessionStorage.getItem(ACCESS_TOKEN_KEY);
const exp = Number(sessionStorage.getItem(EXPIRES_IN_KEY) || 0);
const handleAndSetToken = async () => {
const token = sessionStorage.getItem(ACCESS_TOKEN_KEY);
if (token && Date.now() < exp) {
inMemoryToken = token;
expiresAt = exp;
setAccessToken(token);
}
if (token) {
setUserInfoFromToken(token);
await refreshAccessToken();
inMemoryToken = token;
expiresAt = Number(sessionStorage.getItem(EXPIRES_IN_KEY) || 0);
setAccessToken(token);
setAuthFinished(true);
}
setAuthFinished(true);
};
handleAndSetToken();
}, []);
// Background refresh
@@ -38,8 +72,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
setAccessToken(inMemoryToken);
};
handleRefreshTokenIfNeeded();
const interval = setInterval(async () => {
await handleRefreshTokenIfNeeded();
}, 30_000);
@@ -52,6 +84,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
refresh_token: string;
expires_in: number;
}) {
setUserInfoFromToken(tokens.access_token);
inMemoryToken = tokens.access_token;
expiresAt = Date.now() + tokens.expires_in * 1000;
@@ -100,6 +133,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
expiresAt = Date.now() + result.data.expires_in * 1000;
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);
@@ -111,8 +145,33 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
}
}
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);
}
return (
<AuthContext.Provider value={{ accessToken, getToken, login, logout }}>
<AuthContext.Provider
value={{
accessToken,
getToken,
login,
logout,
authFinished,
userInfo: userInfo as UserInfo,
}}
>
{children}
</AuthContext.Provider>
);

View File

@@ -2,6 +2,7 @@ import { Suspense, type ReactNode } from 'react';
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
import { appRoutes, type RouteConfig } from './config';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import { Loading } from '@/components/Loading';
/**
* A recursive function to map our custom route config to the format
@@ -11,7 +12,7 @@ function mapRoutes(routes: RouteConfig[]): RouteObject[] {
return routes.map((route) => {
// Start with the base element, wrapped in Suspense for lazy loading
let element: ReactNode = (
<Suspense fallback={<div>Loading...</div>}>{route.element}</Suspense>
<Suspense fallback={<Loading />}>{route.element}</Suspense>
);
// Conditionally wrap the element in the specified layout