Files
MAIA/interfaces/nativeapp/src/api/client.ts
2025-04-26 12:43:19 +02:00

196 lines
7.9 KiB
TypeScript

// src/api/client.ts
import axios, { AxiosError } from 'axios';
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://maia.depaoli.id.au/api';
const ACCESS_TOKEN_KEY = 'maia_access_token';
const REFRESH_TOKEN_KEY = 'maia_refresh_token';
console.log("Using API Base URL:", API_BASE_URL);
// Helper functions for storage
const storeToken = async (key: string, token: string): Promise<void> => {
if (Platform.OS === 'web') {
await AsyncStorage.setItem(key, token);
} else {
await SecureStore.setItemAsync(key, token);
}
};
const getToken = async (key: string): Promise<string | null> => {
if (Platform.OS === 'web') {
return await AsyncStorage.getItem(key);
} else {
return await SecureStore.getItemAsync(key).catch(() => null);
}
};
const deleteToken = async (key: string): Promise<void> => {
if (Platform.OS === 'web') {
await AsyncStorage.removeItem(key);
} else {
await SecureStore.deleteItemAsync(key).catch(() => {});
}
};
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000,
// Remove withCredentials from default config if not needed globally
// ...(Platform.OS === 'web' ? { withCredentials: true } : {}),
});
// --- Request Interceptor ---
apiClient.interceptors.request.use(
async (config) => {
const token = await getToken(ACCESS_TOKEN_KEY); // Use helper
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log('[API Client] Starting Request', config.method?.toUpperCase(), config.url);
return config;
},
(error) => {
console.error('[API Client] Request Setup Error:', error);
return Promise.reject(error);
}
);
// --- Modified Response Interceptor ---
apiClient.interceptors.response.use(
(response) => {
// Success case
return response;
},
async (error: AxiosError) => { // Explicitly type error as AxiosError
const originalRequest = error.config;
if (error.response && originalRequest) {
console.error('[API Client] Response Error Status:', error.response.status);
console.error('[API Client] Response Error Data:', error.response.data);
if (error.response.status === 401) {
console.warn('[API Client] Unauthorized (401). Token might be expired or invalid.');
// Prevent refresh loops if the refresh endpoint itself returns 401
if (originalRequest.url === '/auth/refresh') {
console.error('[API Client] Refresh token attempt failed with 401. Clearing tokens.');
await deleteToken(ACCESS_TOKEN_KEY);
await deleteToken(REFRESH_TOKEN_KEY); // Clear refresh token too
delete apiClient.defaults.headers.common['Authorization'];
// TODO: Trigger logout flow in UI
return Promise.reject(error);
}
// Check if we haven't already tried to refresh for this request
// Need to declare _retry on AxiosRequestConfig interface or use a different check
// Using a simple check here, consider a more robust solution if needed
if (!(originalRequest as any)._retry) {
(originalRequest as any)._retry = true;
try {
const storedRefreshToken = await getToken(REFRESH_TOKEN_KEY);
if (!storedRefreshToken) {
console.error('[API Client] No refresh token found to attempt refresh.');
await deleteToken(ACCESS_TOKEN_KEY); // Clear potentially invalid access token
// TODO: Trigger logout flow in UI
return Promise.reject(error); // Reject if no refresh token
}
console.log('[API Client] Attempting token refresh...');
const refreshResponse = await apiClient.post('/auth/refresh',
{ refresh_token: storedRefreshToken }, // Send token in body
{
headers: { 'Content-Type': 'application/json' },
}
);
if (refreshResponse.status === 200) {
const newAccessToken = refreshResponse.data?.access_token; // Expecting only access token
if (newAccessToken) {
console.log('[API Client] Token refreshed successfully.');
await storeToken(ACCESS_TOKEN_KEY, newAccessToken); // Store new access token
// Update the default Authorization header for subsequent requests
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
// Update the Authorization header in the original request config
if (originalRequest.headers) {
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
}
// Retry the original request with the new access token
return apiClient(originalRequest);
} else {
console.error('[API Client] Invalid token structure received during refresh:', refreshResponse.data);
throw new Error('Invalid access token received from server during refresh.');
}
} else {
// Handle non-200 responses from refresh endpoint if necessary
console.error(`[API Client] Token refresh endpoint returned status ${refreshResponse.status}`);
throw new Error(`Refresh failed with status ${refreshResponse.status}`);
}
} catch (refreshError: any) {
console.error('[API Client] Token refresh failed:', refreshError);
// Clear tokens if refresh fails
await deleteToken(ACCESS_TOKEN_KEY);
await deleteToken(REFRESH_TOKEN_KEY);
delete apiClient.defaults.headers.common['Authorization'];
// TODO: Trigger logout flow in UI
// Propagate the refresh error instead of the original 401
return Promise.reject(refreshError);
}
} else {
console.warn('[API Client] Already retried this request. Not attempting refresh again.');
}
// If retry flag was already set, or if refresh attempt failed and fell through,
// we might still end up here. Ensure tokens are cleared if a 401 persists.
console.log('[API Client] Clearing tokens due to persistent 401 or failed refresh.');
await deleteToken(ACCESS_TOKEN_KEY);
await deleteToken(REFRESH_TOKEN_KEY);
delete apiClient.defaults.headers.common['Authorization'];
// TODO: Trigger logout flow in UI
} // End of 401 handling
} else if (error.request) {
console.error('[API Client] Network Error or No Response:', error.message);
if (error.message.toLowerCase().includes('network error') && Platform.OS === 'web') {
console.warn('[API Client] Hint: A "Network Error" on web often masks a CORS issue. Check browser console & backend CORS config.');
}
} else {
console.error('[API Client] Request Setup Error (Interceptor):', error.message);
}
return Promise.reject(error);
}
);
export default apiClient;
// Add functions to manage tokens globally if needed by UI components
export const storeTokens = async (accessToken: string, refreshToken?: string) => {
await storeToken(ACCESS_TOKEN_KEY, accessToken);
if (refreshToken) {
await storeToken(REFRESH_TOKEN_KEY, refreshToken);
}
apiClient.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`; // Update client instance
};
export const clearTokens = async () => {
await deleteToken(ACCESS_TOKEN_KEY);
await deleteToken(REFRESH_TOKEN_KEY);
delete apiClient.defaults.headers.common['Authorization']; // Clear client instance header
};
export const getRefreshToken = () => getToken(REFRESH_TOKEN_KEY);
export const getAccessToken = () => getToken(ACCESS_TOKEN_KEY);