Add mobile core: API client, auth, storage, state, and offline support
Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
2026-05-17 15:09:23 -04:00
|
|
|
import axios from 'axios';
|
|
|
|
|
import Config from '../../config';
|
|
|
|
|
import { tokenStorage } from '../storage';
|
|
|
|
|
|
2026-07-14 01:18:30 -04:00
|
|
|
// Fix: CONC-L2 — carries HTTP status + response body so screens can branch on
|
|
|
|
|
// optimistic-concurrency conflicts (409 { message, currentState }).
|
|
|
|
|
export class ApiError extends Error {
|
|
|
|
|
constructor(
|
|
|
|
|
message: string,
|
|
|
|
|
public status: number,
|
|
|
|
|
public data?: unknown,
|
|
|
|
|
) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = 'ApiError';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:07:49 -04:00
|
|
|
type RefreshFn = () => Promise<void>;
|
|
|
|
|
let _refreshTokens: RefreshFn | null = null;
|
|
|
|
|
|
|
|
|
|
export function registerTokenRefresh(fn: RefreshFn) {
|
|
|
|
|
_refreshTokens = fn;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SessionExpiredListener = () => void;
|
|
|
|
|
|
|
|
|
|
const sessionExpiredListeners: SessionExpiredListener[] = [];
|
|
|
|
|
|
|
|
|
|
export function onSessionExpired(listener: SessionExpiredListener): () => void {
|
|
|
|
|
sessionExpiredListeners.push(listener);
|
|
|
|
|
return () => {
|
|
|
|
|
const idx = sessionExpiredListeners.indexOf(listener);
|
|
|
|
|
if (idx >= 0) sessionExpiredListeners.splice(idx, 1);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function emitSessionExpired() {
|
|
|
|
|
sessionExpiredListeners.forEach((fn) => fn());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let isRefreshing = false;
|
|
|
|
|
let refreshSubscribers: Array<(token: string) => void> = [];
|
|
|
|
|
|
|
|
|
|
function subscribeToRefresh(cb: (token: string) => void) {
|
|
|
|
|
refreshSubscribers.push(cb);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onRefreshComplete(token: string) {
|
|
|
|
|
refreshSubscribers.forEach((cb) => cb(token));
|
|
|
|
|
refreshSubscribers = [];
|
|
|
|
|
}
|
|
|
|
|
|
Add mobile core: API client, auth, storage, state, and offline support
Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
2026-05-17 15:09:23 -04:00
|
|
|
const apiClient = axios.create({
|
|
|
|
|
baseURL: Config.API_URL,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
apiClient.interceptors.request.use(
|
|
|
|
|
async (config) => {
|
|
|
|
|
const tokens = await tokenStorage.get();
|
|
|
|
|
if (tokens?.accessToken) {
|
|
|
|
|
config.headers.Authorization = `Bearer ${tokens.accessToken}`;
|
|
|
|
|
}
|
|
|
|
|
return config;
|
|
|
|
|
},
|
|
|
|
|
(error) => Promise.reject(error),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
apiClient.interceptors.response.use(
|
|
|
|
|
(response) => response,
|
2026-05-20 19:07:49 -04:00
|
|
|
async (error) => {
|
Add mobile core: API client, auth, storage, state, and offline support
Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
2026-05-17 15:09:23 -04:00
|
|
|
if (error.response) {
|
2026-05-20 19:07:49 -04:00
|
|
|
const { status, data, config: originalRequest } = error.response;
|
Add mobile core: API client, auth, storage, state, and offline support
Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
2026-05-17 15:09:23 -04:00
|
|
|
|
2026-05-20 19:07:49 -04:00
|
|
|
if (status === 401 && !originalRequest._retry) {
|
|
|
|
|
originalRequest._retry = true;
|
|
|
|
|
|
|
|
|
|
if (isRefreshing) {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
subscribeToRefresh((token) => {
|
|
|
|
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
|
|
|
resolve(apiClient(originalRequest));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isRefreshing = true;
|
|
|
|
|
try {
|
|
|
|
|
if (!_refreshTokens) throw new Error('No refresh handler');
|
|
|
|
|
await _refreshTokens();
|
|
|
|
|
const tokens = await tokenStorage.get();
|
|
|
|
|
const newToken = tokens?.accessToken ?? '';
|
|
|
|
|
onRefreshComplete(newToken);
|
|
|
|
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
|
|
|
|
return apiClient(originalRequest);
|
|
|
|
|
} catch {
|
|
|
|
|
await tokenStorage.clear();
|
|
|
|
|
emitSessionExpired();
|
|
|
|
|
return Promise.reject(
|
|
|
|
|
new Error('Session expired. Please log in again.'),
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
isRefreshing = false;
|
|
|
|
|
}
|
Add mobile core: API client, auth, storage, state, and offline support
Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
2026-05-17 15:09:23 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (status === 403) {
|
|
|
|
|
return Promise.reject(
|
|
|
|
|
new Error('You do not have permission to perform this action.'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (status === 404) {
|
|
|
|
|
return Promise.reject(
|
|
|
|
|
new Error('The requested resource was not found.'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const message =
|
|
|
|
|
data?.detail || data?.title || data?.message || 'An error occurred';
|
2026-07-14 01:18:30 -04:00
|
|
|
return Promise.reject(new ApiError(message, status, data));
|
Add mobile core: API client, auth, storage, state, and offline support
Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
2026-05-17 15:09:23 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error.request) {
|
|
|
|
|
return Promise.reject(
|
|
|
|
|
new Error('No response from server. Please check your connection.'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Promise.reject(error);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export default apiClient;
|