diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index d2014c9..6bb7ab2 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,9 +1,11 @@ -import { getAccessToken } from '@/lib/apiClient'; +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 new file mode 100644 index 0000000..efa3610 --- /dev/null +++ b/src/features/authorization/api/identityAPI.ts @@ -0,0 +1,59 @@ +import apiClient from '@/lib/apiClient'; + +export interface GenerateTokenWithPassword { + username: string; + password: string; +} + +export interface GenerateTokenResponse { + access_token: string; + expires_in: number; + token_type: 'Bearer'; + refresh_token: string; + scope: string; +} + +export const generateTokenWithPassword = ( + request: GenerateTokenWithPassword, +) => { + const body = new URLSearchParams(); + body.set('grant_type', 'password'); + body.set('client_id', import.meta.env.VITE_IDENTITY_CLIENT_ID); + body.set('scope', import.meta.env.VITE_IDENTITY_SCOPE); + body.set('username', request.username); + body.set('password', request.password); + + return apiClient.post( + import.meta.env.VITE_IDENTITY_URL, + body.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); +}; + +export interface GenerateTokenWithOTP { + username: string; + otp: string; +} + +export const generateTokenWithOtp = (request: GenerateTokenWithOTP) => { + const body = new URLSearchParams(); + body.set('grant_type', 'otp'); + body.set('client_id', import.meta.env.VITE_IDENTITY_CLIENT_ID); + body.set('scope', import.meta.env.VITE_IDENTITY_SCOPE); + body.set('username', request.username); + body.set('otp', request.otp); + + return apiClient.post( + import.meta.env.VITE_IDENTITY_URL, + body.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); +}; 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 f1f2271..2235c7f 100644 --- a/src/features/authorization/components/AuthenticationSteps/EnterPasswordForm.tsx +++ b/src/features/authorization/components/AuthenticationSteps/EnterPasswordForm.tsx @@ -21,6 +21,8 @@ import { import type { PasswordLoginRequest } from '../../types/userTypes'; import { Icon, useToast } from '@rkheftan/harmony-ui'; import { useApi } from '@/hooks/useApi'; +import { useAuth } from '@/hooks/useAuth'; +import { generateTokenWithPassword } from '../../api/identityAPI'; export interface EnterPasswordFormProps { onEditValue: () => void; @@ -53,8 +55,12 @@ export const EnterPasswordForm = ({ useApi(sendSmsOtp); const { loading: emailResendLoading, execute: emailResendCall } = useApi(sendEmailOtp); - const { loading: loginWithPassLoading, execute: loginWithPassCall } = - useApi(loginWithPassword); + const { + data: loginWithPassResult, + loading: loginWithPassLoading, + execute: loginWithPassCall, + } = useApi(loginWithPassword); + const auth = useAuth(); const handleBlur = () => { setInputTouched(true); @@ -75,9 +81,16 @@ export const EnterPasswordForm = ({ if (!res) return; - if (res.success) { - onLoggedIn(res.userId); + if (loginWithPassResult.success) { + const tokenRes = await generateTokenWithPassword({ + username: apiRequest.email ?? (apiRequest.phoneNumber as string), + password: apiRequest.password, + }); + auth.login({ + ...tokenRes.data, + }); + onLoggedIn(loginWithPassResult.userId); toast({ message: t('verify.youHaveSuccessfullyLoggedIn'), severity: 'success', 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 d433f73..ee63a48 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -1,9 +1,6 @@ +import { ACCESS_TOKEN_KEY } from '@/providers/AuthProvider'; import axios from 'axios'; -// Function to get the token from local storage or state management -export const ACCESS_TOKEN_KEY: 'access_token' = 'access_token' as const; -export const getAccessToken = () => sessionStorage.getItem(ACCESS_TOKEN_KEY); - const apiClient = axios.create({ // Define the base URL for all API requests baseURL: import.meta.env.VITE_API_URL, @@ -22,7 +19,7 @@ const apiClient = axios.create({ // This runs BEFORE each request is sent apiClient.interceptors.request.use( (config) => { - const token = getAccessToken(); + 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} + + ); +};