Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/components/examples/TokenRefreshExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
'use client';

import React, { useState, useEffect } from 'react';
import { useTokenRefresh } from '@/hooks/useTokenRefresh';
import { useAppSelector } from '@/store';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, AlertCircle, CheckCircle } from 'lucide-react';

export function TokenRefreshExample() {
const { refreshToken, refreshTokenIfNeeded, isRefreshing, refreshError, isTokenExpired } = useTokenRefresh();
const { token, isAuthenticated } = useAppSelector((state) => state.auth);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);

useEffect(() => {
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
setLastRefreshTime(new Date(payload.iat * 1000));
} catch (error) {
console.error('Error parsing token:', error);
}
}
}, [token]);

const handleManualRefresh = async () => {
const success = await refreshToken();
if (success) {
setLastRefreshTime(new Date());
}
};

const handleAutoRefresh = async () => {
const success = await refreshTokenIfNeeded();
if (success) {
setLastRefreshTime(new Date());
}
};

const getTokenStatus = () => {
if (!token) return { status: 'No Token', color: 'destructive' };
if (isTokenExpired()) return { status: 'Expired', color: 'destructive' };
return { status: 'Valid', color: 'default' };
};

const tokenStatus = getTokenStatus();

return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RefreshCw className={`h-5 w-5 ${isRefreshing ? 'animate-spin' : ''}`} />
JWT Token Auto-Refresh
</CardTitle>
<CardDescription>
Demonstrates automatic JWT token refresh functionality
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Authentication Status */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Authentication:</span>
<Badge variant={isAuthenticated ? 'default' : 'destructive'}>
{isAuthenticated ? 'Authenticated' : 'Not Authenticated'}
</Badge>
</div>

{/* Token Status */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Token Status:</span>
<Badge variant={tokenStatus.color as any}>
{tokenStatus.status}
</Badge>
</div>

{/* Refresh Status */}
{isRefreshing && (
<div className="flex items-center gap-2 text-sm text-blue-600">
<RefreshCw className="h-4 w-4 animate-spin" />
Refreshing token...
</div>
)}

{/* Error Display */}
{refreshError && (
<div className="flex items-center gap-2 text-sm text-red-600">
<AlertCircle className="h-4 w-4" />
{refreshError}
</div>
)}

{/* Success Message */}
{lastRefreshTime && !refreshError && !isRefreshing && (
<div className="flex items-center gap-2 text-sm text-green-600">
<CheckCircle className="h-4 w-4" />
Last refreshed: {lastRefreshTime.toLocaleTimeString()}
</div>
)}

{/* Token Info */}
{token && (
<div className="space-y-2">
<div className="text-sm">
<span className="font-medium">Token Preview:</span>
<div className="mt-1 p-2 bg-gray-100 rounded text-xs font-mono break-all">
{token.substring(0, 50)}...
</div>
</div>
</div>
)}

{/* Action Buttons */}
<div className="space-y-2">
<Button
onClick={handleManualRefresh}
disabled={isRefreshing || !isAuthenticated}
className="w-full"
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
Manual Refresh
</Button>

<Button
onClick={handleAutoRefresh}
disabled={isRefreshing || !isAuthenticated}
variant="outline"
className="w-full"
>
Auto Refresh (if needed)
</Button>
</div>

{/* Instructions */}
<div className="text-xs text-gray-600 space-y-1">
<p>β€’ <strong>Manual Refresh:</strong> Forces a token refresh</p>
<p>β€’ <strong>Auto Refresh:</strong> Only refreshes if token is expired</p>
<p>β€’ The API service automatically refreshes tokens on 401 errors</p>
</div>
</CardContent>
</Card>
);
}
49 changes: 49 additions & 0 deletions src/hooks/useTokenRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useDispatch } from 'react-redux';
import { useCallback } from 'react';
import { refreshToken } from '@/store/slices/authSlice';
import { useAppSelector } from '@/store';
import type { AppDispatch } from '@/store';

