mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-05 08:22:02 +00:00
BLOCK-10: Add CloudWatch alarms (DLQ, Lambda errors, RDS, API 5xx) with SNS email BLOCK-11: Remove sync-over-async deadlock in CurrentUserService BLOCK-12: Add AppDelegate OAuth URL callback handler for mobile BLOCK-13: Wire mobile 401 interceptor to dispatch Redux logout BLOCK-14: Fix JWT base64 padding crash and SysAdmin role detection BLOCK-15: Reset pagination to page 1 on filter change BLOCK-16: Add unsaved-changes guard (beforeunload + useBlocker) to AdminWorkspace FIX-08: Add BulkUpdateLineItems FluentValidation validator FIX-13: Display auth errors on LoginPage FIX-25: Add token refresh with retry queue to mobile API client FIX-44: Add httpx retry logic to all Lambda handlers FIX-42/43: Align docker-compose PG version (15) and DB name (proposals) with RDS
124 lines
3.3 KiB
TypeScript
124 lines
3.3 KiB
TypeScript
import axios from 'axios';
|
|
import Config from '../../config';
|
|
import { tokenStorage } from '../storage';
|
|
|
|
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 = [];
|
|
}
|
|
|
|
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,
|
|
async (error) => {
|
|
if (error.response) {
|
|
const { status, data, config: originalRequest } = error.response;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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';
|
|
return Promise.reject(new Error(message));
|
|
}
|
|
|
|
if (error.request) {
|
|
return Promise.reject(
|
|
new Error('No response from server. Please check your connection.'),
|
|
);
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
export default apiClient;
|