Backend (FastAPI + SQLAlchemy + SQLite): - JWT auth with access/refresh tokens, bcrypt password hashing - User model with member/organizer/admin roles, auto-approve members - Championship, Registration, ParticipantList, Notification models - Alembic async migrations, seed data with test users - Registration endpoint returns tokens for members, pending for organizers - /registrations/my returns championship title/date/location via eager loading - Admin endpoints: list users, approve/reject organizers Mobile (React Native + Expo + TypeScript): - Zustand auth store, Axios client with token refresh interceptor - Role-based registration (Member vs Organizer) with contextual form labels - Tab navigation with Ionicons, safe area headers, admin tab for admin role - Championships list with status badges, detail screen with registration progress - My Registrations with championship title, progress bar, and tap-to-navigate - Admin panel with pending/all filter, approve/reject with confirmation - Profile screen with role badge, Ionicons info rows, sign out - Password visibility toggle (Ionicons), keyboard flow hints (returnKeyType) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
74 lines
2.2 KiB
TypeScript
74 lines
2.2 KiB
TypeScript
import axios from 'axios';
|
|
import { tokenStorage } from '../utils/tokenStorage';
|
|
|
|
// Replace with your machine's LAN IP when testing on a physical device
|
|
export const BASE_URL = 'http://192.168.2.56:8000/api/v1';
|
|
|
|
export const apiClient = axios.create({
|
|
baseURL: BASE_URL,
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Attach access token from in-memory cache (synchronous — no await needed)
|
|
apiClient.interceptors.request.use((config) => {
|
|
const token = tokenStorage.getAccessTokenSync();
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// Refresh token on 401
|
|
let isRefreshing = false;
|
|
let queue: Array<{ resolve: (token: string) => void; reject: (err: unknown) => void }> = [];
|
|
|
|
function processQueue(error: unknown, token: string | null = null) {
|
|
queue.forEach((p) => (error ? p.reject(error) : p.resolve(token!)));
|
|
queue = [];
|
|
}
|
|
|
|
apiClient.interceptors.response.use(
|
|
(res) => res,
|
|
async (error) => {
|
|
const original = error.config;
|
|
if (error.response?.status === 401 && !original._retry) {
|
|
if (isRefreshing) {
|
|
return new Promise((resolve, reject) => {
|
|
queue.push({
|
|
resolve: (token) => {
|
|
original.headers.Authorization = `Bearer ${token}`;
|
|
resolve(apiClient(original));
|
|
},
|
|
reject,
|
|
});
|
|
});
|
|
}
|
|
|
|
original._retry = true;
|
|
isRefreshing = true;
|
|
|
|
try {
|
|
const refreshToken = tokenStorage.getRefreshTokenSync();
|
|
if (!refreshToken) throw new Error('No refresh token');
|
|
|
|
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
|
|
refresh_token: refreshToken,
|
|
});
|
|
|
|
await tokenStorage.saveTokens(data.access_token, data.refresh_token);
|
|
apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
|
|
processQueue(null, data.access_token);
|
|
original.headers.Authorization = `Bearer ${data.access_token}`;
|
|
return apiClient(original);
|
|
} catch (err) {
|
|
processQueue(err, null);
|
|
await tokenStorage.clearTokens();
|
|
return Promise.reject(err);
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|