Merge branch 'development' into task/archivos_winsaai

This commit is contained in:
2026-03-05 08:19:07 -06:00
140 changed files with 25119 additions and 1400 deletions

View File

@@ -40,94 +40,49 @@ function onTokenRefreshed(token: string) {
}
/**
* Intenta refrescar el token usando el refresh token
* Refresca el token silenciosamente usando el endpoint server-side.
*
* El servidor lee el refresh_token desde la cookie HttpOnly,
* llama a Keycloak, actualiza las cookies y devuelve el nuevo access_token.
* El refresh_token NUNCA es leído por este código JavaScript.
*/
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`, {
const response = await fetch('/api-sveltekit/auth/silent-refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh_token: refreshTokenValue }),
credentials: 'include'
credentials: 'include', // Envía cookies HttpOnly automáticamente
headers: { 'Content-Type': 'application/json' }
});
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
console.error('❌ [API] Silent refresh falló, status:', response.status);
// Limpiar la cookie del access_token (no HttpOnly) para forzar re-login
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);
setTimeout(() => { window.location.href = '/login'; }, 1500);
return null;
}
const data = await response.json();
const data = await response.json() as { access_token?: string };
// Guardar los nuevos tokens
if (data.access_token) {
localStorage.setItem('access_token', data.access_token);
// Actualizar cookie no-HttpOnly del access_token
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secure}`;
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
// Actualizar authStore en memoria
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);
}
} catch {}
return data.access_token;
}
return null;
} catch (error) {
console.error('❌ [API] Error refreshing token:', error);
console.error('❌ [API] Error en silent refresh:', error);
return null;
}
}
@@ -315,6 +270,35 @@ export const api = {
delete: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, { method: 'DELETE', ...options }),
/**
* Download CSV template by template_id (generated from code, no static file).
* Returns blob and suggested filename for the browser download.
*/
async getCsvTemplateDownload(
templateId: string
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE_URL}/v1/a76/csv-templates/${templateId}`, {
method: 'GET',
headers,
credentials: 'include'
});
if (!response.ok) {
const msg = response.status === 404 ? 'Plantilla no encontrada' : `Error ${response.status}`;
throw new Error(msg);
}
const blob = await response.blob();
let filename = `plantilla_${templateId}.csv`;
const disposition = response.headers.get('Content-Disposition');
if (disposition) {
const match = /filename="?([^";\n]+)"?/.exec(disposition);
if (match) filename = match[1].trim();
}
return { blob, filename };
},
// Endpoints específicos
auth: {
login: (credentials: { username: string; password: string; tenant_slug: string }) =>
@@ -341,6 +325,215 @@ export const api = {
validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`)
},
imports: {
upload: (
file: File,
modelTarget: string,
footerConfig: any,
companyId: number,
operationType: string,
templateId?: string
) => {
const formData = new FormData();
formData.append('file', file);
if (footerConfig) {
formData.append('footer_config', JSON.stringify(footerConfig));
}
if (templateId) {
formData.append('template_id', templateId);
}
const queryParams = new URLSearchParams({
company_id: String(companyId),
operation_type: operationType || 'imp'
}).toString();
return fetchApi(`/v1/a76/imports/upload/${modelTarget}?${queryParams}`, {
method: 'POST',
body: formData
});
},
status: (jobId: string) => api.get(`/v1/a76/imports/${jobId}/status`),
commit: (jobId: string, modelTarget: string) =>
api.post(`/v1/a76/imports/${jobId}/commit`, { model_target: modelTarget })
},
// CSV import for Agentes Aduanales (flujo propio en customs_brokers/imports)
customsBrokerImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/customs-brokers/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/customs-brokers/imports/${jobId}/status`),
commit: (jobId: string) =>
api.post(`/v1/a76/customs-brokers/imports/${jobId}/commit`, {})
},
// CSV import for Clientes y Proveedores (flujo propio en clients_and_providers/imports)
clientProviderImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/clients-providers/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/clients-providers/imports/${jobId}/status`),
commit: (jobId: string) =>
api.post(`/v1/a76/clients-providers/imports/${jobId}/commit`, {})
},
// CSV import for Tipos de Cambio (flujo en general_catalogs/exchange_rate/imports)
exchangeRateImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/exchange-rate/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/exchange-rate/imports/${jobId}/status`),
commit: (jobId: string) =>
api.post(`/v1/a76/exchange-rate/imports/${jobId}/commit`, {})
},
// CSV import for Fracción Americana (us_tariff_fractions/imports)
americanFractionImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/us-tariff-fractions/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/us-tariff-fractions/imports/${jobId}/status`),
commit: (jobId: string) =>
api.post(`/v1/a76/us-tariff-fractions/imports/${jobId}/commit`, {})
},
// CSV import for Pedimentos (pedimentos/imports)
pedimentosImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/pedimentos/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/pedimentos/imports/${jobId}/status`),
commit: (jobId: string) =>
api.post(`/v1/a76/pedimentos/imports/${jobId}/commit`, {})
},
// CSV import for Clases de Materiales (classes/imports)
materialClassImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/classes/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/classes/imports/${jobId}/status`),
commit: (jobId: string) =>
api.post(`/v1/a76/classes/imports/${jobId}/commit`, {})
},
// CSV import for Vehículos / Transportes (transportation/vehicles/imports)
vehicleImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/transportation/vehicles/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/transportation/vehicles/imports/${jobId}/status`),
commit: (jobId: string) =>
api.post(`/v1/a76/transportation/vehicles/imports/${jobId}/commit`, {})
},
// CSV import for Conductores (drivers/imports)
driverImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/drivers/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/drivers/imports/${jobId}/status`),
commit: (jobId: string) => api.post(`/v1/a76/drivers/imports/${jobId}/commit`, {})
},
// CSV import for Trailers y Cajas (transportation/trailers/imports)
trailerImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/transportation/trailers/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/transportation/trailers/imports/${jobId}/status`),
commit: (jobId: string) =>
api.post(`/v1/a76/transportation/trailers/imports/${jobId}/commit`, {})
},
// CSV import for Transportistas (transporters/imports)
transporterImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/transporters/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/transporters/imports/${jobId}/status`),
commit: (jobId: string) => api.post(`/v1/a76/transporters/imports/${jobId}/commit`, {})
},
// CSV import for Números de parte (parts/imports)
partNumberImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/parts/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/parts/imports/${jobId}/status`),
commit: (jobId: string) => api.post(`/v1/a76/parts/imports/${jobId}/commit`, {})
},
// CSV import for BOMs (boms/imports)
bomImports: {
upload: (file: File, companyId: number) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
`/v1/a76/boms/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
);
},
status: (jobId: string) => api.get(`/v1/a76/boms/imports/${jobId}/status`),
commit: (jobId: string) => api.post(`/v1/a76/boms/imports/${jobId}/commit`, {})
},
// Generic request for custom needs (like file uploads)
request: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options)
};

View File

