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
95 changes: 90 additions & 5 deletions src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,110 @@ import axios from 'axios';

export const TOKEN_STORAGE_KEY = 'jwt_token';

interface FailedRequest {
resolve: (token: string | null) => void;
reject: (error: unknown) => void;
}

// Create axios instance with base configuration
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});

// console.log('API Base URL:', import.meta.env.VITE_API_BASE_URL);

// Request Interceptor: Attach Access Token if available
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem(TOKEN_STORAGE_KEY);

if (token) {
config.headers.Authorization = `Bearer ${token}`;
}

return config;
},
(error) => {
return Promise.reject(error);
}
);

export default apiClient;
// Variables to handle concurrent refresh requests
let isRefreshing = false;
let failedQueue: FailedRequest[] = [];

// Helper to process the queue of failed requests
const processQueue = (error: Error | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};

// Response Interceptor: Handle 401 errors (Expired Access Token)
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;

if (
error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url.includes('/auth/login') &&
!originalRequest.url.includes('/auth/refresh')
) {

if (isRefreshing) {
return new Promise<string | null>(function (resolve, reject) {
failedQueue.push({ resolve, reject });
})
.then((token) => {
if (token) {
originalRequest.headers['Authorization'] = 'Bearer ' + token;
}
return apiClient(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}

originalRequest._retry = true;
isRefreshing = true;

try {
const { data } = await apiClient.post('/auth/refresh');
const newToken = data.data.token;

localStorage.setItem(TOKEN_STORAGE_KEY, newToken);
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;

processQueue(null, newToken);

originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return apiClient(originalRequest);

} catch (refreshError: unknown) {
processQueue(refreshError as Error, null);

localStorage.removeItem(TOKEN_STORAGE_KEY);

if (window.location.pathname !== '/login') {
window.location.href = '/login';
}

return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}

return Promise.reject(error);
}
);

export default apiClient;
8 changes: 7 additions & 1 deletion src/api/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const register = async (payload: RegisterPayload): Promise<User> => {
// Gets the current user's profile
const getProfile = async (): Promise<User> => {
try {
const response = await apiClient.get<ApiResponse<User>>('/auth/profile');
const response = await apiClient.get<ApiResponse<User>>('/users/me');
return response.data.data;
} catch (error) {
console.error('Failed to get user profile', error);
Expand All @@ -66,10 +66,16 @@ const resetPassword = async (token: string, password_plaintext: string): Promise
await apiClient.post('/auth/reset-password', { token, password_plaintext });
};

// Logout: Calls backend to clear HttpOnly cookie
const logout = async (): Promise<void> => {
await apiClient.post('/auth/logout');
};

export const authService = {
login,
register,
getProfile,
forgotPassword,
resetPassword,
logout,
};
115 changes: 56 additions & 59 deletions src/contexts/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { User } from '../types/entities.ts';
import { userService } from '../api/services/user.service.ts';
import { authService } from '../api/services/auth.service.ts';
import { AuthContext } from './AuthContext';
import apiClient, { TOKEN_STORAGE_KEY } from '../api/apiClient';

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

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

// Function to get the user's profile and update the global state.
const fetchProfileAndSetUser = useCallback(async () => {
try {
const userData = await userService.getProfile();
setUser(userData);
} catch (error) {
// If it fails, we remove the token and log out the user.
console.error('Failed to fetch profile, logging out.', error);
localStorage.removeItem(TOKEN_STORAGE_KEY);
setUser(null);
}
const userData = await userService.getProfile();
setUser(userData);
return userData;
}, []);

// When this runs for the first time, we look for a saved token and try to log in with it.
useEffect(() => {
const validateTokenOnLoad = async () => {
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
if (token) {
await fetchProfileAndSetUser();
}
setIsLoading(false);
};
validateTokenOnLoad();
}, [fetchProfileAndSetUser]);

// Login function: saves the token, sets it in the header, and gets the profile.
const login = useCallback(
async (token: string) => {
localStorage.setItem(TOKEN_STORAGE_KEY, token);
Expand All @@ -56,50 +35,69 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
[fetchProfileAndSetUser]
);

// Logout function: removes the token and header, and clears the user.
const logout = useCallback(() => {
const logout = useCallback(async () => {
try {
// Clear all React Query caches to avoid stale data
await authService.logout();
} catch (error) {
console.error('Error logout servidor:', error);
} finally {
queryClient.clear();

// Remove JWT token from localStorage using the correct key
localStorage.removeItem(TOKEN_STORAGE_KEY);

['jwt_token', 'token', 'authToken'].forEach((k) => localStorage.removeItem(k));

// Also remove any other common auth-related keys (defensive cleanup)
const authKeys = [
'jwt_token',
'token',
'authToken',
'access_token',
'refresh_token',
];
authKeys.forEach((key) => {
if (localStorage.getItem(key)) {
localStorage.removeItem(key);
}
});

// Clear Authorization header from future requests
delete apiClient.defaults.headers.common['Authorization'];

// Clear user state
setUser(null);
setIsLoading(false);

console.log('Logout completado exitosamente');
} catch (error) {
console.error('Error durante logout:', error);
// Force cleanup even if something fails
localStorage.clear();
setUser(null);
setIsLoading(false);
}
}, [queryClient]);

// We keep these values ready so the app doesn't reload too much.
useEffect(() => {
const initAuth = async () => {
const token = localStorage.getItem(TOKEN_STORAGE_KEY);

const trySilentRefresh = async () => {
try {
const { data } = await apiClient.post('/auth/refresh');
const newToken = data.data.token;
if (newToken) {
await login(newToken);
return true;
}
} catch {
return false;
}
return false;
};

if (token) {
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
try {
await fetchProfileAndSetUser();
} catch {
console.warn("Token local inválido o expirado. Intentando renovar sesión...");

const success = await trySilentRefresh();

if (!success) {
console.error("No se pudo restaurar la sesión.");
localStorage.removeItem(TOKEN_STORAGE_KEY);
setUser(null);
}
}
} else {
await trySilentRefresh();
}

setIsLoading(false);
};

initAuth();
}, [fetchProfileAndSetUser, login]);

const value = useMemo(
() => ({
isAuthenticated: !!user, // We know if someone is logged in if there's a user object.
isAuthenticated: !!user,
user,
isLoading,
login,
Expand All @@ -108,6 +106,5 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
[user, isLoading, login, logout]
);

// Provide the context to child components.
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
};
8 changes: 8 additions & 0 deletions src/hooks/useAppeals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { appealService } from '../api/services/appeal.service';
import type { SearchAppealsParams, PaginatedAppealsResponse } from '../types/shared.ts';
import type { Appeal } from '../types/entities.ts';
import type { AxiosError } from 'axios';
import { useAuth } from './useAuth';

export const useAppeals = (params: SearchAppealsParams = {}) => {
const { isAuthenticated } = useAuth();

return useQuery<PaginatedAppealsResponse, AxiosError>({
queryKey: ['appeals', params],
queryFn: () => appealService.findAllAppeals(params),
enabled: isAuthenticated,
});
}

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

export const useMyAppeals = () => {
const { isAuthenticated } = useAuth();

return useQuery<Appeal[], AxiosError>({
queryKey: ['appeals', 'me'],
queryFn: appealService.getMyAppeals,
enabled: isAuthenticated,
retry: false,
});
};
17 changes: 9 additions & 8 deletions src/hooks/useAppealsCount.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { appealService } from '../api/services/appeal.service';
import type { AxiosError } from 'axios';
import { useAuth } from './useAuth';

/**
* Hook to get the count of pending professor appeals
* Useful for displaying badges in admin navigation
*/
export const useAppealsCount = () => {
const { isAuthenticated } = useAuth();

const { data, isLoading, error } = useQuery({
queryKey: ['appeals', 'pending-count'],
queryFn: async () => {
const response = await appealService.findAllAppeals({
status: 'pending',
limit: 1, // We only need the total count
limit: 1,
offset: 0,
});
return { count: response.total };
},
refetchInterval: 60000, // Refetch every minute to keep count updated
staleTime: 30000, // Consider data stale after 30 seconds
refetchInterval: 60000,
staleTime: 30000,
enabled: isAuthenticated,
retry: false,
});

return {
pendingCount: data?.count ?? 0,
isLoading,
error: error as AxiosError | null,
};
};
};
Loading