Archived
- Add mobile/ directory with Expo React Native project - Create API client with JWT auth and token refresh using AsyncStorage - Implement AuthContext for login/register/logout flow - Add screens: Login, Register, Library, Search, Settings - Set up React Navigation with AuthStack and MainTabs - Create packages/shared/ with shared types and utilities - Add shared validation utilities (email, password strength) - Update root package.json workspaces to include mobile + shared - Add spec document docs/backend/009-expo-integration.md
140 lines
4.0 KiB
TypeScript
140 lines
4.0 KiB
TypeScript
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
|
|
const STORAGE_KEYS = {
|
|
ACCESS_TOKEN: "access_token",
|
|
REFRESH_TOKEN: "refresh_token",
|
|
} as const;
|
|
|
|
interface RetryConfig extends InternalAxiosRequestConfig {
|
|
_retry?: boolean;
|
|
}
|
|
|
|
const api = axios.create({
|
|
baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:8000",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
// ── Token helpers ────────────────────────────────────────────────────
|
|
|
|
async function getAccessToken(): Promise<string | null> {
|
|
return AsyncStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
|
|
}
|
|
|
|
async function getRefreshToken(): Promise<string | null> {
|
|
return AsyncStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
|
}
|
|
|
|
async function setTokens(access: string, refresh: string): Promise<void> {
|
|
await AsyncStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access);
|
|
await AsyncStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh);
|
|
}
|
|
|
|
async function clearTokens(): Promise<void> {
|
|
await AsyncStorage.multiRemove([
|
|
STORAGE_KEYS.ACCESS_TOKEN,
|
|
STORAGE_KEYS.REFRESH_TOKEN,
|
|
]);
|
|
}
|
|
|
|
// ── Request interceptor ─────────────────────────────────────────────
|
|
|
|
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
|
const token = await getAccessToken();
|
|
if (token && config.headers) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// ── Response interceptor: auto-refresh on 401 ───────────────────────
|
|
|
|
let isRefreshing = false;
|
|
let failedQueue: Array<{
|
|
resolve: (token: string) => void;
|
|
reject: (err: unknown) => void;
|
|
}> = [];
|
|
|
|
function processQueue(error: unknown, token: string | null = null): void {
|
|
failedQueue.forEach((prom) => {
|
|
if (error) {
|
|
prom.reject(error);
|
|
} else if (token) {
|
|
prom.resolve(token);
|
|
}
|
|
});
|
|
failedQueue = [];
|
|
}
|
|
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error: AxiosError) => {
|
|
const originalRequest = error.config as RetryConfig | undefined;
|
|
|
|
if (!originalRequest) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
if (
|
|
error.response?.status !== 401 ||
|
|
originalRequest._retry ||
|
|
originalRequest.url?.includes("/api/auth/token/refresh/") ||
|
|
originalRequest.url?.includes("/api/auth/login/") ||
|
|
originalRequest.url?.includes("/api/auth/register/") ||
|
|
originalRequest.url?.includes("/api/auth/logout/")
|
|
) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
if (isRefreshing) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
failedQueue.push({ resolve, reject });
|
|
}).then((token) => {
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return api(originalRequest);
|
|
});
|
|
}
|
|
|
|
originalRequest._retry = true;
|
|
isRefreshing = true;
|
|
|
|
const refreshToken = await getRefreshToken();
|
|
|
|
if (!refreshToken) {
|
|
isRefreshing = false;
|
|
await clearTokens();
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
try {
|
|
const response = await axios.post(
|
|
`${api.defaults.baseURL}/api/auth/token/refresh/`,
|
|
{ refresh: refreshToken },
|
|
);
|
|
|
|
const newAccess = response.data.access as string;
|
|
const newRefresh = response.data.refresh as string;
|
|
await setTokens(newAccess, newRefresh);
|
|
|
|
processQueue(null, newAccess);
|
|
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
|
|
}
|
|
return api(originalRequest);
|
|
} catch (refreshError) {
|
|
processQueue(refreshError, null);
|
|
await clearTokens();
|
|
return Promise.reject(refreshError);
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
},
|
|
);
|
|
|
|
export { getAccessToken, getRefreshToken, setTokens, clearTokens };
|
|
export default api; |