@@ -1,11 +1,21 @@
/**
* Servicio de autenticación con Keycloak
*
* Seguridad de tokens:
* - access_token → en memoria (authStore) + cookie no-HttpOnly (para SSR)
* - refresh_token → cookie HttpOnly únicamente (JS nunca lo lee directamente)
* - El refresh se hace server-side via /api-sveltekit/auth/silent-refresh
* - NO se usa localStorage para tokens
*/
import Keycloak from 'keycloak-js';
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
// ─────────────────────────────────────────────────────────
// Tipos
// ─────────────────────────────────────────────────────────
export interface User {
id: string;
username: string;
@@ -22,51 +32,51 @@ export interface AuthState {
token: string | null;
}
// ─────────────────────────────────────────────────────────
// Configuración de Keycloak
// ─────────────────────────────────────────────────────────
const keycloakConfig = {
url: import.meta.env.VITE_KEYCLOAK_URL,
realm: import.meta.env.VITE_KEYCLOAK_REALM,
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID
};
// Instancia de Keycloak
let keycloakInstance: Keycloak | null = null;
// Helper para obtener cookies
// ─────────────────────────────────────────────────────────
// Cookie helpers (solo para access_token no-HttpOnly)
// ─────────────────────────────────────────────────────────
/** Lee el valor de una cookie no-HttpOnly */
const getCookie = (name: string): string | null => {
if (!browser) return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
return null;
};
// Helper para establecer cookies con las opciones correctas según el entorno
/** Escribe una cookie no-HttpOnly */
const setCookie = (name: string, value: string, days: number = 7) => {
if (!browser) return;
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + days);
// En desarrollo (localhost), no usar Secure flag
const isSecure = window.location.protocol === 'https:';
const secureFlag = isSecure ? '; Secure' : '';
const cookieString = `${name}=${value}; path=/; expires=${expirationDate.toUTCString()}; SameSite=Lax${secureFlag}`;
document.cookie = cookieString;
// Verificar que se estableció
const verification = getCookie(name);
const exp = new Date();
exp.setDate(exp.getDate() + days);
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${name}=${value}; path=/; expires=${exp.toUTCString()}; SameSite=Lax${secure}`;
};
// Helper para eliminar cookies
/** Elimina una cookie */
const deleteCookie = (name: string) => {
if (!browser) return;
const isSecure = window.location.protocol === 'https:';
const secureFlag = isSecure ? '; Secure' : '';
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secure}`;
};
// Store de autenticación
// ─────────────────────────────────────────────────────────
// Auth store (tokens solo en memoria)
// ─────────────────────────────────────────────────────────
const createAuthStore = () => {
const { subscribe, set, update } = writable<AuthState>({
isAuthenticated: false,
@@ -78,16 +88,16 @@ const createAuthStore = () => {
return {
subscribe,
setAuthenticated: (authenticated: boolean) =>
update((state) => ({ ...state, isAuthenticated: authenticated })),
update((s) => ({ ...s, isAuthenticated: authenticated })),
setLoading: (loading: boolean) =>
update((state) => ({ ...state, isLoading: loading })),
setUser: (user: User | null) => update((state) => ({ ...state, user })),
setToken: (token: string | null) => update((state) => ({ ...state, token })),
setTokens: (accessToken: string, refreshToken?: string) => {
update((state) => ({ ...state, token: accessToken }));
if (browser && refreshToken) {
localStorage.setItem('refresh_token', refreshToken);
}
update((s) => ({ ...s, isLoading: loading })),
setUser: (user: User | null) => update((s) => ({ ...s, user })),
setToken: (token: string | null) => update((s) => ({ ...s, token })),
/** ⚠️ Los tokens ya NO se guardan en localStorage; solo en memoria. */
setTokens: (accessToken: string, _refreshToken?: string) => {
update((s) => ({ ...s, token: accessToken }));
// El refresh_token llega en cookie HttpOnly desde el servidor;
// el cliente no lo almacena ni lo lee en ningún momento.
},
reset: () =>
set({
@@ -101,14 +111,17 @@ const createAuthStore = () => {
export const authStore = createAuthStore();
// Derived store para verificar si está autenticado
export const isAuthenticated = derived(authStore, ($auth) => $auth.isAuthenticated);
export const isAuthenticated = derived(authStore, ($a) => $a.isAuthenticated);
export const currentUser = derived(authStore, ($a) => $a.user);
// Derived store para obtener el usuario
export const currentUser = derived(authStore, ($auth) => $auth.user);
// ─────────────────────────────────────────────────────────
// Inicialización
// ─────────────────────────────────────────────────────────
/**
* Inicializa la autenticación (Keycloak o token-based)
* Inicializa el estado de autenticación en el cliente.
* - Si hay un access_token en la cookie no-HttpOnly, lo usa.
* - En cualquier caso intenta inicializar Keycloak JS (para el flujo SSO).
*/
export const initAuth = async (): Promise<boolean> => {
if (!browser) return false;
@@ -116,38 +129,28 @@ export const initAuth = async (): Promise<boolean> => {
try {
authStore.setLoading(true);
// Primero intentar restaurar sesión desde localStorage
const token = localStorage.getItem('access_token');
if (token) {
authStore.setToken(token);
// Restaurar token desde cookie no-HttpOnly (password login flow)
const cookieToken = getCookie('access_token');
if (cookieToken) {
authStore.setToken(cookieToken);
authStore.setAuthenticated(true);
// Sincronizar con cookies si no existe
const cookieToken = getCookie('access_token');
if (!cookieToken) {
setCookie('access_token', token);
}
await loadUserInfo(token);
await loadUserInfo(cookieToken).catch(() => {});
authStore.setLoading(false);
return true;
}
// Si no hay token local, intentar con Keycloak
await initKeycloak();
// Sin token local, intentar Keycloak JS (flujo SSO)
const authenticated = await initKeycloak();
authStore.setLoading(false);
return false;
} catch (error) {
console.error('Error inicializando autenticación:', error);
return authenticated;
} catch (err) {
console.error('[auth] Error en initAuth:', err);
authStore.setLoading(false);
return false;
}
};
/**
* Inicializa Keycloak
*/
/** Inicializa Keycloak JS para el flujo SSO con PKCE */
export const initKeycloak = async (): Promise<boolean> => {
if (!browser) return false;
@@ -163,22 +166,18 @@ export const initKeycloak = async (): Promise<boolean> => {
if (authenticated) {
await updateAuthState();
setupTokenRefresh();
setupKeycloakTokenHooks();
}
return authenticated;
} catch (error) {
console.error('Error inicializando Keycloak:', error);
} catch (err) {
console.error('[auth] Error inicializando Keycloak:', err);
return false;
}
};
// Variable para rastrear el tenant anterior
let previousTenantId: number | undefined = undefined;
/**
* Actualiza el estado de autenticación con los datos de Keycloak
*/
const updateAuthState = async () => {
if (!keycloakInstance?.authenticated) {
authStore.reset();
@@ -187,22 +186,22 @@ const updateAuthState = async () => {
try {
const profile = await keycloakInstance.loadUserProfile();
const token = keycloakInstance.token || null;
const tokenParsed = keycloakInstance.tokenParsed as any;
const token = keycloakInstance.token ?? null;
const parsed = keycloakInstance.tokenParsed as any;
const roles = tokenParsed?.realm_access?.roles || [];
const tenantId = tokenParsed?.tenant_id || tokenParsed?.attributes?.tenant_id;
const newTenantId = tenantId ? parseInt(tenantId) : undefined;
const roles: string[] = parsed?.realm_access?.roles ?? [];
const tenantId: number | undefined = parsed?.tenant_id
? parseInt(parsed.tenant_id)
: undefined;
// Detectar si cambió el tenant
const tenantChanged = previousTenantId !== undefined && previousTenantId !== newTenantId;
const tenantChanged = previousTenantId !== undefined && previousTenantId !== tenantId;
const user: User = {
id: profile.id || '',
username: profile.username || '',
id: profile.id ?? '',
username: profile.username ?? '',
email: profile.email,
name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(),
tenantId: newTenantId,
name: `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim(),
tenantId,
roles
};
@@ -210,71 +209,83 @@ const updateAuthState = async () => {
authStore.setUser(user);
authStore.setToken(token);
// Si cambió el tenant, limpiar el store de compañías
if (tenantChanged && browser) {
try {
const { companyStore } = await import('./stores/company.svelte');
companyStore.clear();
} catch (error) {
console.error('Error al limpiar store de compañías:', error);
}
} catch {}
}
// Actualizar el tenant anterior
previousTenantId = newTenantId;
} catch (error) {
console.error('Error actualizando estado de autenticación:', error);
previousTenantId = tenantId;
} catch (err) {
console.error('[auth] Error actualizando estado:', err);
authStore.reset();
}
};
// ─────────────────────────────────────────────────────────
// Keycloak JS token hooks (solo para el flujo SSO)
// ─────────────────────────────────────────────────────────
/**
* Configura el refresh automático del token
* Configura los callbacks de Keycloak JS para notificar al SessionManager
* sobre cambios de token y eventos de sesión SSO.
*/
const setupTokenRefresh = () => {
const setupKeycloakTokenHooks = () => {
if (!keycloakInstance) return;
// Refrescar token cada 60 segundos si está cerca de expirar
keycloakInstance.onTokenExpired = () => {
keycloakInstance
?.updateToken(70)
.then((refreshed) => {
if (refreshed) {
authStore.setToken(keycloakInstance?.token || null);
if (refreshed && keycloakInstance?.token) {
authStore.setToken(keycloakInstance.token);
import('./session-manager')
.then(({ getSessionManager }) => {
getSessionManager()?.updateToken(keycloakInstance!.token!);
})
.catch(() => {});
}
})
.catch(() => {
console.error('Error refrescando token');
logout();
console.error('[auth] No se pudo refrescar el token de Keycloak');
void logout();
});
};
keycloakInstance.onAuthRefreshSuccess = () => {
if (keycloakInstance?.token) authStore.setToken(keycloakInstance.token);
};
keycloakInstance.onAuthRefreshError = () => {
console.error('[auth] Error en refresh de Keycloak — cerrando sesión');
void logout();
};
keycloakInstance.onAuthLogout = () => {
authStore.reset();
};
};
/**
* Inicia sesión con Keycloak (OAuth flow)
*/
// ─────────────────────────────────────────────────────────
// Login
// ─────────────────────────────────────────────────────────
/** Inicia sesión con Keycloak (OAuth redirect flow) */
export const loginWithKeycloak = async (tenantSlug?: string) => {
if (!keycloakInstance) {
console.error('Keycloak no está inicializado');
console.error('[auth] Keycloak no está inicializado');
return;
}
const options: any = {
redirectUri: window.location.origin + '/callback'
};
if (tenantSlug) {
options.loginHint = tenantSlug;
}
const options: any = { redirectUri: window.location.origin + '/callback' };
if (tenantSlug) options.loginHint = tenantSlug;
await keycloakInstance.login(options);
};
/**
* Inicia sesión con credenciales (username/password)
* Nota: Esta función ya no se usa directamente desde el login form,
* el login ahora se hace mediante form actions del servidor.
* Se mantiene para compatibilidad con SSO y otros flujos.
* Login con usuario/contraseña (legacy — el login principal es via form action del servidor).
* Los tokens se guardan en cookies (vía setCookie) y en memoria (authStore).
* NO se guardan en localStorage.
*/
export const login = async (credentials: {
username: string;
@@ -282,235 +293,185 @@ export const login = async (credentials: {
tenant_slug: string;
}): Promise<{ success: boolean; error?: string; data?: any }> => {
try {
// Usar la API centralizada
const { api } = await import('./api');
const response = await api.auth.login(credentials);
// Si hay error en la respuesta
if (response.error) {
return {
success: false,
error: response.error
};
return { success: false, error: response.error };
}
// Guardar tokens y actualizar estado
const loginData = response.data;
if (loginData?.access_token) {
// Guardar en memoria y en cookie no-HttpOnly para SSR
authStore.setToken(loginData.access_token);
authStore.setAuthenticated(true);
// Guardar también en localStorage para persistencia
if (browser) {
localStorage.setItem('access_token', loginData.access_token);
if (loginData.refresh_token) {
localStorage.setItem('refresh_token', loginData.refresh_token);
}
// 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
setCookie('access_token', loginData.access_token);
// El refresh_token llega en cookie HttpOnly desde el servidor.
// NO lo guardamos en JS.
await loadUserInfo(loginData.access_token);
}
return {
success: true,
data: loginData
};
} catch (error) {
console.error('Error en login:', error);
return {
success: false,
error: 'Error de conexión con el servidor'
};
return { success: true, data: loginData };
} catch (err) {
console.error('[auth] Error en login:', err);
return { success: false, error: 'Error de conexión con el servidor' };
}
};
/**
* Carga la información del usuario desde el token
*/
// ─────────────────────────────────────────────────────────
// User info
// ─────────────────────────────────────────────────────────
const loadUserInfo = async (token: string) => {
try {
// Guardar temporalmente el token para que api.ts lo use
authStore.setToken(token);
// Usar la API centralizada
const { api } = await import('./api');
const response = await api.auth.me();
if (response.data) {
const data = response.data;
const user: User = {
id: data.sub || '',
username: data.preferred_username || data.username || '',
email: data.email,
name: data.name,
tenantId: data.tenant_id,
roles: data.realm_access?.roles || []
};
authStore.setUser(user);
const d = response.data;
authStore.setUser({
id: d.sub ?? '',
username: d.preferred_username ?? d.username ?? '',
email: d.email,
name: d.name,
tenantId: d.tenant_id,
roles: d.realm_access?.roles ?? []
});
}
} catch (error) {
console.error('Error cargando información del usuario:', error);
} catch (err) {
console.error('[auth] Error cargando info del usuario:', err);
}
};
/**
* Cierra sesión
*/
// ─────────────────────────────────────────────────────────
// Logout
// ─────────────────────────────────────────────────────────
export const logout = async () => {
if (!browser) return;
try {
// Capturar tokens antes de limpiar nada
const refreshToken = localStorage.getItem('refresh_token');
const accessToken = localStorage.getItem('access_token');
// Detener el SessionManager
try {
const { destroySessionManager } = await import('./session-manager');
destroySessionManager();
} catch {}
// Limpiar store de compañías
try {
const { companyStore } = await import('./stores/company.svelte');
companyStore.clear();
} catch (error) {
console.error('Error al limpiar store de compañías:', error);
}
} catch {}
// Limpiar estado local
// Limpiar estado en memoria
authStore.reset();
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// Eliminar cookie no-HttpOnly del access_token
deleteCookie('access_token');
deleteCookie('refresh_token');
// La cookie HttpOnly del refresh_token la limpia el servidor
// Si hay instancia de Keycloak, hacer logout de Keycloak
// Logout de Keycloak JS si estaba autenticado con SSO
if (keycloakInstance?.authenticated) {
// Primero notificamos al servidor para limpieza de cookies (SvelteKit)
try {
await fetch('/logout', {
method: 'POST'
});
} catch (e) {
console.error("Error calling server logout:", e);
}
await fetch('/logout', { method: 'POST' });
} catch {}
await keycloakInstance.logout({
redirectUri: window.location.origin + '/login'
});
return;
}
// Llamar al endpoint del servidor para limpiar cookies de SvelteKit
// Usar un formulario para hacer POST y permitir la redirección
// Para login con password: POST al logout route del servidor
const form = document.createElement('form');
form.method = 'POST';
form.action = '/logout';
document.body.appendChild(form);
form.submit();
} catch (error) {
console.error('Error durante logout:', error);
// Asegurar que se redirija al login aunque haya error
} catch (err) {
console.error('[auth] Error durante logout:', err);
window.location.href = '/login';
}
};
/**
* Verifica si el usuario tiene un rol específico
*/
// ─────────────────────────────────────────────────────────
// Token accessors
// ─────────────────────────────────────────────────────────
export const hasRole = (role: string): boolean => {
if (!keycloakInstance?.authenticated) return false;
return keycloakInstance.hasRealmRole(role);
};
/**
* Obtiene el token de acceso actual
*/
/** Obtiene el access token desde memoria (Keycloak JS o authStore) */
export const getToken = (): string | null => {
// Intentar obtener de Keycloak primero
if (keycloakInstance?.token) {
return keycloakInstance.token;
}
// Prioridad 1: Keycloak JS en memoria
if (keycloakInstance?.token) return keycloakInstance.token;
// Si no, intentar de localStorage
if (browser) {
let token = localStorage.getItem('access_token');
// Prioridad 2: authStore en memoria
let token: string | null = null;
const unsub = authStore.subscribe((s) => { token = s.token; });
unsub();
if (token) return token;
// Si no hay token en localStorage, intentar de las cookies
if (!token) {
token = getCookie('access_token');
// Si lo encontramos en cookies, sincronizarlo a localStorage
if (token) {
localStorage.setItem('access_token', token);
}
}
return token;
}
// Prioridad 3: cookie no-HttpOnly (fallback para acceso inicial antes del onMount)
if (browser) return getCookie('access_token');
return null;
};
/**
* Refresca el access token usando el refresh token
* Refresca el access token usando el endpoint server-side seguro.
* El servidor lee el refresh_token de la cookie HttpOnly.
* @returns true si el refresh fue exitoso
*/
export const refreshAccessToken = async (): Promise<boolean> => {
if (!browser) return false;
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
return false;
// Con Keycloak JS activo, usar su mecanismo nativo
if (keycloakInstance?.authenticated) {
try {
const refreshed = await keycloakInstance.updateToken(70);
if (refreshed || keycloakInstance.token) {
authStore.setToken(keycloakInstance.token ?? null);
return true;
}
} catch {
await logout();
return false;
}
}
// Flujo de contraseña: usar el endpoint server-side seguro
try {
const { api } = await import('./api');
const response = await api.auth.refresh(refreshToken);
const resp = await fetch('/api-sveltekit/auth/silent-refresh', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }
});
if (response.error || !response.data) {
console.error('Failed to refresh token:', response.error);
// Si falla el refresh, hacer logout
if (!resp.ok) {
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);
const data = await resp.json() as { access_token?: string };
if (data.access_token) {
authStore.setToken(data.access_token);
setCookie('access_token', data.access_token);
return true;
}
// Actualizar también la cookie
setCookie('access_token', newAccessToken);
return true;
} catch (error) {
await logout();
return false;
} catch (err) {
console.error('[auth] Error en refreshAccessToken:', err);
}
await logout();
return false;
};
/**
* Obtiene el refresh token
*/
/** @deprecated El refresh_token ya no se expone en JS. */
export const getRefreshToken = (): string | null => {
if (!browser) return null;
return localStorage.getItem('refresh_token');
console.warn('[auth] getRefreshToken() está deprecado — el refresh_token no se expone en JS.');
return null;
};
/**
* Obtiene la instancia de Keycloak
*/
export const getKeycloakInstance = (): Keycloak | null => {
return keycloakInstance;
};
export const getKeycloakInstance = (): Keycloak | null => keycloakInstance;

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { CircleAlert } from 'lucide-svelte';
let {
open = $bindable(false),
agentsCount = 0,
clientsCount = 0,
onAccept,
onCancel
}: {
open?: boolean;
agentsCount?: number;
clientsCount?: number;
onAccept?: () => void;
onCancel?: () => void;
} = $props();
const showModal = $derived(agentsCount === 0 || clientsCount === 0);
const message = $derived(
agentsCount === 0 && clientsCount === 0
? 'No hay Agentes aduanales ni Clientes registrados. Debes darlos de alta para poder trabajar en este módulo.'
: agentsCount === 0
? 'No hay Agentes aduanales registrados. Debes darlos de alta para poder trabajar en este módulo.'
: 'No hay Clientes registrados. Debes darlos de alta para poder trabajar en este módulo.'
);
function handleOpenChange(newOpen: boolean) {
open = newOpen;
}
function handleCancel() {
onCancel?.();
}
function handleAccept() {
onAccept?.();
}
</script>
<AlertDialog.Root bind:open onOpenChange={handleOpenChange}>
<AlertDialog.Content>
<AlertDialog.Header>
<div class="flex items-center gap-3">
<CircleAlert class="h-6 w-6 shrink-0 text-amber-500" />
<AlertDialog.Title>Aviso</AlertDialog.Title>
</div>
<AlertDialog.Description class="space-y-3 pt-1">
<p>{message}</p>
<p class="text-sm text-muted-foreground">
Puedes registrarlos en
<a
href="/dashboard/customs_brokers"
class="font-medium text-primary underline underline-offset-4 hover:no-underline"
>
Agentes Aduanales
</a>
y
<a
href="/dashboard/clients_and_providers"
class="font-medium text-primary underline underline-offset-4 hover:no-underline"
>
Clientes y Proveedores
</a>.
</p>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={handleCancel}>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action onclick={handleAccept}>Aceptar</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -143,17 +143,53 @@
{#if scanResults.error_count > 0}
<div
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3"
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3 mb-4"
>
<XCircle class="w-5 h-5 text-destructive mt-0.5 shrink-0" />
<div class="text-sm text-destructive-foreground/90">
<p class="font-semibold mb-1">Se detectaron problemas en el archivo</p>
<p>
Las filas con errores serán omitidas automáticamente. Solo se importarán los
registros válidos.
Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para
importar solo las filas válidas (las erróneas se omitirán).
</p>
</div>
</div>
{#if scanResults.errors && scanResults.errors.length > 0}
<div class="border rounded-lg overflow-hidden shadow-sm">
<div class="bg-muted/50 px-4 py-2 border-b flex justify-between items-center">
<h5 class="text-xs font-bold text-foreground uppercase tracking-wide">
Detalle de errores (para corregir en el CSV)
</h5>
<span
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
>
{scanResults.errors.length} error(es)
</span>
</div>
<div class="max-h-60 overflow-y-auto bg-card relative">
<table class="w-full text-xs text-left">
<thead
class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm"
>
<tr>
<th class="px-4 py-2 w-16">Línea</th>
<th class="px-4 py-2 w-40">Columna</th>
<th class="px-4 py-2">Mensaje</th>
</tr>
</thead>
<tbody class="divide-y">
{#each scanResults.errors as err}
<tr class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-2 font-mono text-muted-foreground">{err.line}</td>
<td class="px-4 py-2 font-mono font-medium text-foreground">{err.col || '-'}</td>
<td class="px-4 py-2 text-destructive">{err.msg || '-'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{:else}
<div class="rounded-md bg-primary/5 border border-primary/10 p-4 flex items-start gap-3">
<CheckCircle2 class="w-5 h-5 text-primary mt-0.5 shrink-0" />

View File

@@ -4,6 +4,7 @@
import { UploadCloud, Lock } from 'lucide-svelte';
import { cn } from '$lib/utils';
import { toast } from 'svelte-sonner';
import { api } from '$lib/api';
let {
items,
@@ -88,23 +89,31 @@
}
}
function handleContextMenu(e: MouseEvent, item: CsvUploadItem) {
async function handleContextMenu(e: MouseEvent, item: CsvUploadItem) {
if (item.disabled) {
e.preventDefault();
return;
}
if (!item.templateUrl) return;
e.preventDefault();
const link = document.createElement('a');
link.href = item.templateUrl;
link.download = item.templateUrl.split('/').pop() || 'plantilla.xls';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
if (!item.templateId) return;
toast.info(`Descargando plantilla para ${item.title}...`);
try {
toast.info(`Descargando plantilla para ${item.title}...`);
const { blob, filename } = await api.getCsvTemplateDownload(item.templateId);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`Plantilla descargada: ${filename}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Error al descargar la plantilla');
}
}
</script>
@@ -123,7 +132,7 @@
ondragover={(e) => handleDragOver(e, item.disabled)}
ondrop={(e) => handleDrop(e, item)}
oncontextmenu={(e) => handleContextMenu(e, item)}
roles="button"
role="button"
tabindex={item.disabled ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled)}
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}

View File

@@ -8,6 +8,20 @@ export type { CustomsBroker };
export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[] {
return [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
const { id } = getId();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm">${id}</code>`
};
});
return renderSnippet(idSnippet, { id: row.original.id });
}
},
{
accessorKey: "broker_key",
header: "Clave",

View File

@@ -275,7 +275,7 @@
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} disabled={loading} />
<Input id="postal_code" bind:value={formData.postal_code} disabled={loading} oninput={(e) => { formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); }} />
</div>
<div class="space-y-2 col-span-2">
<Label for="city">Ciudad</Label>

View File

@@ -318,6 +318,7 @@
placeholder="C.P."
maxlength={15}
disabled={loading}
oninput={(e) => { formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); }}
/>
</div>

View File

@@ -119,7 +119,14 @@
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('No hay una compañía seleccionada');
if (!formData.date) throw new Error('La fecha es requerida');
if (formData.value === null) throw new Error('El valor es requerido');
if (
formData.value === null ||
formData.value === undefined ||
String(formData.value).trim() === ''
)
throw new Error('El tipo de cambio es requerido');
if (Number(formData.value) <= 0)
throw new Error('El tipo de cambio debe ser un valor mayor a 0');
showConfirmation = true;
} catch (e) {

View File

@@ -181,6 +181,20 @@
return;
}
// Validar tipo de cambio
if (
formData.exchange_rate === null ||
formData.exchange_rate === undefined ||
String(formData.exchange_rate).trim() === ''
) {
error = 'El tipo de cambio es requerido (pestaña Financieros)';
return;
}
if (Number(formData.exchange_rate) <= 0) {
error = 'El tipo de cambio debe ser mayor a 0 (pestaña Financieros)';
return;
}
loading = true;
error = null;

View File

@@ -121,7 +121,7 @@
</script>
<Sheet.Root bind:open={helpStore.isOpen}>
<Sheet.Trigger>
<Sheet.Trigger asChild>
<button
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
aria-label="Ayuda"

View File

@@ -7,11 +7,11 @@
// Group local shortcuts
let localShortcuts = $derived($store.shortcuts);
let globalList: HTMLDivElement;
let localList: HTMLDivElement;
let modalRef: HTMLDivElement;
let globalList = $state<HTMLDivElement | undefined>();
let localList = $state<HTMLDivElement | undefined>();
let modalRef = $state<HTMLDivElement | undefined>();
function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement) {
function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement | undefined) {
if (!target) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
@@ -51,6 +51,7 @@
>
<div
class="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-900 text-gray-900 dark:text-gray-100 max-h-[80vh] overflow-y-auto"
role="document"
bind:this={modalRef}
onkeydown={handleFocusTrap}
>
@@ -88,6 +89,8 @@
</h3>
<div
class="space-y-2 max-h-64 overflow-y-auto pr-1"
role="region"
aria-label="Global Navigation shortcuts"
bind:this={globalList}
tabindex="0"
onkeydown={(event) => handleArrowScroll(event, globalList)}
@@ -124,6 +127,8 @@
{:else}
<div
class="space-y-2 max-h-64 overflow-y-auto pr-1"
role="region"
aria-label="Active Actions shortcuts"
bind:this={localList}
tabindex="0"
onkeydown={(event) => handleArrowScroll(event, localList)}

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import {
SESSION_WARNING_EVENT,
SESSION_EXPIRED_EVENT,
SESSION_EXTENDED_EVENT,
getSessionManager
} from '$lib/session-manager';
import type { SessionWarningDetail } from '$lib/session-manager';
// ─── State ────────────────────────────────────────────────────────────────
let open = $state(false);
let remainingSeconds = $state(300);
let countdownId: ReturnType<typeof setInterval> | null = null;
// ─── Helpers ──────────────────────────────────────────────────────────────
function formatTime(secs: number): string {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function clearCountdown() {
if (countdownId !== null) {
clearInterval(countdownId);
countdownId = null;
}
}
function startCountdown() {
clearCountdown();
countdownId = setInterval(() => {
remainingSeconds = Math.max(0, remainingSeconds - 1);
if (remainingSeconds === 0) clearCountdown();
}, 1000);
}
// ─── Event handlers ───────────────────────────────────────────────────────
function onWarning(e: Event) {
const { remainingMs } = (e as CustomEvent<SessionWarningDetail>).detail;
remainingSeconds = Math.floor(remainingMs / 1000);
open = true;
startCountdown();
}
function onExpired() {
open = false;
clearCountdown();
}
function onExtended() {
open = false;
clearCountdown();
}
// ─── User actions ─────────────────────────────────────────────────────────
function continueSession() {
const mgr = getSessionManager();
mgr?.extendSession();
open = false;
clearCountdown();
}
function logoutNow() {
open = false;
clearCountdown();
// Dispara el evento de sesión expirada para que el layout gestione el logout
window.dispatchEvent(
new CustomEvent(SESSION_EXPIRED_EVENT, { detail: { reason: 'manual' } })
);
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
onMount(() => {
if (!browser) return;
window.addEventListener(SESSION_WARNING_EVENT, onWarning);
window.addEventListener(SESSION_EXPIRED_EVENT, onExpired);
window.addEventListener(SESSION_EXTENDED_EVENT, onExtended);
});
onDestroy(() => {
if (!browser) return;
clearCountdown();
window.removeEventListener(SESSION_WARNING_EVENT, onWarning);
window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired);
window.removeEventListener(SESSION_EXTENDED_EVENT, onExtended);
});
</script>
<!--
session-timeout-warning.svelte
Diálogo que avisa al usuario cuando su sesión está a punto de expirar
por inactividad. Se controla completamente a través de eventos DOM.
-->
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm" />
<Dialog.Content
class="fixed left-1/2 top-1/2 z-[9999] w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6 shadow-xl"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-lg font-semibold">
⚠️ Sesión por expirar
</Dialog.Title>
<Dialog.Description class="mt-2 text-sm text-muted-foreground">
Tu sesión cerrará automáticamente por inactividad en
<span class="font-mono font-bold text-foreground">
{formatTime(remainingSeconds)}
</span>.
<br />
¿Deseas continuar trabajando?
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="mt-6 flex gap-3">
<Button variant="outline" class="flex-1" onclick={logoutNow}>
Cerrar sesión
</Button>
<Button class="flex-1" onclick={continueSession}>
Continuar sesión
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -101,7 +101,7 @@
{#if item.icon}
<item.icon />
{:else}
<div class="size-4" />
<div class="size-4"></div>
{/if}
<!-- Ocultamos el texto en modo colapsado para asegurar que solo sea el icono -->
<span class="sr-only">{item.title}</span>
@@ -127,7 +127,7 @@
{#if item.icon}
<item.icon class="size-4 shrink-0" />
{:else}
<div class="size-4 shrink-0" />
<div class="size-4 shrink-0"></div>
{/if}
</div>

View File

@@ -85,7 +85,6 @@
bind:this={searchInputRef}
value={searchQuery}
placeholder={searchPlaceholder}
autofocus={autoFocusSearch}
class="placeholder:text-muted-foreground flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
oninput={(event) => updateQuery((event.currentTarget as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}

View File

@@ -1,366 +1,388 @@
import {
User,
Users,
FileText,
Truck,
Container,
Ship,
Plane,
Package,
Briefcase,
Globe,
CreditCard,
DollarSign,
Calendar,
Hash,
MapPin,
ShieldCheck,
FileDigit,
Scale,
} from 'lucide-svelte';
// --- Interfaces ---
export interface CsvUploadItem {
id: string;
title: string;
icon: any;
group?: string; // For grouping within a tab
modelTarget?: string; // The backend model this maps to
description?: string;
templateUrl?: string; // Path to the template file in static/
disabled?: boolean; // New property to mark items as "Coming Soon"
}
export interface CsvUploadField {
name: string;
label: string;
type: 'text' | 'select' | 'boolean' | 'date' | 'radio';
options?: { label: string; value: string | boolean | number }[];
required?: boolean;
defaultValue?: any;
}
// Map of Tab ID -> Array of Fields
export const tabSettings: Record<string, CsvUploadField[]> = {
catalogos: [
{
name: 'mode',
label: 'Modo de Carga',
type: 'radio',
options: [
{ label: 'Actualizar', value: 'update' },
{ label: 'Reemplazar', value: 'replace' }
],
defaultValue: 'update'
}
],
transportes: [
{
name: 'mode',
label: 'Modo de Carga',
type: 'radio',
options: [
{ label: 'Actualizar', value: 'update' },
{ label: 'Reemplazar', value: 'replace' }
],
defaultValue: 'update'
}
],
importacion: [
{
name: 'autonumber_remesas',
label: 'Autonumerar Remesas',
type: 'boolean',
defaultValue: false
},
{
name: 'recalculate_dates',
label: 'Recalcular Fechas',
type: 'boolean',
defaultValue: false
},
{
name: 'dateFormat',
label: 'Formato de Fecha',
type: 'select',
options: [
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
],
defaultValue: 'dd/mm/yyyy'
}
],
exportacion: [
{
name: 'invoice_type',
label: 'Tipo de Factura',
type: 'select',
options: [
{ label: 'AFIJO', value: 'AFIJO' },
{ label: 'NORMAL', value: 'NORMAL' },
],
defaultValue: 'AFIJO',
},
{
name: 'is_regime_change',
label: 'Es Cambio de Régimen',
type: 'boolean',
defaultValue: false,
},
{
name: 'dateFormat',
label: 'Formato de Fecha',
type: 'select',
options: [
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
],
defaultValue: 'dd/mm/yyyy'
}
]
};
// --- DATA DEFINITIONS (Items only, no config) ---
export const catalogosConfig: CsvUploadItem[] = [
{
id: 'customs_brokers',
title: 'Agentes Aduanales',
icon: User,
modelTarget: 'CustomsBroker',
templateUrl: '/csv/EstructuraCatAgenteAduanal.xls'
},
{
id: 'clients_providers',
title: 'Clientes y Proveedores',
icon: Users,
modelTarget: 'ClientProvider',
templateUrl: '/csv/EstructuraCatClienteProv.xls'
},
{
id: 'exchange_rates',
title: 'Tipo de Cambios',
icon: DollarSign,
modelTarget: 'ExchangeRate',
templateUrl: '/csv/EstructuraCatTiposCambio.xls'
},
{
id: 'american_fractions',
title: 'Fracc. Ame.',
icon: Globe,
modelTarget: 'AmericanFraction',
templateUrl: '/csv/EstructuraCatFraccAme.xls'
},
{
id: 'material_classes',
title: 'Clases de Materiales',
icon: Package,
modelTarget: 'MaterialClass',
templateUrl: '/csv/EstructuraCatClasesAF.xls'
},
{
id: 'items',
title: 'Partidas (Permisos)',
icon: FileText,
group: 'Permisos',
modelTarget: 'ItemPermission',
templateUrl: '/csv/EstructuraCatPartesAF.xls'
},
{
id: 'headers',
title: 'Encabezados (Permisos)',
icon: FileText,
group: 'Permisos',
modelTarget: 'HeaderPermission',
disabled: true,
},
{
id: 'historical_fractions',
title: 'Fracciones Históricas',
icon: Calendar,
modelTarget: 'HistoricalFraction',
disabled: true,
},
{
id: 'pedimentos',
title: 'Pedimentos',
icon: FileDigit,
modelTarget: 'Pedimento',
templateUrl: '/csv/EstructuraCatPedimentos.xls'
},
];
export const transportesConfig: CsvUploadItem[] = [
{
id: 'transports',
title: 'Transportes',
icon: Truck,
modelTarget: 'Transport',
templateUrl: '/csv/EstructuraCatTransportes.xls'
},
{
id: 'drivers',
title: 'Conductores',
icon: User,
modelTarget: 'Driver',
templateUrl: '/csv/EstructuraCatConductor.xls'
},
{
id: 'trailers',
title: 'Trailers y Cajas',
icon: Container,
modelTarget: 'Trailer',
templateUrl: '/csv/EstructuraCatTrailers.xls'
},
];
export const importacionConfig: CsvUploadItem[] = [
// Impo Temp
{
id: 'imp_temp_header',
title: 'Encabezado',
icon: FileText,
group: 'Impo. Temp.',
modelTarget: 'invoice_header',
templateUrl: '/csv/EstructuraEncFacImpoTemp.xls'
},
{
id: 'imp_temp_details',
title: 'Partidas',
icon: Package,
group: 'Impo. Temp.',
modelTarget: 'invoice_details',
templateUrl: '/csv/EstructuraParFacImpoTempAF.xls'
},
{
id: 'imp_temp_series',
title: 'Series',
icon: Hash,
group: 'Impo. Temp.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
// Impo Def
{
id: 'imp_def_header',
title: 'Encabezado',
icon: FileText,
group: 'Impo. Def.',
modelTarget: 'InvoiceHeader',
templateUrl: '/csv/EstructuraEncFacImpoDef.xls'
},
{
id: 'imp_def_details',
title: 'Partidas',
icon: Package,
group: 'Impo. Def.',
modelTarget: 'InvoiceSalesDetails',
templateUrl: '/csv/EstructuraParFacImpoDefAF.xls'
},
{
id: 'imp_def_series',
title: 'Series',
icon: Hash,
group: 'Impo. Def.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
// Compras Mex
{
id: 'comp_mex_header',
title: 'Encabezado',
icon: FileText,
group: 'Compras Mex.',
modelTarget: 'InvoiceHeader',
disabled: true,
},
{
id: 'comp_mex_details',
title: 'Partidas',
icon: Package,
group: 'Compras Mex.',
modelTarget: 'InvoiceSalesDetails',
disabled: true,
},
{
id: 'comp_mex_series',
title: 'Series',
icon: Hash,
group: 'Compras Mex.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
];
export const exportacionConfig: CsvUploadItem[] = [
// Expo Def / Cam. Reg.
{
id: 'exp_def_header',
title: 'Encabezado',
icon: FileText,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceHeader',
templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls'
},
{
id: 'exp_def_details',
title: 'Partidas',
icon: Package,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceSalesDetails',
templateUrl: '/csv/EstructuraParExpoCamReg.xls'
},
{
id: 'exp_def_series',
title: 'Series',
icon: Hash,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
{
id: 'exp_def_nodes',
title: 'NODES',
icon: Briefcase,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'Nodes',
disabled: true,
},
// Expo Rep
{
id: 'exp_rep_header',
title: 'Encabezado',
icon: FileText,
group: 'Expo. Rep.',
modelTarget: 'InvoiceHeader',
disabled: true,
},
{
id: 'exp_rep_details',
title: 'Partidas',
icon: Package,
group: 'Expo. Rep.',
modelTarget: 'InvoiceSalesDetails',
disabled: true,
},
{
id: 'exp_rep_series',
title: 'Series',
icon: Hash,
group: 'Expo. Rep.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
// Manifiesto
{
id: 'manifest_header',
title: 'Encabezado',
icon: FileText,
group: 'Manifiesto',
modelTarget: 'Manifest',
disabled: true,
},
];
import {
User,
Users,
FileText,
Truck,
Container,
Ship,
Plane,
Package,
Briefcase,
Globe,
CreditCard,
DollarSign,
Calendar,
Hash,
MapPin,
ShieldCheck,
FileDigit,
Scale,
} from 'lucide-svelte';
// --- Interfaces ---
export interface CsvUploadItem {
id: string;
title: string;
icon: any;
group?: string; // For grouping within a tab
modelTarget?: string; // The backend model this maps to
description?: string;
/** Backend template id for CSV download (e.g. customs_brokers, part_numbers). No physical file. */
templateId?: string;
disabled?: boolean; // New property to mark items as "Coming Soon"
}
export interface CsvUploadField {
name: string;
label: string;
type: 'text' | 'select' | 'boolean' | 'date' | 'radio';
options?: { label: string; value: string | boolean | number }[];
required?: boolean;
defaultValue?: any;
}
// Map of Tab ID -> Array of Fields
export const tabSettings: Record<string, CsvUploadField[]> = {
catalogos: [
{
name: 'mode',
label: 'Modo de Carga',
type: 'radio',
options: [
{ label: 'Actualizar', value: 'update' },
{ label: 'Reemplazar', value: 'replace' }
],
defaultValue: 'update'
}
],
transportes: [
{
name: 'mode',
label: 'Modo de Carga',
type: 'radio',
options: [
{ label: 'Actualizar', value: 'update' },
{ label: 'Reemplazar', value: 'replace' }
],
defaultValue: 'update'
}
],
importacion: [
{
name: 'autonumber_remesas',
label: 'Autonumerar Remesas',
type: 'boolean',
defaultValue: false
},
{
name: 'recalculate_dates',
label: 'Recalcular Fechas',
type: 'boolean',
defaultValue: false
},
{
name: 'dateFormat',
label: 'Formato de Fecha',
type: 'select',
options: [
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
],
defaultValue: 'dd/mm/yyyy'
}
],
exportacion: [
{
name: 'invoice_type',
label: 'Tipo de Factura',
type: 'select',
options: [
{ label: 'AFIJO', value: 'AFIJO' },
{ label: 'NORMAL', value: 'NORMAL' },
],
defaultValue: 'AFIJO',
},
{
name: 'is_regime_change',
label: 'Es Cambio de Régimen',
type: 'boolean',
defaultValue: false,
},
{
name: 'dateFormat',
label: 'Formato de Fecha',
type: 'select',
options: [
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
],
defaultValue: 'dd/mm/yyyy'
}
]
};
// --- DATA DEFINITIONS (Items only, no config) ---
export const catalogosConfig: CsvUploadItem[] = [
{
id: 'customs_brokers',
title: 'Agentes Aduanales',
icon: User,
modelTarget: 'CustomsBroker',
templateId: 'customs_brokers'
},
{
id: 'clients_providers',
title: 'Clientes y Proveedores',
icon: Users,
modelTarget: 'ClientProvider',
templateId: 'clients_providers'
},
{
id: 'exchange_rates',
title: 'Tipo de Cambios',
icon: DollarSign,
modelTarget: 'ExchangeRate',
templateId: 'exchange_rates'
},
{
id: 'american_fractions',
title: 'Fracc. Ame.',
icon: Globe,
modelTarget: 'AmericanFraction',
templateId: 'american_fractions'
},
{
id: 'material_classes',
title: 'Clases de Materiales',
icon: Package,
modelTarget: 'MaterialClass',
templateId: 'material_classes'
},
{
id: 'part_numbers',
title: 'Números de parte',
icon: Hash,
modelTarget: 'Part',
templateId: 'part_numbers',
},
{
id: 'boms',
title: 'BOMs',
icon: Briefcase,
modelTarget: 'Bom',
templateId: 'boms',
},
{
id: 'items',
title: 'Partidas (Permisos)',
icon: FileText,
group: 'Permisos',
modelTarget: 'ItemPermission',
templateId: 'part_numbers'
},
{
id: 'headers',
title: 'Encabezados (Permisos)',
icon: FileText,
group: 'Permisos',
modelTarget: 'HeaderPermission',
disabled: true,
},
{
id: 'historical_fractions',
title: 'Fracciones Históricas',
icon: Calendar,
modelTarget: 'HistoricalFraction',
disabled: true,
},
{
id: 'pedimentos',
title: 'Pedimentos',
icon: FileDigit,
modelTarget: 'Pedimento',
templateId: 'pedimentos'
},
];
export const transportesConfig: CsvUploadItem[] = [
{
id: 'transporters',
title: 'Transportistas',
icon: Ship,
modelTarget: 'Transporter',
// No templateId: backend transporters/imports not implemented yet
},
{
id: 'transports',
title: 'Transportes',
icon: Truck,
modelTarget: 'Transport',
templateId: 'transports'
},
{
id: 'drivers',
title: 'Conductores',
icon: User,
modelTarget: 'Driver',
templateId: 'drivers'
},
{
id: 'trailers',
title: 'Trailers y Cajas',
icon: Container,
modelTarget: 'Trailer',
templateId: 'trailers'
},
];
export const importacionConfig: CsvUploadItem[] = [
// Impo Temp
{
id: 'imp_temp_header',
title: 'Encabezado',
icon: FileText,
group: 'Impo. Temp.',
modelTarget: 'invoice_header',
templateId: 'imp_temp_header'
},
{
id: 'imp_temp_details',
title: 'Partidas',
icon: Package,
group: 'Impo. Temp.',
modelTarget: 'invoice_details',
templateId: 'imp_temp_details'
},
{
id: 'imp_temp_series',
title: 'Series',
icon: Hash,
group: 'Impo. Temp.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
// Impo Def
{
id: 'imp_def_header',
title: 'Encabezado',
icon: FileText,
group: 'Impo. Def.',
modelTarget: 'invoice_header',
templateId: 'imp_def_header'
},
{
id: 'imp_def_details',
title: 'Partidas',
icon: Package,
group: 'Impo. Def.',
modelTarget: 'invoice_details',
templateId: 'imp_def_details'
},
{
id: 'imp_def_series',
title: 'Series',
icon: Hash,
group: 'Impo. Def.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
// Compras Mex
{
id: 'comp_mex_header',
title: 'Encabezado',
icon: FileText,
group: 'Compras Mex.',
modelTarget: 'invoice_header',
disabled: true,
},
{
id: 'comp_mex_details',
title: 'Partidas',
icon: Package,
group: 'Compras Mex.',
modelTarget: 'invoice_details',
disabled: true,
},
{
id: 'comp_mex_series',
title: 'Series',
icon: Hash,
group: 'Compras Mex.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
];
export const exportacionConfig: CsvUploadItem[] = [
// Expo Def / Cam. Reg.
{
id: 'exp_def_header',
title: 'Encabezado',
icon: FileText,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'invoice_header',
templateId: 'exp_def_header'
},
{
id: 'exp_def_details',
title: 'Partidas',
icon: Package,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'invoice_details',
templateId: 'exp_def_details'
},
{
id: 'exp_def_series',
title: 'Series',
icon: Hash,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
{
id: 'exp_def_nodes',
title: 'NODES',
icon: Briefcase,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'Nodes',
disabled: true,
},
// Expo Rep
{
id: 'exp_rep_header',
title: 'Encabezado',
icon: FileText,
group: 'Expo. Rep.',
modelTarget: 'InvoiceHeader',
disabled: true,
},
{
id: 'exp_rep_details',
title: 'Partidas',
icon: Package,
group: 'Expo. Rep.',
modelTarget: 'InvoiceSalesDetails',
disabled: true,
},
{
id: 'exp_rep_series',
title: 'Series',
icon: Hash,
group: 'Expo. Rep.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
// Manifiesto
{
id: 'manifest_header',
title: 'Encabezado',
icon: FileText,
group: 'Manifiesto',
modelTarget: 'Manifest',
disabled: true,
},
];

View File

@@ -41,6 +41,10 @@ export function getAuthTokens(cookies: Cookies) {
/**
* Establece los tokens de autenticación en las cookies
*
* Política de seguridad:
* - access_token → NO HttpOnly (el cliente necesita incluirlo en el header Authorization)
* - refresh_token → HttpOnly=true (JS nunca lo lee; el servidor lo maneja via /api-sveltekit/auth/silent-refresh)
*/
export function setAuthTokens(
cookies: Cookies,
@@ -49,7 +53,7 @@ export function setAuthTokens(
) {
cookies.set('access_token', accessToken, {
path: '/',
httpOnly: false,
httpOnly: false, // El cliente JS necesita leerlo para el header Bearer
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7 // 7 días
@@ -58,7 +62,7 @@ export function setAuthTokens(
if (refreshToken) {
cookies.set('refresh_token', refreshToken, {
path: '/',
httpOnly: false,
httpOnly: true, // *** HttpOnly: JS nunca lee el refresh_token ***
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 30 // 30 días

View File

@@ -0,0 +1,505 @@
/**
* Gestor de sesión SSO para Keycloak
*
* Responsabilidades:
* - Refresh silencioso del access token SOLO cuando el usuario está activo.
* - Detección de actividad del usuario (evita polling innecesario cuando está idle).
* - Idle timeout: si el usuario está inactivo, no refrescar → el token y la sesión
* expiran en Keycloak de forma natural → siguiente petición 401 → logout.
* - Logout automático cuando el refresh falla (sesión SSO terminada por Keycloak,
* admin forzado, max session alcanzado, etc.).
* - Verificación de sesión SSO usando el iframe silencioso de Keycloak JS.
*
* Flujo de tokens:
* - Access token: en memoria (authStore) + cookie no-HttpOnly (password login)
* o en instancia Keycloak JS (SSO flow)
* - Refresh token: cookie HttpOnly únicamente (el JS nunca lo toca)
* - El refresh se hace server-side via /api-sveltekit/auth/silent-refresh
*/
import { browser } from '$app/environment';
import type Keycloak from 'keycloak-js';
// ─────────────────────────────────────────────────────────
// Eventos DOM personalizados
// ─────────────────────────────────────────────────────────
/** Se emite cuando la sesión está próxima a expirar por inactividad */
export const SESSION_WARNING_EVENT = 'session:warning';
/** Se emite cuando la sesión ha expirado (idle, max-session o refresh fallido) */
export const SESSION_EXPIRED_EVENT = 'session:expired';
/** Se emite cuando el usuario extiende la sesión desde el diálogo de advertencia */
export const SESSION_EXTENDED_EVENT = 'session:extended';
/** Se emite después de un refresh silencioso exitoso */
export const SESSION_TOKEN_REFRESHED_EVENT = 'session:token-refreshed';
// ─────────────────────────────────────────────────────────
// Tipos
// ─────────────────────────────────────────────────────────
export type SessionExpiredReason = 'idle' | 'refresh_failed' | 'keycloak_session_ended' | 'error' | 'manual';
export interface SessionExpiredDetail {
reason: SessionExpiredReason;
}
export interface SessionWarningDetail {
remainingMs: number;
}
export interface SessionManagerOptions {
/**
* Segundos antes de la expiración del token para intentar el refresh.
* Default: 60
*/
refreshBeforeExpirySeconds?: number;
/**
* Tiempo de inactividad (ms) después del cual NO se refresca el token,
* permitiendo que la sesión de Keycloak expire de forma natural.
* Default: 30 minutos (1_800_000 ms)
*/
idleTimeoutMs?: number;
/**
* Milisegundos antes del idle timeout para mostrar el diálogo de advertencia.
* Default: 5 minutos (300_000 ms)
*/
warningBeforeIdleMs?: number;
/**
* Intervalo (ms) para verificar silenciosamente la sesión SSO de Keycloak.
* Solo se usa cuando hay una instancia de Keycloak JS autenticada.
* Default: 5 minutos (300_000 ms). 0 para deshabilitar.
*/
ssoCheckIntervalMs?: number;
/** Función para obtener la instancia de Keycloak JS (si usa el SSO flow) */
getKeycloakInstance?: () => Keycloak | null;
/** Callback cuando el token se refresca exitosamente */
onTokenRefreshed?: (newToken: string) => void;
/** Callback cuando la sesión expira */
onSessionExpired?: (reason: SessionExpiredReason) => void;
}
// ─────────────────────────────────────────────────────────
// SessionManager
// ─────────────────────────────────────────────────────────
export class SessionManager {
private opts: Required<SessionManagerOptions>;
// Timers
private refreshTimerId: ReturnType<typeof setTimeout> | null = null;
private idleTimerId: ReturnType<typeof setTimeout> | null = null;
private warningTimerId: ReturnType<typeof setTimeout> | null = null;
private ssoCheckIntervalId: ReturnType<typeof setInterval> | null = null;
// State
private currentToken: string | null = null;
private lastActivityAt = Date.now();
private warningShown = false;
private isRefreshing = false;
private destroyed = false;
// Activity listener cleanups
private removeListeners: Array<() => void> = [];
constructor(options: SessionManagerOptions = {}) {
this.opts = {
refreshBeforeExpirySeconds: options.refreshBeforeExpirySeconds ?? 60,
idleTimeoutMs: options.idleTimeoutMs ?? 30 * 60 * 1000,
warningBeforeIdleMs: options.warningBeforeIdleMs ?? 5 * 60 * 1000,
ssoCheckIntervalMs: options.ssoCheckIntervalMs ?? 5 * 60 * 1000,
getKeycloakInstance: options.getKeycloakInstance ?? (() => null),
onTokenRefreshed: options.onTokenRefreshed ?? (() => {}),
onSessionExpired: options.onSessionExpired ?? (() => {})
};
}
// ─────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────
/**
* Inicia el gestor de sesión con el token actual.
* Debe llamarse una vez tras la autenticación exitosa.
*/
start(initialToken: string): void {
if (!browser || this.destroyed) return;
this.currentToken = initialToken;
this.lastActivityAt = Date.now();
this.warningShown = false;
this.setupActivityListeners();
this.scheduleRefresh(initialToken);
this.scheduleIdleTimers();
this.startSsoCheckInterval();
}
/**
* Actualiza el token en memoria (llamar después de un refresh exitoso externo).
*/
updateToken(newToken: string): void {
if (this.destroyed) return;
this.currentToken = newToken;
this.warningShown = false;
this.cancelRefreshTimer();
this.scheduleRefresh(newToken);
this.resetIdleTimers();
}
/**
* El usuario hizo clic en "Continuar sesión" en el diálogo de advertencia.
* Fuerza un refresh inmediato y reinicia los timers de inactividad.
*/
extendSession(): void {
if (this.destroyed) return;
this.recordActivity();
if (this.currentToken) {
void this.doRefresh('extend');
}
}
/** Destruye el gestor y limpia todos los recursos. */
destroy(): void {
this.destroyed = true;
this.cancelRefreshTimer();
this.cancelIdleTimers();
this.stopSsoCheckInterval();
this.teardownActivityListeners();
}
// ─────────────────────────────────────────────────────
// Activity tracking
// ─────────────────────────────────────────────────────
private setupActivityListeners(): void {
const events: (keyof WindowEventMap)[] = [
'mousedown',
'mousemove',
'keydown',
'scroll',
'touchstart',
'click',
'pointerdown'
];
// Limitar actualizaciones de actividad a máximo una por segundo
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
if (debounceTimer) return;
debounceTimer = setTimeout(() => {
debounceTimer = null;
this.recordActivity();
}, 1000);
};
events.forEach((event) => {
window.addEventListener(event, handler, { passive: true });
this.removeListeners.push(() => window.removeEventListener(event, handler));
});
// Al volver a la pestaña, registrar actividad y verificar si hace falta
// un refresh inmediato (el tiempo puede haber pasado con la pestaña en segundo plano)
const visibilityHandler = () => {
if (document.visibilityState === 'visible') {
this.recordActivity();
void this.refreshIfExpiringSoon();
}
};
document.addEventListener('visibilitychange', visibilityHandler);
this.removeListeners.push(() =>
document.removeEventListener('visibilitychange', visibilityHandler)
);
}
private teardownActivityListeners(): void {
this.removeListeners.forEach((fn) => fn());
this.removeListeners = [];
}
private recordActivity(): void {
const wasIdle = this.isIdle();
this.lastActivityAt = Date.now();
if (this.warningShown) {
// El usuario volvió activo → descartar advertencia
this.warningShown = false;
this.resetIdleTimers();
window.dispatchEvent(new CustomEvent(SESSION_EXTENDED_EVENT));
} else if (wasIdle) {
// Volvemos de idle → reiniciar timers
this.resetIdleTimers();
void this.refreshIfExpiringSoon();
}
}
private isIdle(): boolean {
return Date.now() - this.lastActivityAt > this.opts.idleTimeoutMs;
}
// ─────────────────────────────────────────────────────
// Token refresh scheduling
// ─────────────────────────────────────────────────────
private parseExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
} catch {
return null;
}
}
private scheduleRefresh(token: string): void {
const expiry = this.parseExpiry(token);
if (!expiry) return;
const msUntilRefresh = expiry - Date.now() - this.opts.refreshBeforeExpirySeconds * 1000;
if (msUntilRefresh <= 0) {
void this.doRefresh('scheduled');
return;
}
this.refreshTimerId = setTimeout(() => {
if (!this.destroyed) void this.doRefresh('scheduled');
}, msUntilRefresh);
}
private cancelRefreshTimer(): void {
if (this.refreshTimerId !== null) {
clearTimeout(this.refreshTimerId);
this.refreshTimerId = null;
}
}
/** Refresca el token si le quedan menos de `refreshBeforeExpirySeconds` */
private async refreshIfExpiringSoon(): Promise<void> {
if (!this.currentToken) return;
const expiry = this.parseExpiry(this.currentToken);
if (!expiry) return;
if (expiry - Date.now() < this.opts.refreshBeforeExpirySeconds * 1000) {
await this.doRefresh('on-demand');
}
}
// ─────────────────────────────────────────────────────
// Idle session timers
// ─────────────────────────────────────────────────────
private scheduleIdleTimers(): void {
this.cancelIdleTimers();
const now = Date.now();
const idleAt = this.lastActivityAt + this.opts.idleTimeoutMs;
const warnAt = idleAt - this.opts.warningBeforeIdleMs;
const msUntilWarn = warnAt - now;
const msUntilIdle = idleAt - now;
if (msUntilWarn > 0) {
this.warningTimerId = setTimeout(() => {
if (!this.destroyed && !this.warningShown && this.isIdle() === false) {
this.showWarning(this.opts.warningBeforeIdleMs);
}
}, msUntilWarn);
}
if (msUntilIdle > 0) {
this.idleTimerId = setTimeout(() => {
if (!this.destroyed && this.isIdle()) {
this.handleIdleExpiry();
}
}, msUntilIdle);
}
}
private cancelIdleTimers(): void {
if (this.warningTimerId !== null) {
clearTimeout(this.warningTimerId);
this.warningTimerId = null;
}
if (this.idleTimerId !== null) {
clearTimeout(this.idleTimerId);
this.idleTimerId = null;
}
}
private resetIdleTimers(): void {
this.scheduleIdleTimers();
}
private showWarning(remainingMs: number): void {
this.warningShown = true;
const detail: SessionWarningDetail = { remainingMs };
window.dispatchEvent(new CustomEvent(SESSION_WARNING_EVENT, { detail }));
}
private handleIdleExpiry(): void {
const detail: SessionExpiredDetail = { reason: 'idle' };
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail }));
this.opts.onSessionExpired('idle');
}
// ─────────────────────────────────────────────────────
// Periodic Keycloak SSO session check (iframe)
// ─────────────────────────────────────────────────────
private startSsoCheckInterval(): void {
if (this.opts.ssoCheckIntervalMs <= 0) return;
this.ssoCheckIntervalId = setInterval(() => {
if (!this.destroyed) void this.checkKeycloakSsoSession();
}, this.opts.ssoCheckIntervalMs);
}
private stopSsoCheckInterval(): void {
if (this.ssoCheckIntervalId !== null) {
clearInterval(this.ssoCheckIntervalId);
this.ssoCheckIntervalId = null;
}
}
/**
* Comprueba silenciosamente si la sesión SSO de Keycloak sigue activa.
* Si el check falla (sesión terminada remotamente) → logout.
*/
private async checkKeycloakSsoSession(): Promise<void> {
const kc = this.opts.getKeycloakInstance();
if (!kc?.authenticated) return; // Solo aplica al flow SSO con Keycloak JS
try {
// updateToken(0) fuerza a Keycloak JS a intentar refrescar via SSO
// Si la sesión SSO de Keycloak ha sido terminada, lanza un error
await kc.updateToken(0);
} catch {
console.warn('[SessionManager] Keycloak SSO session ended remotely');
const detail: SessionExpiredDetail = { reason: 'keycloak_session_ended' };
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail }));
this.opts.onSessionExpired('keycloak_session_ended');
}
}
// ─────────────────────────────────────────────────────
// Token refresh execution
// ─────────────────────────────────────────────────────
private async doRefresh(reason: string): Promise<void> {
if (this.destroyed || this.isRefreshing) return;
// No refrescar automáticamente si el usuario está idle
// (excepto si es un refresh forzado por "extender sesión")
if (reason === 'scheduled' && this.isIdle()) {
console.info('[SessionManager] Omitiendo refresh — usuario inactivo');
return;
}
this.isRefreshing = true;
try {
const kc = this.opts.getKeycloakInstance();
let newToken: string | null = null;
if (kc?.authenticated) {
// ── Keycloak JS flow ──────────────────────────────────────────
// updateToken intenta un silent refresh via iframe con la cookie
// de sesión SSO de Keycloak.
// Si el SSO session ha expirado, esto lanzará un error.
const minValidity = this.opts.refreshBeforeExpirySeconds + 10;
await kc.updateToken(minValidity);
newToken = kc.token ?? null;
} else {
// ── Password login flow ───────────────────────────────────────
// Usar el endpoint server-side de SvelteKit que lee el refresh_token
// desde la cookie HttpOnly (el JS nunca ve el refresh_token).
newToken = await this.silentRefreshViaCookie();
}
if (newToken) {
this.currentToken = newToken;
this.cancelRefreshTimer();
this.scheduleRefresh(newToken);
this.opts.onTokenRefreshed(newToken);
window.dispatchEvent(
new CustomEvent(SESSION_TOKEN_REFRESHED_EVENT, { detail: { token: newToken } })
);
} else {
this.handleRefreshFailure();
}
} catch (err) {
console.error('[SessionManager] Error durante refresh:', err);
this.handleRefreshFailure();
} finally {
this.isRefreshing = false;
}
}
private handleRefreshFailure(): void {
console.warn('[SessionManager] Refresh fallido — la sesión SSO probablemente expiró');
const detail: SessionExpiredDetail = { reason: 'refresh_failed' };
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail }));
this.opts.onSessionExpired('refresh_failed');
}
/**
* Llama al endpoint server-side de SvelteKit para realizar el refresh
* usando la cookie HttpOnly del refresh_token.
*
* El servidor lee la cookie, llama a Keycloak, obtiene los nuevos tokens,
* actualiza las cookies HttpOnly y devuelve el nuevo access_token al cliente.
* El refresh_token NUNCA toca el código JavaScript del cliente.
*/
private async silentRefreshViaCookie(): Promise<string | null> {
try {
const resp = await fetch('/api-sveltekit/auth/silent-refresh', {
method: 'POST',
credentials: 'include', // Envía todas las cookies HttpOnly
headers: { 'Content-Type': 'application/json' }
});
if (!resp.ok) return null;
const data = await resp.json();
return (data as { access_token?: string }).access_token ?? null;
} catch (err) {
console.error('[SessionManager] Error en silentRefreshViaCookie:', err);
return null;
}
}
}
// ─────────────────────────────────────────────────────────
// Singleton helpers
// ─────────────────────────────────────────────────────────
let _instance: SessionManager | null = null;
/** Obtiene la instancia singleton del SessionManager */
export function getSessionManager(): SessionManager | null {
return _instance;
}
/**
* Crea (o recrea) el SessionManager singleton.
* Destruye la instancia anterior si existe.
*/
export function createSessionManager(options?: SessionManagerOptions): SessionManager {
if (_instance) {
_instance.destroy();
}
_instance = new SessionManager(options);
return _instance;
}
/** Destruye el SessionManager singleton y limpia todos los recursos */
export function destroySessionManager(): void {
if (_instance) {
_instance.destroy();
_instance = null;
}
}