From 886a3aeab3ef9dd65cfd003b75678e74943d7aba Mon Sep 17 00:00:00 2001 From: acazares Date: Sun, 2 Nov 2025 13:48:42 -0600 Subject: [PATCH] feat: Implement token refresh mechanism and infinite scroll for code pedimento regimens --- frontend/src/lib/api.ts | 151 +++++++++++++++++- frontend/src/lib/auth.ts | 59 +++++++ .../code_pedimento_regimens/data-table.svelte | 126 +++++++-------- .../src/routes/dashboard/+layout.server.ts | 98 ++++++++++-- .../code_pedimento_regimens/+page.server.ts | 5 +- .../code_pedimento_regimens/+page.svelte | 122 +++++++++++--- 6 files changed, 449 insertions(+), 112 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a0a996d1..d46689f7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,6 +2,7 @@ * Cliente API para comunicación con el backend */ import { getToken } from './auth'; +import { browser } from '$app/environment'; const API_BASE_URL = import.meta.env.VITE_API_URL; @@ -11,13 +12,121 @@ export interface ApiResponse { status: number; } +let isRefreshing = false; +let refreshSubscribers: ((token: string) => void)[] = []; + /** - * Realiza una petición al API + * 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 { + if (!browser) return null; + + const refreshTokenValue = localStorage.getItem('refresh_token'); + if (!refreshTokenValue) { + console.warn('🔄 No refresh token available'); + return null; + } + + try { + console.log('🔄 [API] Intentando refrescar token...'); + + 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'); + // 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) { + console.log('✅ [API] Token refrescado exitosamente'); + 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( endpoint: string, - options: RequestInit = {} + options: RequestInit = {}, + retryCount = 0 ): Promise> { + // Si ya estamos refrescando el token, esperar + if (isRefreshing && retryCount === 0) { + return new Promise((resolve) => { + subscribeTokenRefresh((newToken) => { + resolve(fetchApi(endpoint, options, 1)); + }); + }); + } + const token = getToken(); const headers: Record = { @@ -36,6 +145,41 @@ async function fetchApi( 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) { + console.warn(`⚠️ [API] ${response.status} recibido en ${endpoint}, intentando refrescar token...`); + console.log(`⚠️ [API] Token actual disponible:`, token ? 'Sí (parcial: ' + token.substring(0, 20) + '...)' : 'No'); + isRefreshing = true; + + try { + const newToken = await refreshToken(); + + if (newToken) { + // Token refrescado exitosamente + console.log(`✅ [API] Reintentando petición a ${endpoint} con nuevo token`); + onTokenRefreshed(newToken); + isRefreshing = false; + // Reintentar la petición original con el nuevo token + return await fetchApi(endpoint, options, 1); + } else { + console.error(`❌ [API] No se pudo refrescar el token para ${endpoint}`); + 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 token:`, refreshError); + isRefreshing = false; + return { + error: 'Error al refrescar la sesión', + status: 401 + }; + } + } + const data = await response.json(); if (!response.ok) { @@ -50,6 +194,7 @@ async function fetchApi( 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 @@ -79,6 +224,8 @@ export const api = { 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') diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index ff0b5933..6c1c1da6 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -289,6 +289,9 @@ export const login = async (credentials: { // Guardar en cookies para que el servidor pueda acceder setCookie('access_token', loginData.access_token); + if (loginData.refresh_token) { + setCookie('refresh_token', loginData.refresh_token); + } } // Cargar información del usuario @@ -362,6 +365,8 @@ export const logout = async () => { authStore.reset(); localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); + deleteCookie('access_token'); + deleteCookie('refresh_token'); // Si hay instancia de Keycloak, hacer logout de Keycloak if (keycloakInstance?.authenticated) { @@ -411,6 +416,60 @@ export const getToken = (): string | null => { return null; }; +/** + * Refresca el access token usando el refresh token + */ +export const refreshAccessToken = async (): Promise => { + if (!browser) return false; + + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken) { + console.warn('No refresh token available'); + return false; + } + + try { + const { api } = await import('./api'); + const response = await api.auth.refresh(refreshToken); + + if (response.error || !response.data) { + console.error('Failed to refresh token:', response.error); + // Si falla el refresh, hacer logout + await logout(); + return false; + } + + // Actualizar tokens + const newAccessToken = response.data.access_token; + const newRefreshToken = response.data.refresh_token; + + authStore.setToken(newAccessToken); + localStorage.setItem('access_token', newAccessToken); + + if (newRefreshToken) { + localStorage.setItem('refresh_token', newRefreshToken); + } + + // Actualizar también la cookie + setCookie('access_token', newAccessToken); + + console.log('✅ Token refreshed successfully'); + return true; + } catch (error) { + console.error('Error refreshing token:', error); + await logout(); + return false; + } +}; + +/** + * Obtiene el refresh token + */ +export const getRefreshToken = (): string | null => { + if (!browser) return null; + return localStorage.getItem('refresh_token'); +}; + /** * Obtiene la instancia de Keycloak */ diff --git a/frontend/src/lib/components/dashboard/reference_data/code_pedimento_regimens/data-table.svelte b/frontend/src/lib/components/dashboard/reference_data/code_pedimento_regimens/data-table.svelte index c16b5315..ef98de23 100644 --- a/frontend/src/lib/components/dashboard/reference_data/code_pedimento_regimens/data-table.svelte +++ b/frontend/src/lib/components/dashboard/reference_data/code_pedimento_regimens/data-table.svelte @@ -1,78 +1,68 @@
-
+
- + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} {#each headerGroup.headers as header (header.id)} @@ -107,33 +97,27 @@ {/each} + + + {#if hasMore} + + +
+ {#if loading} +
+
+ Cargando más... +
+ {:else} +
+ Desplázate para cargar más +
+ {/if} +
+
+
+ {/if}
-
-
- Mostrando {data.length} de {totalItems} registro(s) -
-
-
- Página {currentPage} de {Math.ceil(totalItems / pageSize)} -
- - -
-
diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index b95e0ad3..94db7853 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -3,7 +3,8 @@ import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // Verificar si existe el token en las cookies - const token = cookies.get('access_token'); + let token = cookies.get('access_token'); + const refreshToken = cookies.get('refresh_token'); // Si no hay token, redirigir al login if (!token) { @@ -11,20 +12,19 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`); } + // Configurar la URL de la API + let apiUrl = process.env.INTERNAL_API_URL; + 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'); + } + + // Normalizar la URL: asegurar que termine con '/' + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + // Validar el token con el backend para asegurar que sea válido try { - // En Docker, el servidor debe usar el nombre del servicio 'backend' en lugar de 'localhost' - // VITE_API_URL ya incluye '/api/' al final (ej: http://localhost:8000/api/) - let apiUrl = process.env.INTERNAL_API_URL; - 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'); - } - - // Normalizar la URL: asegurar que termine con '/' - const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; - console.log('🔐 [Dashboard] Validando token con:', `${baseUrl}v1/auth/me`); const response = await fetch(`${baseUrl}v1/auth/me`, { @@ -33,9 +33,76 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { } }); - if (!response.ok) { - // Token inválido, limpiar y redirigir + // Si el token está expirado (401) y tenemos refresh token, intentar refrescar + if (response.status === 401 && refreshToken) { + console.log('🔄 [Dashboard] Token expirado, intentando refrescar...'); + + try { + const refreshResponse = await fetch(`${baseUrl}v1/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (refreshResponse.ok) { + const refreshData = await refreshResponse.json(); + + // Actualizar las cookies con los nuevos tokens + cookies.set('access_token', refreshData.access_token, { + path: '/', + httpOnly: false, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 7 // 7 días + }); + + if (refreshData.refresh_token) { + cookies.set('refresh_token', refreshData.refresh_token, { + path: '/', + httpOnly: false, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 30 // 30 días + }); + } + + // Usar el nuevo token para obtener la info del usuario + token = refreshData.access_token; + console.log('✅ [Dashboard] Token refrescado exitosamente'); + + // Reintentar la validación con el nuevo token + const retryResponse = await fetch(`${baseUrl}v1/auth/me`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (retryResponse.ok) { + const userData = await retryResponse.json(); + return { + authenticated: true, + user: userData + }; + } + } else { + console.log('❌ [Dashboard] Refresh token también está expirado'); + } + } catch (refreshError) { + console.error('🔐 [Dashboard] Error al refrescar token:', refreshError); + } + + // Si llegamos aquí, el refresh falló cookies.delete('access_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`); + } + + if (!response.ok) { + // Token inválido y no se pudo refrescar, limpiar y redirigir + cookies.delete('access_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`); } @@ -54,6 +121,7 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // Para cualquier otro error (conexión, etc), limpiar token y redirigir console.error('🔐 [Dashboard] Error validando token:', error); cookies.delete('access_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`); } }; diff --git a/frontend/src/routes/dashboard/reference_data/code_pedimento_regimens/+page.server.ts b/frontend/src/routes/dashboard/reference_data/code_pedimento_regimens/+page.server.ts index 3aabfdd4..603f5785 100644 --- a/frontend/src/routes/dashboard/reference_data/code_pedimento_regimens/+page.server.ts +++ b/frontend/src/routes/dashboard/reference_data/code_pedimento_regimens/+page.server.ts @@ -1,6 +1,9 @@ import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ cookies, fetch, url }) => { +export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { + // Esperar a que el layout padre valide/refresque el token + await parent(); + const token = cookies.get('access_token'); if (!token) { diff --git a/frontend/src/routes/dashboard/reference_data/code_pedimento_regimens/+page.svelte b/frontend/src/routes/dashboard/reference_data/code_pedimento_regimens/+page.svelte index 85e4e473..4dda50d2 100644 --- a/frontend/src/routes/dashboard/reference_data/code_pedimento_regimens/+page.svelte +++ b/frontend/src/routes/dashboard/reference_data/code_pedimento_regimens/+page.svelte @@ -1,32 +1,107 @@ @@ -76,7 +151,9 @@
Listado de Relaciones - Total de registros: {totalItems} + + Mostrando {allItems.length} de {totalItems} registros +