Files
plantillas-proyectos/frontend/src/routes/dashboard/+layout.svelte
AlexeerCT b348be5ff3 feat(auth): implement session management and silent refresh for Keycloak
- Added session manager to handle token refresh and user activity tracking.
- Introduced session timeout warning dialog for user inactivity.
- Updated authentication token handling in cookies with security policies.
- Created server-side endpoint for silent refresh of access tokens using HttpOnly cookies.
- Added silent check SSO HTML page for Keycloak integration.
2026-03-05 08:13:19 -06:00

122 lines
4.7 KiB
Svelte

<script lang="ts">
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';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
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);
// ── 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();
window.addEventListener('companyChanged', handleCompanyChange);
return () => {
window.removeEventListener('companyChanged', handleCompanyChange);
window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
};
});
onDestroy(() => {
destroySessionManager();
});
</script>
<Sidebar.Provider>
<AppSidebar />
<Sidebar.Inset class="overflow-x-hidden">
<header
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"
>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
<!--
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">Dashboard</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Page>Inicio</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
-->
</div>
</header>
<div class="flex flex-1 flex-col gap-4 overflow-x-hidden p-4 pt-0">
<!-- Contenido de cada página -->
{@render children?.()}
</div>
</Sidebar.Inset>
</Sidebar.Provider>
{#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 />