feat: Refresh token on load and user information added

This commit is contained in:
مهرزاد قدرتی
2025-08-17 13:00:25 +03:30
parent dd6e2fdd0d
commit 3410c52656
13 changed files with 137 additions and 47 deletions

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,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<HeaderProps> = ({ user }) => {
@@ -19,11 +19,9 @@ export const Header: React.FC<HeaderProps> = ({ user }) => {
}}
>
<Box>
<Typography variant="body1">
{user.firstName + ' ' + user.lastName}
</Typography>
<Typography variant="body1">{user.fullName}</Typography>
<Typography variant="body2" color="textSecondary">
{user.phoneNumber}
{user.phoneNumber ?? user.email}
</Typography>
</Box>

View File

@@ -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<User>({
firstName: 'محمد حسین',
lastName: 'برزه گر',
phoneNumber: '09123456789',
});
const { userInfo } = useAuth();
return (
<Box
@@ -46,7 +43,7 @@ export const Layout = () => {
isMobile={isMobile}
sideNavOpen={sideNavOpen}
setSideNavOpen={setSideNavOpen}
user={user}
user={userInfo}
/>
<Outlet />
</Box>
@@ -54,8 +51,8 @@ export const Layout = () => {
<SideNav
open={sideNavOpen}
onClose={() => setSideNavOpen(false)}
header={isMobile ? undefined : <Header user={user} />}
footer={isMobile ? <Header user={user} /> : 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,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<SetStateAction<boolean>>;
isMobile: boolean;
user: User;
user: UserInfo;
}
export const Toolbar: React.FC<ToolbarProps> = ({
@@ -61,7 +61,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
{isMobile && (
<Avatar
sx={{ width: 32, height: 32, fontSize: '14px' }}
src={user.profileUrl}
src={user.picture}
>
{user.firstName.charAt(0) + ' ' + user.lastName.charAt(0)}
</Avatar>

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

@@ -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,

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);
@@ -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<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 }}
>
{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