feat: Refresh token on load and user information added
This commit is contained in:
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,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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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