Merge pull request #19 from rkheftan/chore/token

Chore/token
This commit is contained in:
SajadMRjl
2025-08-16 16:05:09 +03:30
committed by GitHub
9 changed files with 230 additions and 13 deletions

View File

@@ -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 <Navigate to="/login" replace />;
}

View 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,
);

View 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',
},
},
);
};

View File

@@ -62,7 +62,7 @@ export const AuthenticationSteps = (): JSX.Element => {
};
const handlePhoneNumberVerified = () => {
redirectToReturnUrl();
navigate('/signup');
};
const redirectToReturnUrl = () => {

View File

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

8
src/hooks/useAuth.ts Normal file
View 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;
};

View File

@@ -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}`;

View File

@@ -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 }> = ({
<I18nextProvider i18n={i18n}>
<RtlProvider>
<CustomThemeProvider>
<ToastProvider>{children}</ToastProvider>
<AuthProvider>
<ToastProvider>{children}</ToastProvider>
</AuthProvider>
</CustomThemeProvider>
</RtlProvider>
</I18nextProvider>

View 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>
);
};