Skip to content

Commit 941d07e

Browse files
authored
feat: Integración de refresh token y manejo de sesiones (#156)
* feat: Implement silent refresh interceptor and fix course pricing * fix: Ngrok redirection problems * fix: Problems with refresh token
1 parent a031f2e commit 941d07e

8 files changed

Lines changed: 241 additions & 152 deletions

File tree

src/api/apiClient.ts

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,110 @@ import axios from 'axios';
22

33
export const TOKEN_STORAGE_KEY = 'jwt_token';
44

5+
interface FailedRequest {
6+
resolve: (token: string | null) => void;
7+
reject: (error: unknown) => void;
8+
}
9+
10+
// Create axios instance with base configuration
511
const apiClient = axios.create({
612
baseURL: import.meta.env.VITE_API_BASE_URL,
13+
withCredentials: true,
14+
headers: {
15+
'Content-Type': 'application/json',
16+
},
717
});
818

9-
// console.log('API Base URL:', import.meta.env.VITE_API_BASE_URL);
10-
19+
// Request Interceptor: Attach Access Token if available
1120
apiClient.interceptors.request.use(
1221
(config) => {
1322
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
14-
1523
if (token) {
1624
config.headers.Authorization = `Bearer ${token}`;
1725
}
18-
1926
return config;
2027
},
2128
(error) => {
2229
return Promise.reject(error);
2330
}
2431
);
2532

26-
export default apiClient;
33+
// Variables to handle concurrent refresh requests
34+
let isRefreshing = false;
35+
let failedQueue: FailedRequest[] = [];
36+
37+
// Helper to process the queue of failed requests
38+
const processQueue = (error: Error | null, token: string | null = null) => {
39+
failedQueue.forEach((prom) => {
40+
if (error) {
41+
prom.reject(error);
42+
} else {
43+
prom.resolve(token);
44+
}
45+
});
46+
failedQueue = [];
47+
};
48+
49+
// Response Interceptor: Handle 401 errors (Expired Access Token)
50+
apiClient.interceptors.response.use(
51+
(response) => response,
52+
async (error) => {
53+
const originalRequest = error.config;
54+
55+
if (
56+
error.response?.status === 401 &&
57+
!originalRequest._retry &&
58+
!originalRequest.url.includes('/auth/login') &&
59+
!originalRequest.url.includes('/auth/refresh')
60+
) {
61+
62+
if (isRefreshing) {
63+
return new Promise<string | null>(function (resolve, reject) {
64+
failedQueue.push({ resolve, reject });
65+
})
66+
.then((token) => {
67+
if (token) {
68+
originalRequest.headers['Authorization'] = 'Bearer ' + token;
69+
}
70+
return apiClient(originalRequest);
71+
})
72+
.catch((err) => {
73+
return Promise.reject(err);
74+
});
75+
}
76+
77+
originalRequest._retry = true;
78+
isRefreshing = true;
79+
80+
try {
81+
const { data } = await apiClient.post('/auth/refresh');
82+
const newToken = data.data.token;
83+
84+
localStorage.setItem(TOKEN_STORAGE_KEY, newToken);
85+
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
86+
87+
processQueue(null, newToken);
88+
89+
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
90+
return apiClient(originalRequest);
91+
92+
} catch (refreshError: unknown) {
93+
processQueue(refreshError as Error, null);
94+
95+
localStorage.removeItem(TOKEN_STORAGE_KEY);
96+
97+
if (window.location.pathname !== '/login') {
98+
window.location.href = '/login';
99+
}
100+
101+
return Promise.reject(refreshError);
102+
} finally {
103+
isRefreshing = false;
104+
}
105+
}
106+
107+
return Promise.reject(error);
108+
}
109+
);
110+
111+
export default apiClient;

src/api/services/auth.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const register = async (payload: RegisterPayload): Promise<User> => {
4848
// Gets the current user's profile
4949
const getProfile = async (): Promise<User> => {
5050
try {
51-
const response = await apiClient.get<ApiResponse<User>>('/auth/profile');
51+
const response = await apiClient.get<ApiResponse<User>>('/users/me');
5252
return response.data.data;
5353
} catch (error) {
5454
console.error('Failed to get user profile', error);
@@ -66,10 +66,16 @@ const resetPassword = async (token: string, password_plaintext: string): Promise
6666
await apiClient.post('/auth/reset-password', { token, password_plaintext });
6767
};
6868

69+
// Logout: Calls backend to clear HttpOnly cookie
70+
const logout = async (): Promise<void> => {
71+
await apiClient.post('/auth/logout');
72+
};
73+
6974
export const authService = {
7075
login,
7176
register,
7277
getProfile,
7378
forgotPassword,
7479
resetPassword,
80+
logout,
7581
};

src/contexts/AuthProvider.tsx

Lines changed: 56 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
33
import { useQueryClient } from '@tanstack/react-query';
44
import type { User } from '../types/entities.ts';
55
import { userService } from '../api/services/user.service.ts';
6+
import { authService } from '../api/services/auth.service.ts';
67
import { AuthContext } from './AuthContext';
78
import apiClient, { TOKEN_STORAGE_KEY } from '../api/apiClient';
89

@@ -14,39 +15,17 @@ export interface AuthContextType {
1415
logout: () => void;
1516
}
1617

17-
// This provider handles authentication and user state globally.
1818
export const AuthProvider = ({ children }: { children: ReactNode }) => {
19-
// State for the authenticated user and to know if we're loading data.
2019
const [user, setUser] = useState<User | null>(null);
2120
const [isLoading, setIsLoading] = useState(true);
2221
const queryClient = useQueryClient();
2322

24-
// Function to get the user's profile and update the global state.
2523
const fetchProfileAndSetUser = useCallback(async () => {
26-
try {
27-
const userData = await userService.getProfile();
28-
setUser(userData);
29-
} catch (error) {
30-
// If it fails, we remove the token and log out the user.
31-
console.error('Failed to fetch profile, logging out.', error);
32-
localStorage.removeItem(TOKEN_STORAGE_KEY);
33-
setUser(null);
34-
}
24+
const userData = await userService.getProfile();
25+
setUser(userData);
26+
return userData;
3527
}, []);
3628

37-
// When this runs for the first time, we look for a saved token and try to log in with it.
38-
useEffect(() => {
39-
const validateTokenOnLoad = async () => {
40-
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
41-
if (token) {
42-
await fetchProfileAndSetUser();
43-
}
44-
setIsLoading(false);
45-
};
46-
validateTokenOnLoad();
47-
}, [fetchProfileAndSetUser]);
48-
49-
// Login function: saves the token, sets it in the header, and gets the profile.
5029
const login = useCallback(
5130
async (token: string) => {
5231
localStorage.setItem(TOKEN_STORAGE_KEY, token);
@@ -56,50 +35,69 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
5635
[fetchProfileAndSetUser]
5736
);
5837

59-
// Logout function: removes the token and header, and clears the user.
60-
const logout = useCallback(() => {
38+
const logout = useCallback(async () => {
6139
try {
62-
// Clear all React Query caches to avoid stale data
40+
await authService.logout();
41+
} catch (error) {
42+
console.error('Error logout servidor:', error);
43+
} finally {
6344
queryClient.clear();
64-
65-
// Remove JWT token from localStorage using the correct key
6645
localStorage.removeItem(TOKEN_STORAGE_KEY);
46+
47+
['jwt_token', 'token', 'authToken'].forEach((k) => localStorage.removeItem(k));
6748

68-
// Also remove any other common auth-related keys (defensive cleanup)
69-
const authKeys = [
70-
'jwt_token',
71-
'token',
72-
'authToken',
73-
'access_token',
74-
'refresh_token',
75-
];
76-
authKeys.forEach((key) => {
77-
if (localStorage.getItem(key)) {
78-
localStorage.removeItem(key);
79-
}
80-
});
81-
82-
// Clear Authorization header from future requests
8349
delete apiClient.defaults.headers.common['Authorization'];
84-
85-
// Clear user state
86-
setUser(null);
87-
setIsLoading(false);
88-
89-
console.log('Logout completado exitosamente');
90-
} catch (error) {
91-
console.error('Error durante logout:', error);
92-
// Force cleanup even if something fails
93-
localStorage.clear();
9450
setUser(null);
9551
setIsLoading(false);
9652
}
9753
}, [queryClient]);
9854

99-
// We keep these values ready so the app doesn't reload too much.
55+
useEffect(() => {
56+
const initAuth = async () => {
57+
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
58+
59+
const trySilentRefresh = async () => {
60+
try {
61+
const { data } = await apiClient.post('/auth/refresh');
62+
const newToken = data.data.token;
63+
if (newToken) {
64+
await login(newToken);
65+
return true;
66+
}
67+
} catch {
68+
return false;
69+
}
70+
return false;
71+
};
72+
73+
if (token) {
74+
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
75+
try {
76+
await fetchProfileAndSetUser();
77+
} catch {
78+
console.warn("Token local inválido o expirado. Intentando renovar sesión...");
79+
80+
const success = await trySilentRefresh();
81+
82+
if (!success) {
83+
console.error("No se pudo restaurar la sesión.");
84+
localStorage.removeItem(TOKEN_STORAGE_KEY);
85+
setUser(null);
86+
}
87+
}
88+
} else {
89+
await trySilentRefresh();
90+
}
91+
92+
setIsLoading(false);
93+
};
94+
95+
initAuth();
96+
}, [fetchProfileAndSetUser, login]);
97+
10098
const value = useMemo(
10199
() => ({
102-
isAuthenticated: !!user, // We know if someone is logged in if there's a user object.
100+
isAuthenticated: !!user,
103101
user,
104102
isLoading,
105103
login,
@@ -108,6 +106,5 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
108106
[user, isLoading, login, logout]
109107
);
110108

111-
// Provide the context to child components.
112109
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
113-
};
110+
};

src/hooks/useAppeals.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import { appealService } from '../api/services/appeal.service';
33
import type { SearchAppealsParams, PaginatedAppealsResponse } from '../types/shared.ts';
44
import type { Appeal } from '../types/entities.ts';
55
import type { AxiosError } from 'axios';
6+
import { useAuth } from './useAuth';
67

78
export const useAppeals = (params: SearchAppealsParams = {}) => {
9+
const { isAuthenticated } = useAuth();
10+
811
return useQuery<PaginatedAppealsResponse, AxiosError>({
912
queryKey: ['appeals', params],
1013
queryFn: () => appealService.findAllAppeals(params),
14+
enabled: isAuthenticated,
1115
});
1216
}
1317

@@ -25,8 +29,12 @@ export const useUpdateAppealState = () => {
2529
};
2630

2731
export const useMyAppeals = () => {
32+
const { isAuthenticated } = useAuth();
33+
2834
return useQuery<Appeal[], AxiosError>({
2935
queryKey: ['appeals', 'me'],
3036
queryFn: appealService.getMyAppeals,
37+
enabled: isAuthenticated,
38+
retry: false,
3139
});
3240
};

src/hooks/useAppealsCount.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
11
import { useQuery } from '@tanstack/react-query';
22
import { appealService } from '../api/services/appeal.service';
33
import type { AxiosError } from 'axios';
4+
import { useAuth } from './useAuth';
45

5-
/**
6-
* Hook to get the count of pending professor appeals
7-
* Useful for displaying badges in admin navigation
8-
*/
96
export const useAppealsCount = () => {
7+
const { isAuthenticated } = useAuth();
8+
109
const { data, isLoading, error } = useQuery({
1110
queryKey: ['appeals', 'pending-count'],
1211
queryFn: async () => {
1312
const response = await appealService.findAllAppeals({
1413
status: 'pending',
15-
limit: 1, // We only need the total count
14+
limit: 1,
1615
offset: 0,
1716
});
1817
return { count: response.total };
1918
},
20-
refetchInterval: 60000, // Refetch every minute to keep count updated
21-
staleTime: 30000, // Consider data stale after 30 seconds
19+
refetchInterval: 60000,
20+
staleTime: 30000,
21+
enabled: isAuthenticated,
22+
retry: false,
2223
});
2324

2425
return {
2526
pendingCount: data?.count ?? 0,
2627
isLoading,
2728
error: error as AxiosError | null,
2829
};
29-
};
30+
};

0 commit comments

Comments
 (0)