From 8a41aad9b97c681d380721cab62883f217ce26ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=85=D9=87=D8=B1=D8=B2=D8=A7=D8=AF=20=D9=82=D8=AF=D8=B1?= =?UTF-8?q?=D8=AA=DB=8C?= Date: Sat, 16 Aug 2025 15:42:58 +0330 Subject: [PATCH] Auth provider added to the project --- .env | 2 +- src/components/ProtectedRoute.tsx | 6 +- src/contexts/AuthContext.ts | 16 +++ src/features/authorization/api/identityAPI.ts | 75 +---------- .../AuthenticationSteps.tsx | 2 +- .../AuthenticationSteps/EnterPasswordForm.tsx | 11 +- src/hooks/useAuth.ts | 8 ++ src/lib/apiClient.ts | 6 +- src/providers/AppProvider.tsx | 5 +- src/providers/AuthProvider.tsx | 119 ++++++++++++++++++ 10 files changed, 165 insertions(+), 85 deletions(-) create mode 100644 src/contexts/AuthContext.ts create mode 100644 src/hooks/useAuth.ts create mode 100644 src/providers/AuthProvider.tsx diff --git a/.env b/.env index b8e0f03..b5ec710 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ VITE_GOOGLE_CLIENT_ID=https://272098283932-bft2gvlgjn8edopg0lnqjq1i9ekdmipt.apps.googleusercontent.com/ -VITE_DEFUALT_AUTH_RETURN_URL=/signup +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 VITE_IDENTITY_CLIENT_ID=harmony_identity diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 0550805..6bb7ab2 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,9 +1,11 @@ -import { getAccessToken } from '@/features/authorization/api/identityAPI'; +import { useAuth } from '@/hooks/useAuth'; import { type PropsWithChildren } from 'react'; import { Navigate } from 'react-router-dom'; export const ProtectedRoute = ({ children }: PropsWithChildren) => { - if (!getAccessToken()) { + const auth = useAuth(); + + if (!auth.accessToken) { // If no token, redirect to login page return ; } diff --git a/src/contexts/AuthContext.ts b/src/contexts/AuthContext.ts new file mode 100644 index 0000000..03d9528 --- /dev/null +++ b/src/contexts/AuthContext.ts @@ -0,0 +1,16 @@ +import { createContext } from 'react'; + +type AuthContextType = { + accessToken: string | null; + getToken: () => string | null; + login: (tokens: { + access_token: string; + refresh_token: string; + expires_in: number; + }) => void; + logout: () => void; +}; + +export const AuthContext = createContext( + undefined, +); diff --git a/src/features/authorization/api/identityAPI.ts b/src/features/authorization/api/identityAPI.ts index f06b7d8..0d20e01 100644 --- a/src/features/authorization/api/identityAPI.ts +++ b/src/features/authorization/api/identityAPI.ts @@ -18,7 +18,7 @@ const ACCESS_TOKEN_KEY: 'access_token' = 'access_token' as const; const REFRESH_TOKEN_KEY: 'refresh_token' = 'refresh_token' as const; const EXPIRES_IN_KEY: 'expires_in' = 'expires_in' as const; -export const generateToken = async (request: GenerateToken) => { +export const generateTokenWithPassword = (request: GenerateToken) => { const body = new URLSearchParams(); body.set('grant_type', 'password'); body.set('client_id', import.meta.env.VITE_IDENTITY_CLIENT_ID); @@ -26,7 +26,7 @@ export const generateToken = async (request: GenerateToken) => { body.set('username', request.username); body.set('password', request.password); - const result = await apiClient.post( + return apiClient.post( import.meta.env.VITE_IDENTITY_URL, body.toString(), { @@ -35,75 +35,4 @@ export const generateToken = async (request: GenerateToken) => { }, }, ); - - sessionStorage.setItem(ACCESS_TOKEN_KEY, result.data.access_token); - sessionStorage.setItem(REFRESH_TOKEN_KEY, result.data.refresh_token); - sessionStorage.setItem( - EXPIRES_IN_KEY, - String(Date.now() + result.data.expires_in * 1000), - ); }; - -export const getAccessToken = () => { - const sessionToken = sessionStorage.getItem(ACCESS_TOKEN_KEY); - - if (sessionToken) return null; - - const expiresAt = Number(sessionStorage.getItem(EXPIRES_IN_KEY)); - const now = Date.now(); - - if (now < expiresAt) { - return sessionToken; - } else { - return refreshAccessToken(); - } -}; - -const refreshAccessToken = async () => { - const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_KEY); - if (!refreshToken) return null; - - const result = await axios.post( - import.meta.env.VITE_IDENTITY_URL, - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: 'harmony_identity', // from your token payload - }), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }, - ); - - if (result.data.access_token) { - sessionStorage.setItem(ACCESS_TOKEN_KEY, result.data.access_token); - sessionStorage.setItem( - EXPIRES_IN_KEY, - String(Date.now() + result.data.expires_in * 1000), - ); - return result.data.access_token; - } - - return null; -}; - -// export const generateToken = (request: any) => { -// const body = new URLSearchParams(); -// body.set('grant_type', request.password ? 'password' : 'otp'); -// body.set('client_id', import.meta.env.VITE_IDENTITY_CLIENT_ID); -// body.set('scope', import.meta.env.VITE_IDENTITY_SCOPE); -// if (request.phoneNumber) body.set('phonenumber', request.phoneNumber); -// if (request.email) body.set('email', request.email); -// if (request.password) body.set('password', request.password); -// if (request.otp) body.set('otp', request.otp); - -// return axios -// .post(import.meta.env.VITE_IDENTITY_URL, body.toString(), { -// headers: { -// 'Content-Type': 'application/x-www-form-urlencoded', -// }, -// }) -// .then((result) => console.log(result)); -// }; diff --git a/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx b/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx index e0ff695..ad240b8 100644 --- a/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx +++ b/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx @@ -62,7 +62,7 @@ export const AuthenticationSteps = (): JSX.Element => { }; const handlePhoneNumberVerified = () => { - redirectToReturnUrl(); + navigate('/signup'); }; const redirectToReturnUrl = () => { diff --git a/src/features/authorization/components/AuthenticationSteps/EnterPasswordForm.tsx b/src/features/authorization/components/AuthenticationSteps/EnterPasswordForm.tsx index 59de5b2..26d262c 100644 --- a/src/features/authorization/components/AuthenticationSteps/EnterPasswordForm.tsx +++ b/src/features/authorization/components/AuthenticationSteps/EnterPasswordForm.tsx @@ -21,7 +21,8 @@ import { import type { PasswordLoginRequest } from '../../types/userTypes'; import { Icon, useToast } from '@rkheftan/harmony-ui'; import { useApi } from '@/hooks/useApi'; -import { generateToken } from '../../api/identityAPI'; +import { useAuth } from '@/hooks/useAuth'; +import { generateTokenWithPassword } from '../../api/identityAPI'; export interface EnterPasswordFormProps { onEditValue: () => void; @@ -59,6 +60,7 @@ export const EnterPasswordForm = ({ loading: loginWithPassLoading, execute: loginWithPassCall, } = useApi(loginWithPassword); + const auth = useAuth(); const handleBlur = () => { setInputTouched(true); @@ -80,12 +82,13 @@ export const EnterPasswordForm = ({ if (!loginWithPassResult) return; if (loginWithPassResult.success) { - console.log('logged in'); - - await generateToken({ + const tokenRes = await generateTokenWithPassword({ username: apiRequest.email ?? (apiRequest.phoneNumber as string), password: apiRequest.password, }); + auth.login({ + ...tokenRes.data, + }); onLoggedIn(loginWithPassResult.userId); diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..fb4705a --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,8 @@ +import { AuthContext } from '@/contexts/AuthContext'; +import { useContext } from 'react'; + +export const useAuth = () => { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); + return ctx; +}; diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 346a0b5..ee63a48 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -1,4 +1,4 @@ -import { getAccessToken } from '@/features/authorization/api/identityAPI'; +import { ACCESS_TOKEN_KEY } from '@/providers/AuthProvider'; import axios from 'axios'; const apiClient = axios.create({ @@ -18,8 +18,8 @@ const apiClient = axios.create({ // --- Request Interceptor --- // This runs BEFORE each request is sent apiClient.interceptors.request.use( - async (config) => { - const token = await getAccessToken(); + (config) => { + const token = sessionStorage.getItem(ACCESS_TOKEN_KEY); if (token) { // Add the authorization token to the headers config.headers.Authorization = `Bearer ${token}`; diff --git a/src/providers/AppProvider.tsx b/src/providers/AppProvider.tsx index f68f2ae..66efe98 100644 --- a/src/providers/AppProvider.tsx +++ b/src/providers/AppProvider.tsx @@ -4,6 +4,7 @@ import i18n from '@/config/i18n'; import { CustomThemeProvider } from './CustomThemeProvider'; import { RtlProvider } from './RtlProvider'; import { ToastProvider } from '@rkheftan/harmony-ui'; +import { AuthProvider } from './AuthProvider'; export const AppProviders: React.FC<{ children: React.ReactNode }> = ({ children, @@ -12,7 +13,9 @@ export const AppProviders: React.FC<{ children: React.ReactNode }> = ({ - {children} + + {children} + diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx new file mode 100644 index 0000000..7b2ae27 --- /dev/null +++ b/src/providers/AuthProvider.tsx @@ -0,0 +1,119 @@ +// useAuth.tsx +import { AuthContext } from '@/contexts/AuthContext'; +import type { GenerateTokenResponse } from '@/features/authorization/api/identityAPI'; +import axios from 'axios'; +import { useEffect, useState, type ReactNode } from 'react'; + +export const ACCESS_TOKEN_KEY: 'access_token' = 'access_token' as const; +export const REFRESH_TOKEN_KEY: 'refresh_token' = 'refresh_token' as const; +export const EXPIRES_IN_KEY: 'expires_in' = 'expires_in' as const; + +let inMemoryToken: string | null = null; +let expiresAt = 0; + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + 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); + + if (token && Date.now() < exp) { + inMemoryToken = token; + expiresAt = exp; + setAccessToken(token); + } + }, []); + + // Background refresh + useEffect(() => { + const handleRefreshTokenIfNeeded = async () => { + if (!inMemoryToken) return; + if (Date.now() < expiresAt - 60_000) return; // still valid (buffer) + + await refreshAccessToken(); + setAccessToken(inMemoryToken); + }; + + handleRefreshTokenIfNeeded(); + + const interval = setInterval(async () => { + await handleRefreshTokenIfNeeded(); + }, 30_000); + + return () => clearInterval(interval); + }, []); + + function login(tokens: { + access_token: string; + refresh_token: string; + expires_in: number; + }) { + inMemoryToken = tokens.access_token; + expiresAt = Date.now() + tokens.expires_in * 1000; + + sessionStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token); + sessionStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token); + sessionStorage.setItem(EXPIRES_IN_KEY, String(expiresAt)); + + setAccessToken(tokens.access_token); + } + + function logout() { + inMemoryToken = null; + expiresAt = 0; + sessionStorage.clear(); + setAccessToken(null); + } + + function getToken() { + return inMemoryToken; + } + + async function refreshAccessToken() { + const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_KEY); + if (!refreshToken) { + logout(); + return; + } + + try { + const result = await axios.post( + import.meta.env.VITE_IDENTITY_URL, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: import.meta.env.VITE_IDENTITY_CLIENT_ID, // from your token payload + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + + if (result.data.access_token) { + inMemoryToken = result.data.access_token; + expiresAt = Date.now() + result.data.expires_in * 1000; + + sessionStorage.setItem(ACCESS_TOKEN_KEY, inMemoryToken as string); + sessionStorage.setItem(EXPIRES_IN_KEY, String(expiresAt)); + + setAccessToken(inMemoryToken); + } else { + logout(); + } + } catch { + logout(); + } + } + + return ( + + {children} + + ); +};