384 lines
10 KiB
TypeScript
384 lines
10 KiB
TypeScript
/**
|
|
* Utilidades para llamadas a la API desde el servidor (SSR)
|
|
* Centraliza la lógica de configuración de URL, autenticación y manejo de tokens
|
|
*/
|
|
|
|
import { redirect, type Cookies } from '@sveltejs/kit';
|
|
|
|
/**
|
|
* Obtiene y normaliza la URL base de la API para llamadas desde el servidor
|
|
* Automáticamente reemplaza localhost/127.0.0.1 con 'backend' para Docker
|
|
*/
|
|
export function getServerApiUrl(): string {
|
|
// Primero intentar con INTERNAL_API_URL (para llamadas server-side en Docker)
|
|
let apiUrl = process.env.INTERNAL_API_URL;
|
|
|
|
// Si no está definida, usar VITE_API_URL del entorno runtime (no import.meta.env)
|
|
if (!apiUrl) {
|
|
apiUrl = process.env.VITE_API_URL;
|
|
}
|
|
|
|
// Como último recurso, usar el valor de build-time
|
|
if (!apiUrl) {
|
|
apiUrl = import.meta.env.VITE_API_URL;
|
|
// Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR)
|
|
apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend').replace('anexo76-dev.aduanasoft.com', 'backend');
|
|
}
|
|
|
|
// Normalizar la URL: asegurar que termine con '/'
|
|
return apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`;
|
|
}
|
|
|
|
/**
|
|
* Obtiene los tokens de autenticación de las cookies
|
|
*/
|
|
export function getAuthTokens(cookies: Cookies) {
|
|
return {
|
|
accessToken: cookies.get('access_token'),
|
|
refreshToken: cookies.get('refresh_token')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Establece los tokens de autenticación en las cookies
|
|
*/
|
|
export function setAuthTokens(
|
|
cookies: Cookies,
|
|
accessToken: string,
|
|
refreshToken?: string
|
|
) {
|
|
cookies.set('access_token', accessToken, {
|
|
path: '/',
|
|
httpOnly: false,
|
|
sameSite: 'lax',
|
|
secure: process.env.NODE_ENV === 'production',
|
|
maxAge: 60 * 60 * 24 * 7 // 7 días
|
|
});
|
|
|
|
if (refreshToken) {
|
|
cookies.set('refresh_token', refreshToken, {
|
|
path: '/',
|
|
httpOnly: false,
|
|
sameSite: 'lax',
|
|
secure: process.env.NODE_ENV === 'production',
|
|
maxAge: 60 * 60 * 24 * 30 // 30 días
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Limpia todos los tokens de autenticación de las cookies
|
|
*/
|
|
export function clearAuthTokens(cookies: Cookies) {
|
|
cookies.delete('access_token', { path: '/' });
|
|
cookies.delete('refresh_token', { path: '/' });
|
|
cookies.delete('active_company_id', { path: '/' });
|
|
}
|
|
|
|
/**
|
|
* Crea headers de autorización con el token Bearer
|
|
*/
|
|
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>) {
|
|
return {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
...additionalHeaders
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Intenta refrescar el token de acceso usando el refresh token
|
|
* @returns El nuevo access token o null si falla
|
|
*/
|
|
export async function refreshAccessToken(
|
|
cookies: Cookies,
|
|
fetch: typeof globalThis.fetch
|
|
): Promise<string | null> {
|
|
const { refreshToken } = getAuthTokens(cookies);
|
|
|
|
if (!refreshToken) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const baseUrl = getServerApiUrl();
|
|
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ refresh_token: refreshToken })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Actualizar las cookies con los nuevos tokens
|
|
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
|
|
|
return data.access_token;
|
|
} catch (error) {
|
|
console.error('🔄 [API] Error al refrescar token:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Realiza una petición autenticada a la API con manejo automático de refresh
|
|
* @param endpoint - Endpoint relativo (ej: 'v1/auth/me')
|
|
* @param options - Opciones de fetch
|
|
* @param cookies - Objeto de cookies de SvelteKit
|
|
* @param fetch - Función fetch de SvelteKit
|
|
* @param redirectUrl - URL a la que redirigir si falla la autenticación (opcional)
|
|
* @param timeout - Timeout en milisegundos (default: 30000ms)
|
|
*/
|
|
export async function authenticatedFetch(
|
|
endpoint: string,
|
|
options: RequestInit = {},
|
|
cookies: Cookies,
|
|
fetch: typeof globalThis.fetch,
|
|
redirectUrl?: string,
|
|
timeout: number = 30000
|
|
): Promise<Response> {
|
|
try {
|
|
const baseUrl = getServerApiUrl();
|
|
let { accessToken } = getAuthTokens(cookies);
|
|
|
|
// Si no hay token, redirigir o lanzar error
|
|
if (!accessToken) {
|
|
if (redirectUrl) {
|
|
throw redirect(303, redirectUrl);
|
|
}
|
|
throw new Error('No access token available');
|
|
}
|
|
|
|
// Construir URL completa
|
|
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
|
|
|
|
// Crear AbortController para timeout
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => {
|
|
console.error(`⏱️ [API] Timeout después de ${timeout}ms:`, endpoint);
|
|
controller.abort();
|
|
}, timeout);
|
|
|
|
// Realizar la petición inicial
|
|
// Si el body es FormData, no incluir Content-Type (el navegador lo establece con el boundary)
|
|
const isFormData = options.body instanceof FormData;
|
|
const headers = isFormData
|
|
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
|
|
: createAuthHeaders(accessToken, options.headers as Record<string, string>);
|
|
|
|
let response = await fetch(url, {
|
|
...options,
|
|
headers,
|
|
signal: controller.signal
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
// Si es 403, no intentar refrescar - es un problema de permisos
|
|
if (response.status === 403) {
|
|
console.warn('🚫 [API] Acceso denegado (403):', endpoint);
|
|
return response; // Retornar directamente para que el llamador maneje el error
|
|
}
|
|
|
|
// Si es 401, intentar refrescar el token
|
|
if (response.status === 401) {
|
|
const newToken = await refreshAccessToken(cookies, fetch);
|
|
|
|
if (newToken) {
|
|
// Reintentar la petición con el nuevo token
|
|
const newController = new AbortController();
|
|
const newTimeoutId = setTimeout(() => {
|
|
console.error(`⏱️ [API] Timeout en retry después de ${timeout}ms:`, endpoint);
|
|
newController.abort();
|
|
}, timeout);
|
|
|
|
// Si el body es FormData, no incluir Content-Type
|
|
const newHeaders = isFormData
|
|
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
|
|
: createAuthHeaders(newToken, options.headers as Record<string, string>);
|
|
|
|
response = await fetch(url, {
|
|
...options,
|
|
headers: newHeaders,
|
|
signal: newController.signal
|
|
});
|
|
|
|
clearTimeout(newTimeoutId);
|
|
} else {
|
|
// No se pudo refrescar, limpiar y redirigir
|
|
clearAuthTokens(cookies);
|
|
if (redirectUrl) {
|
|
throw redirect(303, redirectUrl);
|
|
}
|
|
}
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
// Si es un redirect, re-lanzarlo
|
|
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
|
throw error;
|
|
}
|
|
|
|
console.error('🔴 [API] Error en authenticatedFetch:', endpoint, error);
|
|
|
|
// Retornar una respuesta de error simulada en lugar de lanzar
|
|
return new Response(JSON.stringify({ error: 'Network error', details: String(error) }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Valida que el usuario esté autenticado y obtiene sus datos
|
|
* @returns Los datos del usuario o null si no está autenticado
|
|
*/
|
|
export async function validateAuth(
|
|
cookies: Cookies,
|
|
fetch: typeof globalThis.fetch,
|
|
redirectOnFail?: string
|
|
): Promise<any> {
|
|
try {
|
|
const response = await authenticatedFetch(
|
|
'v1/auth/me',
|
|
{},
|
|
cookies,
|
|
fetch,
|
|
redirectOnFail
|
|
);
|
|
|
|
if (!response.ok) {
|
|
if (redirectOnFail) {
|
|
clearAuthTokens(cookies);
|
|
throw redirect(303, redirectOnFail);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const keycloakData = await response.json();
|
|
|
|
// Obtener perfil adicional del usuario (avatar, bio, etc.)
|
|
try {
|
|
const profileResponse = await authenticatedFetch(
|
|
'v1/core/users/me/profile',
|
|
{},
|
|
cookies,
|
|
fetch
|
|
);
|
|
|
|
if (profileResponse.ok) {
|
|
const profileData = await profileResponse.json();
|
|
// Combinar datos de Keycloak con datos del perfil
|
|
return {
|
|
...keycloakData,
|
|
avatar_url: profileData.avatar_url,
|
|
phone: profileData.phone,
|
|
bio: profileData.bio,
|
|
preferences: profileData.preferences
|
|
};
|
|
}
|
|
} catch (profileError) {
|
|
console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak');
|
|
}
|
|
|
|
return keycloakData;
|
|
} catch (error) {
|
|
// Si es un redirect, re-lanzarlo
|
|
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
|
throw error;
|
|
}
|
|
|
|
console.error('🔐 [API] Error validando autenticación:', error);
|
|
|
|
if (redirectOnFail) {
|
|
clearAuthTokens(cookies);
|
|
throw redirect(303, redirectOnFail);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Obtiene las compañías del usuario autenticado
|
|
*/
|
|
export async function getUserCompanies(
|
|
cookies: Cookies,
|
|
fetch: typeof globalThis.fetch
|
|
): Promise<any[]> {
|
|
try {
|
|
const response = await authenticatedFetch(
|
|
'v1/a76/company/my-companies',
|
|
{},
|
|
cookies,
|
|
fetch
|
|
);
|
|
|
|
if (!response.ok) {
|
|
console.error('🏢 [API] Error cargando compañías:', response.status);
|
|
return [];
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('🏢 [API] Error cargando compañías:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Obtiene el ID de la compañía activa, o la primera disponible si no hay ninguna seleccionada
|
|
*/
|
|
export async function getActiveCompanyId(
|
|
cookies: Cookies,
|
|
fetch: typeof globalThis.fetch
|
|
): Promise<string | null> {
|
|
let companyId = cookies.get('active_company_id');
|
|
|
|
// Si no hay companyId en cookie, obtener las compañías del usuario y usar la primera
|
|
if (!companyId) {
|
|
const companies = await getUserCompanies(cookies, fetch);
|
|
if (companies.length > 0) {
|
|
companyId = companies[0].id.toString();
|
|
}
|
|
}
|
|
|
|
return companyId || null;
|
|
}
|
|
|
|
/**
|
|
* Helper para manejar respuestas de API y convertir errores 403 en formato adecuado
|
|
* para mostrar toasts en el cliente
|
|
*/
|
|
export async function handleApiResponse<T = any>(
|
|
response: Response
|
|
): Promise<{ data?: T; error?: { detail: string; status: number; isForbidden?: boolean } }> {
|
|
if (response.ok) {
|
|
// Para respuestas sin contenido (204)
|
|
if (response.status === 204) {
|
|
return { data: null as T };
|
|
}
|
|
|
|
const data = await response.json();
|
|
return { data };
|
|
}
|
|
|
|
// Manejar errores
|
|
const errorData = await response.json().catch(() => ({ detail: 'Error desconocido' }));
|
|
|
|
const error = {
|
|
detail: errorData.detail || errorData.message || 'Error en la petición',
|
|
status: response.status,
|
|
isForbidden: response.status === 403
|
|
};
|
|
|
|
return { error };
|
|
}
|