326 lines
9.2 KiB
TypeScript
326 lines
9.2 KiB
TypeScript
/**
|
|
* Cliente API para comunicación con el backend
|
|
*/
|
|
import { getToken } from './auth';
|
|
import { browser } from '$app/environment';
|
|
|
|
// Normalize API_BASE_URL to remove trailing slash
|
|
const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
|
|
|
|
export interface ApiResponse<T = any> {
|
|
data?: T;
|
|
error?: string;
|
|
validationErrors?: Array<{
|
|
field: string;
|
|
message: string;
|
|
code?: string;
|
|
solution?: string[];
|
|
value?: any;
|
|
}>;
|
|
status: number;
|
|
}
|
|
|
|
let isRefreshing = false;
|
|
let refreshSubscribers: ((token: string) => void)[] = [];
|
|
|
|
/**
|
|
* Agrega una petición a la cola de espera mientras se refresca el token
|
|
*/
|
|
function subscribeTokenRefresh(callback: (token: string) => void) {
|
|
refreshSubscribers.push(callback);
|
|
}
|
|
|
|
/**
|
|
* Notifica a todas las peticiones en espera que el token se ha refrescado
|
|
*/
|
|
function onTokenRefreshed(token: string) {
|
|
refreshSubscribers.forEach((callback) => callback(token));
|
|
refreshSubscribers = [];
|
|
}
|
|
|
|
/**
|
|
* Intenta refrescar el token usando el refresh token
|
|
*/
|
|
async function refreshToken(): Promise<string | null> {
|
|
if (!browser) return null;
|
|
|
|
let refreshTokenValue = localStorage.getItem('refresh_token');
|
|
|
|
// Si no está en localStorage, intentar obtenerlo de las cookies
|
|
if (!refreshTokenValue) {
|
|
const getCookie = (name: string): string | null => {
|
|
const value = `; ${document.cookie}`;
|
|
const parts = value.split(`; ${name}=`);
|
|
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
|
|
return null;
|
|
};
|
|
|
|
refreshTokenValue = getCookie('refresh_token');
|
|
if (refreshTokenValue) {
|
|
localStorage.setItem('refresh_token', refreshTokenValue);
|
|
}
|
|
}
|
|
|
|
if (!refreshTokenValue) {
|
|
console.error('❌ [API] No hay refresh token disponible');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ refresh_token: refreshTokenValue }),
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error('❌ [API] Refresh token expirado o inválido, status:', response.status);
|
|
// Si el refresh token también está expirado, limpiar todo
|
|
localStorage.removeItem('access_token');
|
|
localStorage.removeItem('refresh_token');
|
|
// Limpiar cookies también
|
|
document.cookie = 'access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC';
|
|
document.cookie = 'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC';
|
|
// Redirigir al login después de un pequeño delay para que el usuario vea el mensaje
|
|
setTimeout(() => {
|
|
if (browser) {
|
|
window.location.href = '/login';
|
|
}
|
|
}, 2000);
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Guardar los nuevos tokens
|
|
if (data.access_token) {
|
|
localStorage.setItem('access_token', data.access_token);
|
|
|
|
if (data.refresh_token) {
|
|
localStorage.setItem('refresh_token', data.refresh_token);
|
|
}
|
|
|
|
// Actualizar también las cookies
|
|
const isSecure = window.location.protocol === 'https:';
|
|
const secureFlag = isSecure ? '; Secure' : '';
|
|
|
|
document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secureFlag}`;
|
|
if (data.refresh_token) {
|
|
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax${secureFlag}`;
|
|
}
|
|
|
|
// Actualizar el authStore si está disponible
|
|
try {
|
|
const { authStore } = await import('./auth');
|
|
authStore.setToken(data.access_token);
|
|
} catch (e) {
|
|
// Si no se puede importar authStore, no es crítico
|
|
console.warn('⚠️ [API] No se pudo actualizar authStore:', e);
|
|
}
|
|
|
|
return data.access_token;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error('❌ [API] Error refreshing token:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Realiza una petición al API con manejo automático de refresh token
|
|
*/
|
|
async function fetchApi<T = any>(
|
|
endpoint: string,
|
|
options: RequestInit = {},
|
|
retryCount = 0
|
|
): Promise<ApiResponse<T>> {
|
|
// Si ya estamos refrescando el token, esperar
|
|
if (isRefreshing && retryCount === 0) {
|
|
return new Promise((resolve) => {
|
|
subscribeTokenRefresh((newToken) => {
|
|
resolve(fetchApi<T>(endpoint, options, 1));
|
|
});
|
|
});
|
|
}
|
|
|
|
const token = getToken();
|
|
|
|
if (!token && !endpoint.includes('/auth/login')) {
|
|
console.warn('⚠️ [API] No hay token disponible para', endpoint);
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
...((options.headers as Record<string, string>) || {})
|
|
};
|
|
|
|
// Only set Content-Type to application/json if not already set and body is not FormData
|
|
if (!headers['Content-Type'] && !(options.body instanceof FormData)) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
...options,
|
|
headers,
|
|
credentials: 'include' // Importante: envía cookies con cada request
|
|
});
|
|
|
|
// Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token
|
|
if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
|
isRefreshing = true;
|
|
|
|
try {
|
|
const newToken = await refreshToken();
|
|
|
|
if (newToken) {
|
|
// Token refrescado exitosamente
|
|
onTokenRefreshed(newToken);
|
|
isRefreshing = false;
|
|
// Reintentar la petición original con el nuevo token
|
|
return await fetchApi<T>(endpoint, options, 1);
|
|
} else {
|
|
console.error('❌ [API] No se pudo refrescar el token');
|
|
isRefreshing = false;
|
|
// Retornar error 401 para que la capa superior lo maneje
|
|
return {
|
|
error: 'Sesión expirada. Por favor, inicia sesión nuevamente.',
|
|
status: 401
|
|
};
|
|
}
|
|
} catch (refreshError) {
|
|
console.error('❌ [API] Error al refrescar:', refreshError);
|
|
isRefreshing = false;
|
|
return {
|
|
error: 'Error al refrescar la sesión',
|
|
status: 401
|
|
};
|
|
}
|
|
}
|
|
|
|
// Manejar respuestas sin contenido (204 No Content)
|
|
if (response.status === 204) {
|
|
return {
|
|
data: null as T,
|
|
status: response.status
|
|
};
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
// Manejo especial para errores 422 (validation error)
|
|
if (response.status === 422) {
|
|
// Errores de validación personalizados (con array errors)
|
|
if (data.errors && Array.isArray(data.errors)) {
|
|
return {
|
|
error: data.message || 'Error de validación',
|
|
validationErrors: data.errors,
|
|
status: response.status
|
|
};
|
|
}
|
|
// Errores de validación de FastAPI (con detail)
|
|
else if (data.detail) {
|
|
let errorMessage = 'Error de validación: ';
|
|
|
|
// FastAPI devuelve errores de validación en data.detail como array
|
|
if (Array.isArray(data.detail)) {
|
|
const errors = data.detail.map((err: any) => {
|
|
const field = err.loc ? err.loc.join('.') : 'campo desconocido';
|
|
return `${field}: ${err.msg}`;
|
|
}).join(', ');
|
|
errorMessage += errors;
|
|
} else if (typeof data.detail === 'string') {
|
|
errorMessage = data.detail;
|
|
} else {
|
|
errorMessage += JSON.stringify(data.detail);
|
|
}
|
|
|
|
return {
|
|
error: errorMessage,
|
|
status: response.status
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
error: data.message || data.detail || 'Error en la petición',
|
|
status: response.status
|
|
};
|
|
}
|
|
|
|
return {
|
|
data,
|
|
status: response.status
|
|
};
|
|
} catch (error) {
|
|
console.error(`❌ [API] Error de conexión en ${endpoint}:`, error);
|
|
return {
|
|
error: 'Error de conexión con el servidor',
|
|
status: 0
|
|
};
|
|
}
|
|
}
|
|
|
|
// Métodos HTTP
|
|
export const api = {
|
|
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
|
|
|
post: <T = any>(endpoint: string, body: any) =>
|
|
fetchApi<T>(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body)
|
|
}),
|
|
|
|
put: <T = any>(endpoint: string, body: any) =>
|
|
fetchApi<T>(endpoint, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body)
|
|
}),
|
|
|
|
patch: <T = any>(endpoint: string, body: any) =>
|
|
fetchApi<T>(endpoint, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body)
|
|
}),
|
|
|
|
delete: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'DELETE' }),
|
|
|
|
// Endpoints específicos
|
|
auth: {
|
|
login: (credentials: { username: string; password: string; tenant_slug: string }) =>
|
|
api.post('/v1/auth/login', credentials),
|
|
refresh: (refreshToken: string) =>
|
|
api.post('/v1/auth/refresh', { refresh_token: refreshToken }),
|
|
logout: (data: { refresh_token: string }) => api.post('/v1/auth/logout', data),
|
|
me: () => api.get('/v1/auth/me'),
|
|
health: () => api.get('/health')
|
|
},
|
|
|
|
tenants: {
|
|
list: (page = 1, pageSize = 50) =>
|
|
api.get(`/v1/tenants?page=${page}&page_size=${pageSize}`),
|
|
get: (id: number) => api.get(`/v1/tenants/${id}`),
|
|
create: (data: any) => api.post('/v1/tenants', data),
|
|
update: (id: number, data: any) => api.put(`/v1/tenants/${id}`, data)
|
|
},
|
|
|
|
licenses: {
|
|
get: (tenantId: number) => api.get(`/v1/licenses/tenant/${tenantId}`),
|
|
myLicense: () => api.get('/v1/licenses/my-license'),
|
|
usage: (tenantId: number) => api.get(`/v1/licenses/usage/${tenantId}`),
|
|
validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}`)
|
|
},
|
|
|
|
// Generic request for custom needs (like file uploads)
|
|
request: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options)
|
|
};
|