export function useTokenRefresh() {
const dispatch = useDispatch<AppDispatch>();
const { isLoading, error, token } = useAppSelector((state) => state.auth);

const refreshAccessToken = useCallback(async () => {
try {
const result = await dispatch(refreshToken()).unwrap();
return result;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}, [dispatch]);

const isTokenExpired = useCallback(() => {
if (!token) return true;

try {
// Decode JWT token to check expiration
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Date.now() / 1000;
return payload.exp < currentTime;
} catch (error) {
console.error('Error decoding token:', error);
return true;
}
}, [token]);

const refreshTokenIfNeeded = useCallback(async () => {
if (isTokenExpired()) {
return await refreshAccessToken();
}
return true;
}, [isTokenExpired, refreshAccessToken]);

return {
refreshToken: refreshAccessToken,
refreshTokenIfNeeded,
isRefreshing: isLoading,
refreshError: error,
isTokenExpired,
};
}
60 changes: 55 additions & 5 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Conversation
} from '@/types';
import agentService from './agentService';
import { tokenRefreshService } from './tokenRefreshService';

class ApiService {
private api: AxiosInstance;
Expand Down Expand Up @@ -43,18 +44,48 @@ class ApiService {
}
);

// Response interceptor for error handling
// Response interceptor for automatic token refresh
this.api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
async (error) => {
const originalRequest = error.config;

// If error is not 401 or original request already tried refresh, reject
if (error.response?.status !== 401 || originalRequest._retry) {
// Handle 401 by clearing token and redirecting to login
if (error.response?.status === 401) {
this.clearToken();
if (typeof window !== 'undefined') {
window.location.href = '/auth/login';
}
}
return Promise.reject(error);
}

// Mark that we're retrying
originalRequest._retry = true;

try {
// Attempt to refresh the token
const response = await tokenRefreshService.refreshToken(this.api.defaults.baseURL as string);
const newToken = response.token;

// Update the token in the service and original request
this.setToken(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;

// Retry the original request
return this.api(originalRequest);
} catch (refreshError) {
// Refresh failed, clear token and redirect to login
this.clearToken();
// Redirect to login or dispatch logout action

if (typeof window !== 'undefined') {
window.location.href = '/auth/login';
}

return Promise.reject(refreshError);
}
return Promise.reject(error);
}
);
}
Expand Down Expand Up @@ -169,6 +200,25 @@ class ApiService {
}
}

async refreshToken(): Promise<{ token: string }> {
// Use direct axios call to avoid interceptor recursion
const response = await axios.post<{ token: string }>(
`${this.api.defaults.baseURL}/auth/refresh`,
{},
{
headers: {
'Content-Type': 'application/json',
// Don't send Authorization header for refresh to avoid circular dependency
},
}
);

if (response.data?.token) {
this.setToken(response.data.token);
}
return response.data;
}

// Protected endpoints
async getProfile(): Promise<ApiResponse<User>> {
const response = await this.api.get<ApiResponse<User>>('/auth/profile');
Expand Down
65 changes: 65 additions & 0 deletions src/services/tokenRefreshService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import axios from 'axios';

export class TokenRefreshService {
private static instance: TokenRefreshService;
private isRefreshing = false;
private failedQueue: Array<{
resolve: (value?: any) => void;
reject: (reason?: any) => void;
}> = [];

static getInstance(): TokenRefreshService {
if (!TokenRefreshService.instance) {
TokenRefreshService.instance = new TokenRefreshService();
}
return TokenRefreshService.instance;
}

private processQueue = (error: any, token: string | null = null) => {
this.failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token);
}
});

this.failedQueue = [];
};

async refreshToken(apiBaseUrl: string): Promise<{ token: string }> {
if (this.isRefreshing) {
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
});
}

this.isRefreshing = true;

try {
const response = await axios.post<{ token: string }>(
`${apiBaseUrl}/auth/refresh`,
{},
{
headers: {
'Content-Type': 'application/json',
},
}
);

this.processQueue(null, response.data.token);
return response.data;
} catch (error) {
this.processQueue(error, null);
throw error;
} finally {
this.isRefreshing = false;
}
}

isTokenRefreshing(): boolean {
return this.isRefreshing;
}
}

export const tokenRefreshService = TokenRefreshService.getInstance();
Loading
Loading