Files
coastal_timesheet/frontend/src/api/client.js
T
BizzleBot 70ed672381 Fix: ManageUsers component missing useAuth() - caused blank admin page
The ManageUsers sub-component referenced user?.role for conditional
role dropdown options but didn't call useAuth() to get the user object.
This caused 'user is not defined' JS errors and blank admin pages.

Browser E2E: 20/20 tests pass.
2026-02-16 20:24:34 +00:00

154 lines
4.1 KiB
JavaScript

import axios from 'axios';
const client = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
timeout: 15000,
});
/* ─── Token helpers ─── */
function getAccessToken() {
return localStorage.getItem('accessToken');
}
function getRefreshToken() {
return localStorage.getItem('refreshToken');
}
function parseJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
}
function setTokens(accessToken, refreshToken) {
localStorage.setItem('accessToken', accessToken);
if (refreshToken) {
localStorage.setItem('refreshToken', refreshToken);
}
// Sync the 'user' object in localStorage with the data from the new access token
const payload = parseJwt(accessToken);
if (payload) {
const existingUser = JSON.parse(localStorage.getItem('user') || '{}');
// Ensure we don't overwrite user-specific static data if missing from JWT
const updatedUser = { ...existingUser, ...payload };
// The user ID usually comes as 'sub' in JWT, but frontend expects 'id'
if (payload.sub && !updatedUser.id) updatedUser.id = payload.sub;
// Explicitly update role and email which are crucial for the UI
if (payload.role) updatedUser.role = payload.role;
if (payload.email) updatedUser.email = payload.email;
localStorage.setItem('user', JSON.stringify(updatedUser));
}
}
function clearTokens() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
}
/* ─── Request interceptor: attach access token ─── */
client.interceptors.request.use(
(config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
/* ─── Response interceptor: auto-refresh on 401 ─── */
let isRefreshing = false;
let failedQueue = [];
function processQueue(error, token) {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
}
client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
/* If 401 with TOKEN_EXPIRED and we haven't retried yet */
if (
error.response?.status === 401 &&
error.response?.data?.code === 'TOKEN_EXPIRED' &&
!originalRequest._retry
) {
if (isRefreshing) {
/* Queue this request until the refresh completes */
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return client(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token');
}
/* Call refresh endpoint directly (bypass interceptor) */
const { data } = await axios.post('/api/auth/refresh', {
refreshToken,
});
setTokens(data.accessToken, data.refreshToken);
processQueue(null, data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return client(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
clearTokens();
/* Redirect to login */
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
/* If 401 on non-refresh, clear and redirect */
if (
error.response?.status === 401 &&
!originalRequest.url?.includes('/auth/refresh')
) {
clearTokens();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export { client as default, setTokens, clearTokens, getAccessToken, getRefreshToken };