@@ -1,9 +1,11 @@
|
|||||||
import { getAccessToken } from '@/lib/apiClient';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { type PropsWithChildren } from 'react';
|
import { type PropsWithChildren } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children }: PropsWithChildren) => {
|
export const ProtectedRoute = ({ children }: PropsWithChildren) => {
|
||||||
if (!getAccessToken()) {
|
const auth = useAuth();
|
||||||
|
|
||||||
|
if (!auth.accessToken) {
|
||||||
// If no token, redirect to login page
|
// If no token, redirect to login page
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/contexts/AuthContext.ts
Normal file
16
src/contexts/AuthContext.ts
Normal file
@@ -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<AuthContextType | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
59
src/features/authorization/api/identityAPI.ts
Normal file
59
src/features/authorization/api/identityAPI.ts
Normal file
@@ -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<GenerateTokenResponse>(
|
||||||
|
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<GenerateTokenResponse>(
|
||||||
|
import.meta.env.VITE_IDENTITY_URL,
|
||||||
|
body.toString(),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -62,7 +62,7 @@ export const AuthenticationSteps = (): JSX.Element => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePhoneNumberVerified = () => {
|
const handlePhoneNumberVerified = () => {
|
||||||
redirectToReturnUrl();
|
navigate('/signup');
|
||||||
};
|
};
|
||||||
|
|
||||||
const redirectToReturnUrl = () => {
|
const redirectToReturnUrl = () => {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
import type { PasswordLoginRequest } from '../../types/userTypes';
|
import type { PasswordLoginRequest } from '../../types/userTypes';
|
||||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||||
import { useApi } from '@/hooks/useApi';
|
import { useApi } from '@/hooks/useApi';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { generateTokenWithPassword } from '../../api/identityAPI';
|
||||||
|
|
||||||
export interface EnterPasswordFormProps {
|
export interface EnterPasswordFormProps {
|
||||||
onEditValue: () => void;
|
onEditValue: () => void;
|
||||||
@@ -53,8 +55,12 @@ export const EnterPasswordForm = ({
|
|||||||
useApi(sendSmsOtp);
|
useApi(sendSmsOtp);
|
||||||
const { loading: emailResendLoading, execute: emailResendCall } =
|
const { loading: emailResendLoading, execute: emailResendCall } =
|
||||||
useApi(sendEmailOtp);
|
useApi(sendEmailOtp);
|
||||||
const { loading: loginWithPassLoading, execute: loginWithPassCall } =
|
const {
|
||||||
useApi(loginWithPassword);
|
data: loginWithPassResult,
|
||||||
|
loading: loginWithPassLoading,
|
||||||
|
execute: loginWithPassCall,
|
||||||
|
} = useApi(loginWithPassword);
|
||||||
|
const auth = useAuth();
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
setInputTouched(true);
|
setInputTouched(true);
|
||||||
@@ -75,9 +81,16 @@ export const EnterPasswordForm = ({
|
|||||||
|
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
|
|
||||||
if (res.success) {
|
if (loginWithPassResult.success) {
|
||||||
onLoggedIn(res.userId);
|
const tokenRes = await generateTokenWithPassword({
|
||||||
|
username: apiRequest.email ?? (apiRequest.phoneNumber as string),
|
||||||
|
password: apiRequest.password,
|
||||||
|
});
|
||||||
|
auth.login({
|
||||||
|
...tokenRes.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
onLoggedIn(loginWithPassResult.userId);
|
||||||
toast({
|
toast({
|
||||||
message: t('verify.youHaveSuccessfullyLoggedIn'),
|
message: t('verify.youHaveSuccessfullyLoggedIn'),
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
|
|||||||
8
src/hooks/useAuth.ts
Normal file
8
src/hooks/useAuth.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
|
import { ACCESS_TOKEN_KEY } from '@/providers/AuthProvider';
|
||||||
import axios from 'axios';
|
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({
|
const apiClient = axios.create({
|
||||||
// Define the base URL for all API requests
|
// Define the base URL for all API requests
|
||||||
baseURL: import.meta.env.VITE_API_URL,
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
@@ -22,7 +19,7 @@ const apiClient = axios.create({
|
|||||||
// This runs BEFORE each request is sent
|
// This runs BEFORE each request is sent
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = getAccessToken();
|
const token = sessionStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
if (token) {
|
if (token) {
|
||||||
// Add the authorization token to the headers
|
// Add the authorization token to the headers
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import i18n from '@/config/i18n';
|
|||||||
import { CustomThemeProvider } from './CustomThemeProvider';
|
import { CustomThemeProvider } from './CustomThemeProvider';
|
||||||
import { RtlProvider } from './RtlProvider';
|
import { RtlProvider } from './RtlProvider';
|
||||||
import { ToastProvider } from '@rkheftan/harmony-ui';
|
import { ToastProvider } from '@rkheftan/harmony-ui';
|
||||||
|
import { AuthProvider } from './AuthProvider';
|
||||||
|
|
||||||
export const AppProviders: React.FC<{ children: React.ReactNode }> = ({
|
export const AppProviders: React.FC<{ children: React.ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
@@ -12,7 +13,9 @@ export const AppProviders: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<RtlProvider>
|
<RtlProvider>
|
||||||
<CustomThemeProvider>
|
<CustomThemeProvider>
|
||||||
<ToastProvider>{children}</ToastProvider>
|
<AuthProvider>
|
||||||
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
</CustomThemeProvider>
|
</CustomThemeProvider>
|
||||||
</RtlProvider>
|
</RtlProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
|
|||||||
119
src/providers/AuthProvider.tsx
Normal file
119
src/providers/AuthProvider.tsx
Normal file
@@ -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<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);
|
||||||
|
|
||||||
|
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<GenerateTokenResponse>(
|
||||||
|
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 (
|
||||||
|
<AuthContext.Provider value={{ accessToken, getToken, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user