Archived
feat: Integrate Expo mobile application into monorepo (#16)
- 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
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user