198 lines
8.3 KiB
TypeScript
198 lines
8.3 KiB
TypeScript
// src/api/client.ts
|
|
import axios, { AxiosError } from 'axios'; // Import AxiosError
|
|
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 || 'http://192.168.255.221:8000/api'; // Use your machine's IP
|
|
// const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'http://192.168.1.9:8000/api'; // Use your machine's IP
|
|
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://maia.depaoli.id.au/api'; // Use your machine's IP
|
|
const ACCESS_TOKEN_KEY = 'maia_access_token'; // Renamed for clarity
|
|
const REFRESH_TOKEN_KEY = 'maia_refresh_token'; // Key for 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(() => {}); // Ignore delete error
|
|
}
|
|
};
|
|
|
|
|
|
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); |