feat: Implement token refresh mechanism and infinite scroll for code pedimento regimens

This commit is contained in:
2025-11-02 13:48:42 -06:00
parent 2b735c27b8
commit 886a3aeab3
6 changed files with 449 additions and 112 deletions

View File

@@ -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<T = any> {
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<string | null> {
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<T = any>(
endpoint: string,
options: RequestInit = {}
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();
const headers: Record<string, string> = {
@@ -36,6 +145,41 @@ async function fetchApi<T = any>(
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<T>(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<T = any>(
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')

View File

@@ -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<boolean> => {
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
*/

View File

@@ -1,78 +1,68 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import {
type ColumnDef,
type PaginationState,
getCoreRowModel,
getPaginationRowModel
getCoreRowModel
} from "@tanstack/table-core";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import { Button } from "$lib/components/ui/button/index.js";
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
totalItems: number;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
loading: boolean;
hasMore: boolean;
loadMore: () => void;
};
let {
data,
columns,
totalItems,
currentPage,
pageSize,
onPageChange
loading,
hasMore,
loadMore
}: DataTableProps<TData, TValue> = $props();
let pagination = $state<PaginationState>({
pageIndex: currentPage - 1,
pageSize: pageSize
});
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
pageCount: Math.ceil(totalItems / pageSize),
state: {
get pagination() {
return pagination;
}
}
getCoreRowModel: getCoreRowModel()
});
$effect(() => {
pagination = {
pageIndex: currentPage - 1,
pageSize: pageSize
let scrollContainer = $state<HTMLDivElement>();
let loadingTrigger = $state<HTMLDivElement>();
// Intersection Observer para detectar cuando el usuario llega al final
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !loading) {
loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
);
if (loadingTrigger) {
observer.observe(loadingTrigger);
}
return () => {
observer.disconnect();
};
});
function handlePreviousPage() {
if (currentPage > 1) {
onPageChange(currentPage - 1);
}
}
function handleNextPage() {
const totalPages = Math.ceil(totalItems / pageSize);
if (currentPage < totalPages) {
onPageChange(currentPage + 1);
}
}
</script>
<div class="w-full">
<div class="rounded-md border">
<div class="rounded-md border max-h-[600px] overflow-y-auto" bind:this={scrollContainer}>
<Table.Root>
<Table.Header>
<Table.Header class="sticky top-0 bg-background z-10">
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
@@ -107,33 +97,27 @@
</Table.Cell>
</Table.Row>
{/each}
<!-- Loading Trigger - Se activa cuando es visible -->
{#if hasMore}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-20 text-center">
<div bind:this={loadingTrigger}>
{#if loading}
<div class="flex items-center justify-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span class="text-muted-foreground text-sm">Cargando más...</span>
</div>
{:else}
<div class="text-muted-foreground text-sm">
Desplázate para cargar más
</div>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<div class="flex items-center justify-between space-x-2 py-4">
<div class="text-muted-foreground text-sm">
Mostrando {data.length} de {totalItems} registro(s)
</div>
<div class="flex items-center space-x-2">
<div class="text-muted-foreground text-sm">
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
</div>
<Button
variant="outline"
size="sm"
onclick={handlePreviousPage}
disabled={currentPage === 1}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
onclick={handleNextPage}
disabled={currentPage >= Math.ceil(totalItems / pageSize)}
>
Siguiente
</Button>
</div>
</div>
</div>

View File

@@ -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)}`);
}
};

View File

@@ -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) {

View File

@@ -1,32 +1,107 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { CodePedimentoRegimen } from '$lib/api/dashboard/refrence_data/code_pedimento_regimens';
import { onMount } from 'svelte';
import { codePedimentoRegimensApi, type CodePedimentoRegimen } from '$lib/api/dashboard/refrence_data/code_pedimento_regimens';
import DataTable from '$lib/components/dashboard/reference_data/code_pedimento_regimens/data-table.svelte';
import { columns } from '$lib/components/dashboard/reference_data/code_pedimento_regimens/columns.js';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import type { PageData } from './$types';
import { browser } from '$app/environment';
// Los datos vienen del servidor a través del +page.server.ts
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
// Desestructurar los datos
let items: CodePedimentoRegimen[] = $derived(data.items || []);
let totalItems = $derived(data.total || 0);
let currentPage = $derived(data.page || 1);
let pageSize = $derived(data.page_size || 50);
let error = $derived(data.error || null);
// Sincronizar token de cookies a localStorage al montar el componente
onMount(() => {
if (browser) {
// Función para obtener el valor de una cookie
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;
};
function handlePageChange(page: number) {
// Navegar a la misma página con los parámetros de paginación
const url = new URL(window.location.href);
url.searchParams.set('page', page.toString());
url.searchParams.set('page_size', pageSize.toString());
goto(url.toString());
// Verificar si hay token en las cookies
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
if (cookieToken && cookieToken !== localToken) {
console.log('🔄 [Page] Sincronizando token de cookies a localStorage');
localStorage.setItem('access_token', cookieToken);
}
// También sincronizar refresh_token si existe
const cookieRefreshToken = getCookie('refresh_token');
const localRefreshToken = localStorage.getItem('refresh_token');
if (cookieRefreshToken && cookieRefreshToken !== localRefreshToken) {
console.log('🔄 [Page] Sincronizando refresh_token de cookies a localStorage');
localStorage.setItem('refresh_token', cookieRefreshToken);
}
}
});
// Estado para infinite scroll
let allItems = $state<CodePedimentoRegimen[]>(data.items || []);
let currentPage = $state(data.page || 1);
let pageSize = $state(50);
let totalItems = $state(data.total || 0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(data.error || null);
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
error = null;
try {
console.log(`📊 [Page] Cargando página ${currentPage + 1}...`);
console.log(`📊 [Page] Token disponible:`, localStorage.getItem('access_token') ? 'Sí' : 'No');
const response = await codePedimentoRegimensApi.list(currentPage + 1, pageSize);
console.log(`📊 [Page] Respuesta recibida:`, {
error: response.error,
status: response.status,
hasData: !!response.data
});
if (response.error) {
console.error('📊 [Page] Error en loadMore:', response.error, 'Status:', response.status);
// Si es un error de autenticación (401 o 403) y no se pudo refrescar, mostrar mensaje específico
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
// Recargar automáticamente después de 2 segundos
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data?.items) {
console.log(`📊 [Page] Cargados ${response.data.items.length} items adicionales`);
// Agregar los nuevos items al array existente
allItems = [...allItems, ...response.data.items];
currentPage++;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error cargando más datos';
console.error('📊 [Page] Error loading more:', e);
} finally {
loading = false;
}
}
function reloadData() {
// Forzar recarga de la página para obtener datos frescos
// Reset y recargar desde el principio
window.location.reload();
}
</script>
@@ -76,7 +151,9 @@
<div class="flex items-center justify-between">
<div>
<Card.Title>Listado de Relaciones</Card.Title>
<Card.Description>Total de registros: {totalItems}</Card.Description>
<Card.Description>
Mostrando {allItems.length} de {totalItems} registros
</Card.Description>
</div>
<Button variant="outline" onclick={reloadData}>
<svg
@@ -99,14 +176,13 @@
</div>
</Card.Header>
<Card.Content>
<!-- TanStack DataTable -->
<!-- TanStack DataTable con Infinite Scroll -->
<DataTable
data={items}
data={allItems}
{columns}
{totalItems}
{currentPage}
{pageSize}
onPageChange={handlePageChange}
{loading}
{hasMore}
{loadMore}
/>
</Card.Content>
</Card.Root>