diff --git a/.env b/.env index b5ec710..cfdb88a 100644 --- a/.env +++ b/.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 diff --git a/package-lock.json b/package-lock.json index 20a2b64..1f0ab8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4215562..c803456 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index ec7f7b6..12661e1 100644 --- a/src/App.tsx +++ b/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 ( - <> - - - - - ); + const { authFinished } = useAuth(); + + if (authFinished) { + return ( + <> + + + + + ); + } + + return ; } export default App; diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index 39f4eb9..9121b43 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -1,10 +1,10 @@ import { Box, IconButton, 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; + user: UserInfo; } export const Header: React.FC = ({ user }) => { @@ -19,11 +19,9 @@ export const Header: React.FC = ({ user }) => { }} > - - {user.firstName + ' ' + user.lastName} - + {user.fullName} - {user.phoneNumber} + {user.phoneNumber ?? user.email} diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 2652dfe..d2ac9bb 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -6,7 +6,7 @@ import { Box, useMediaQuery, useTheme } from '@mui/material'; import { Header } from './Header'; import { useState } from 'react'; import { Toolbar } from './Toolbar'; -import type { User } from './type'; +import { useAuth } from '@/hooks/useAuth'; export const Layout = () => { const navItemConfigs = buildNavItems(appRoutes); @@ -14,11 +14,8 @@ export const Layout = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const [sideNavOpen, setSideNavOpen] = useState(false); - const [user] = useState({ - firstName: 'محمد حسین', - lastName: 'برزه گر', - phoneNumber: '09123456789', - }); + + const { userInfo } = useAuth(); return ( { isMobile={isMobile} sideNavOpen={sideNavOpen} setSideNavOpen={setSideNavOpen} - user={user} + user={userInfo} /> @@ -54,8 +51,8 @@ export const Layout = () => { setSideNavOpen(false)} - header={isMobile ? undefined :
} - footer={isMobile ?
: undefined} + header={isMobile ? undefined :
} + footer={isMobile ?
: undefined} navConfig={navItemConfigs} activePath={location.pathname + location.hash} selectedVariant="textOnly" diff --git a/src/components/Layout/Toolbar.tsx b/src/components/Layout/Toolbar.tsx index 46b482e..1b6ddea 100644 --- a/src/components/Layout/Toolbar.tsx +++ b/src/components/Layout/Toolbar.tsx @@ -8,13 +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 type { UserInfo } from '@/contexts/AuthContext'; interface ToolbarProps { sideNavOpen: boolean; setSideNavOpen: Dispatch>; isMobile: boolean; - user: User; + user: UserInfo; } export const Toolbar: React.FC = ({ @@ -61,7 +61,7 @@ export const Toolbar: React.FC = ({ {isMobile && ( {user.firstName.charAt(0) + ' ' + user.lastName.charAt(0)} diff --git a/src/components/Layout/type.ts b/src/components/Layout/type.ts index ab31407..e69de29 100644 --- a/src/components/Layout/type.ts +++ b/src/components/Layout/type.ts @@ -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; -} diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx new file mode 100644 index 0000000..1572b6a --- /dev/null +++ b/src/components/Loading.tsx @@ -0,0 +1,19 @@ +import { CircularProgress, Stack } from '@mui/material'; +import React from 'react'; + +export const Loading = () => { + return ( + t.palette.background.default, + position: 'fixed', + inset: '0', + zIndex: (t) => t.zIndex.tooltip + 1, + }} + > + + + ); +}; diff --git a/src/contexts/AuthContext.ts b/src/contexts/AuthContext.ts index 03d9528..c240809 100644 --- a/src/contexts/AuthContext.ts +++ b/src/contexts/AuthContext.ts @@ -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( diff --git a/src/features/authorization/components/AuthenticationSteps/GoogleAuthentication.tsx b/src/features/authorization/components/AuthenticationSteps/GoogleAuthentication.tsx index c343a35..d517362 100644 --- a/src/features/authorization/components/AuthenticationSteps/GoogleAuthentication.tsx +++ b/src/features/authorization/components/AuthenticationSteps/GoogleAuthentication.tsx @@ -37,7 +37,7 @@ 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({ idToken: resp.id_token, diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx index 7b2ae27..b18aecb 100644 --- a/src/providers/AuthProvider.tsx +++ b/src/providers/AuthProvider.tsx @@ -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(null); + const [authFinished, setAuthFinished] = useState(false); const [accessToken, setAccessToken] = useState( 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); @@ -100,6 +132,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 +144,26 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { } } + function setUserInfoFromToken(token: string) { + const accessTokenPayload = jwtDecode(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 ( - + {children} ); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 53b7482..9980e7d 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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 = ( - Loading...}>{route.element} + }>{route.element} ); // Conditionally wrap the element in the specified layout