Merge branch 'development' into fix/modulo-pedimento
This commit is contained in:
@@ -41,94 +41,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
128
frontend/src/lib/components/session-timeout-warning.svelte
Normal file
128
frontend/src/lib/components/session-timeout-warning.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
505
frontend/src/lib/session-manager.ts
Normal file
505
frontend/src/lib/session-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Endpoint server-side para el refresh silencioso del access token.
|
||||
*
|
||||
* Flujo de seguridad:
|
||||
* 1. El cliente llama a POST /api-sveltekit/auth/silent-refresh con credentials:'include'
|
||||
* (las cookies HttpOnly se envían automáticamente, sin que JS las lea).
|
||||
* 2. Este servidor lee el refresh_token de la cookie HttpOnly.
|
||||
* 3. Llama al backend FastAPI /v1/auth/refresh con el refresh_token.
|
||||
* 4. Si es exitoso, actualiza las cookies HttpOnly con los nuevos tokens.
|
||||
* 5. Retorna solo el access_token al cliente (el refresh_token permanece en HttpOnly).
|
||||
*
|
||||
* De este modo el refresh_token NUNCA toca el código JavaScript del cliente.
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { getServerApiUrl, setAuthTokens } from '$lib/server/api';
|
||||
|
||||
export const POST = async ({ cookies, fetch }: RequestEvent) => {
|
||||
const refreshToken = cookies.get('refresh_token');
|
||||
|
||||
if (!refreshToken) {
|
||||
return json({ error: 'No refresh token available' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
|
||||
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// El refresh token expiró o fue invalidado por Keycloak (sesión terminada).
|
||||
// Limpiar las cookies para que el servidor redirigir al login en la siguiente carga.
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
cookies.delete('access_token', { path: '/' });
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
|
||||
const status = response.status === 401 ? 401 : 400;
|
||||
return json({ error: 'Refresh token expired or invalid' }, { status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
// Actualizar las cookies HttpOnly con los nuevos tokens
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
|
||||
// Devolver solo el access_token al cliente
|
||||
return json({ access_token: data.access_token });
|
||||
} catch (error) {
|
||||
console.error('[silent-refresh] Error inesperado:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -56,13 +56,14 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
// Establecer las cookies en el servidor (esto es lo importante)
|
||||
// Las cookies deben ser HttpOnly y Secure en producción
|
||||
// Establecer las cookies en el servidor
|
||||
// access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization)
|
||||
// refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona)
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
cookies.set('access_token', tokens.access_token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
httpOnly: false, // El cliente necesita leerlo para Bearer
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7 // 7 días
|
||||
@@ -71,7 +72,7 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||
if (tokens.refresh_token) {
|
||||
cookies.set('refresh_token', tokens.refresh_token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
httpOnly: true, // *** HttpOnly: nunca expuesto a JS ***
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 días
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { setContext, onMount } from 'svelte';
|
||||
import { setContext, onMount, onDestroy } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { LayoutData } from './$types';
|
||||
import AppSidebar from '$lib/components/sidebar/app-sidebar.svelte';
|
||||
@@ -8,29 +8,78 @@
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import ExchangeRateGuard from '$lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte';
|
||||
import SessionTimeoutWarning from '$lib/components/session-timeout-warning.svelte';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
createSessionManager,
|
||||
destroySessionManager,
|
||||
SESSION_EXPIRED_EVENT
|
||||
} from '$lib/session-manager';
|
||||
import type { SessionExpiredDetail } from '$lib/session-manager';
|
||||
import { authStore } from '$lib/auth';
|
||||
import { logout, getKeycloakInstance } from '$lib/auth';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
||||
|
||||
// Hacer disponible el usuario en el contexto para los componentes hijos
|
||||
setContext('user', data.user);
|
||||
|
||||
// Inicializar el store con las compañías pre-cargadas desde el servidor
|
||||
// ── Manejar expiración de sesión ────────────────────────────────────────
|
||||
function handleSessionExpired(e: Event) {
|
||||
const { reason } = (e as CustomEvent<SessionExpiredDetail>).detail;
|
||||
console.info(`[Dashboard] Sesión expirada (motivo: ${reason}) — cerrando sesión`);
|
||||
void logout();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// ── Inicializar el token en el authStore desde los datos del servidor ──
|
||||
// El servidor valida la cookie y pasa el access_token a través de data.user.token.
|
||||
// Lo almacenamos en memoria (authStore) sin tocar localStorage.
|
||||
if (data.user?.token) {
|
||||
authStore.setToken(data.user.token);
|
||||
authStore.setAuthenticated(true);
|
||||
}
|
||||
|
||||
// ── Inicializar el SessionManager ─────────────────────────────────────
|
||||
if (data.user?.token) {
|
||||
const mgr = createSessionManager({
|
||||
refreshBeforeExpirySeconds: 60, // Refrescar 60s antes de que expire
|
||||
idleTimeoutMs: 30 * 60 * 1000, // Idle timeout: 30 minutos
|
||||
warningBeforeIdleMs: 5 * 60 * 1000, // Advertencia: 5 min antes del idle
|
||||
ssoCheckIntervalMs: 5 * 60 * 1000, // Verificar sesión SSO cada 5 min
|
||||
getKeycloakInstance,
|
||||
onTokenRefreshed: (newToken) => {
|
||||
authStore.setToken(newToken);
|
||||
},
|
||||
onSessionExpired: (reason) => {
|
||||
void logout();
|
||||
}
|
||||
});
|
||||
|
||||
mgr.start(data.user.token);
|
||||
}
|
||||
|
||||
// ── Escuchar evento global de expiración de sesión ────────────────────
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
|
||||
|
||||
// ── Inicializar compañías ─────────────────────────────────────────────
|
||||
if (data.companies) {
|
||||
companyStore.initialize(data.companies);
|
||||
}
|
||||
|
||||
// Escuchar cambios de compañía y recargar datos
|
||||
const handleCompanyChange = () => {
|
||||
invalidateAll();
|
||||
};
|
||||
// ── Escuchar cambios de compañía y recargar datos ─────────────────────
|
||||
const handleCompanyChange = () => invalidateAll();
|
||||
window.addEventListener('companyChanged', handleCompanyChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('companyChanged', handleCompanyChange);
|
||||
window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroySessionManager();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider>
|
||||
@@ -67,3 +116,6 @@
|
||||
{#if !page.url.pathname.includes('/dashboard/invoices') && !page.url.pathname.includes('/dashboard/pedimentos')}
|
||||
<ExchangeRateGuard overlayClass="bg-black/5" />
|
||||
{/if}
|
||||
|
||||
<!-- Diálogo de advertencia de sesión por inactividad -->
|
||||
<SessionTimeoutWarning />
|
||||
|
||||
@@ -433,6 +433,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Validar tipo de cambio en create y update
|
||||
if (generalFormData) {
|
||||
const rate = generalFormData.exchange_rate;
|
||||
if (
|
||||
rate === null ||
|
||||
rate === undefined ||
|
||||
String(rate).trim() === '' ||
|
||||
Number(rate) <= 0
|
||||
) {
|
||||
saving = false;
|
||||
activeTab = 'general';
|
||||
const date = generalFormData.entry_date || '';
|
||||
toast.error(
|
||||
Number(rate) <= 0 && rate !== null && rate !== undefined
|
||||
? 'El tipo de cambio debe ser mayor a 0. Registra el tipo de cambio para la fecha de entrada.'
|
||||
: 'No hay tipo de cambio registrado para la fecha de entrada. Por favor, regístralo antes de guardar.'
|
||||
);
|
||||
if (date) {
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Construir el payload unificado
|
||||
const payload: any = {
|
||||
// Datos generales
|
||||
|
||||
Reference in New Issue
Block a user