From 73fb03795d800d1a319a9a254abb5ebb5ae44b19 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 15 May 2026 12:54:55 -0500 Subject: [PATCH] fix(reports): silent refresh y tenant override en descargas blob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchBlob no implementaba silent refresh ni propagaba X-Tenant-Override, por lo que reportes que descargan archivo (Descargos, Vencimientos) fallaban con "token inválido o expirado" cuando el access_token estaba vencido, mientras el resto del dashboard refrescaba sin problema. - Extrae buildAuthHeaders compartido por fetchApi y fetchBlob. - fetchBlob ahora hace silent refresh y reintenta una vez en 401. - fetchBlob muestra toast consistente en 403. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/api.ts | 100 ++++++++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 25 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 00872354..0a89942e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -208,6 +208,32 @@ async function refreshToken(): Promise { } } +/** + * Construye los headers de autenticación (Bearer + X-Tenant-Override SSO multi-tenant). + * Compartido por fetchApi y fetchBlob para garantizar trato uniforme. + */ +function buildAuthHeaders(baseHeaders: Record = {}): Record { + const headers: Record = { ...baseHeaders }; + const token = getToken(); + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + // sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id. + if (browser) { + const tenantPub = document.cookie + .split('; ') + .find((c) => c.startsWith('sso_tenant_pub=')) + ?.split('=')[1]; + if (tenantPub) { + headers['X-Tenant-Override'] = tenantPub; + } + } + + return headers; +} + /** * Realiza una petición al API con manejo automático de refresh token */ @@ -231,30 +257,16 @@ async function fetchApi( console.warn('⚠️ [API] No hay token disponible para', endpoint); } - const headers: Record = { + const baseHeaders: Record = { ...((options.headers as Record) || {}) }; // 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 (!baseHeaders['Content-Type'] && !(options.body instanceof FormData)) { + baseHeaders['Content-Type'] = 'application/json'; } - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - // Incluir tenant override para flujo SSO multi-tenant. - // sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id. - if (browser) { - const tenantPub = document.cookie - .split('; ') - .find((c) => c.startsWith('sso_tenant_pub=')) - ?.split('=')[1]; - if (tenantPub) { - headers['X-Tenant-Override'] = tenantPub; - } - } + const headers = buildAuthHeaders(baseHeaders); try { const response = await fetch(`${API_BASE_URL}${endpoint}`, { @@ -685,21 +697,59 @@ function messageFromBlobErrorResponse(text: string, status: number): string { return raw; } -async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise { - const token = getToken(); - const headers: Record = { - ...((options.headers as Record) || {}) - }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; +async function fetchBlob( + endpoint: string, + options: RequestInit = {}, + retryCount = 0 +): Promise { + // Si ya estamos refrescando el token, esperar a que termine antes de pegar. + if (isRefreshing && retryCount === 0) { + return new Promise((resolve, reject) => { + subscribeTokenRefresh(() => { + fetchBlob(endpoint, options, 1).then(resolve).catch(reject); + }); + }); } + const headers = buildAuthHeaders((options.headers as Record) || {}); + const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers, credentials: 'include' }); + // 401: intentar silent refresh y reintentar una vez (mismo flujo que fetchApi). + if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) { + isRefreshing = true; + try { + const newToken = await refreshToken(); + if (newToken) { + onTokenRefreshed(newToken); + isRefreshing = false; + return await fetchBlob(endpoint, options, 1); + } + isRefreshing = false; + throw new Error('Sesión expirada. Por favor, inicia sesión nuevamente.'); + } catch (refreshError) { + isRefreshing = false; + if (refreshError instanceof Error) throw refreshError; + throw new Error('Error al refrescar la sesión'); + } + } + + // 403: notificar permisos de manera consistente con fetchApi. + if (response.status === 403) { + if (browser) { + toast.error('No tienes permisos para realizar esta acción', { + duration: 4000, + description: 'Contacta a tu administrador si crees que esto es un error' + }); + } + const text = await response.text().catch(() => ''); + throw new Error(messageFromBlobErrorResponse(text, response.status)); + } + if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(messageFromBlobErrorResponse(text, response.status));