Merge pull request #21 from rkheftan/chore/google-authentication
Chore/google authentication
This commit is contained in:
2
.env
2
.env
@@ -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
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
22
src/App.tsx
22
src/App.tsx
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
19
src/components/Loading.tsx
Normal file
19
src/components/Loading.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>(
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user