Merge branch 'development' into feature/reporte-bak

This commit is contained in:
2026-04-30 16:30:14 -06:00
45 changed files with 1923 additions and 2297 deletions

View File

@@ -7,7 +7,13 @@ declare global {
token: string | null;
isAuthenticated: boolean;
}
// interface PageData {}
interface PageData {
licenseError?: {
type: string;
message: string;
status: number;
};
}
// interface PageState {}
// interface Platform {}
}

View File

@@ -229,6 +229,18 @@ async function fetchApi<T = any>(
headers['Authorization'] = `Bearer ${token}`;
}
// Incluir tenant override para flujo SSO multi-tenant.
// sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id.
if (browser) {
const tenantPub = document.cookie
.split('; ')
.find((c) => c.startsWith('sso_tenant_pub='))
?.split('=')[1];
if (tenantPub) {
headers['X-Tenant-Override'] = tenantPub;
}
}
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
@@ -236,24 +248,32 @@ async function fetchApi<T = any>(
credentials: 'include' // Importante: envía cookies con cada request
});
// Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token
if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
// Si es 403 (Forbidden), mostrar toast de permisos insuficientes
if (response.status === 403) {
if (browser) {
toast.error('No tienes permisos para realizar esta acción', {
duration: 4000,
description: 'Contacta a tu administrador si crees que esto es un error'
});
}
// Retornar el error 403 sin intentar refresh
const data = await response.json();
return {
error: data.detail || 'No tienes permisos para realizar esta acción',
status: 403
};
// 403 = permisos, no autenticación: nunca intentar refresh.
if (response.status === 403 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
if (browser) {
toast.error('No tienes permisos para realizar esta acción', {
duration: 4000,
description: 'Contacta a tu administrador si crees que esto es un error'
});
}
const data = await response.json();
return {
error: data.detail || 'No tienes permisos para realizar esta acción',
status: 403
};
}
// 402 = licencia inválida/expirada: no intentar refresh.
if (response.status === 402 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
const data = await response.json().catch(() => ({}));
return {
error: data.message || data.detail || 'Licencia inválida o expirada',
status: 402
};
}
// Solo 401 dispara silent refresh.
if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
// Si es 401, intentar refrescar el token
isRefreshing = true;
@@ -682,7 +702,16 @@ export const api = {
api.post('/v1/auth/refresh/', { refresh_token: refreshToken }),
logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }),
me: () => api.get('/v1/auth/me/'),
health: () => api.get('/health')
health: () => api.get('/health'),
register: (data: {
username: string;
email: string;
password: string;
first_name: string;
last_name: string;
tenant_slug: string;
invite_token?: string;
}) => api.post('/v1/auth/register', data),
},
tenants: {

View File

@@ -405,18 +405,14 @@ export const logout = async () => {
deleteCookie('access_token');
// La cookie HttpOnly del refresh_token la limpia el servidor
// Logout de Keycloak JS si estaba autenticado con SSO
if (keycloakInstance?.authenticated) {
// Logout unificado (SSO y password): POST al logout route del servidor.
// Evita redirección visible al endpoint de Keycloak.
if (keycloakInstance) {
try {
await fetch('/logout', { method: 'POST' });
} catch { }
await keycloakInstance.logout({
redirectUri: window.location.origin + '/login'
});
return;
keycloakInstance.clearToken();
} catch {}
}
// Para login con password: POST al logout route del servidor
const form = document.createElement('form');
form.method = 'POST';
form.action = '/logout';

View File

@@ -124,11 +124,16 @@
</script>
<Sheet.Root bind:open={helpStore.isOpen}>
<Sheet.Trigger
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"
>
<HelpCircle size={28} />
<Sheet.Trigger>
{#snippet child({ props })}
<button
{...props}
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"
>
<HelpCircle size={28} />
</button>
{/snippet}
</Sheet.Trigger>
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
<Sheet.Header>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { logout } from '$lib/auth';
interface LicenseError {
type: string;
message: string;
status: number;
}
let { error }: { error: LicenseError } = $props();
const isHubOffline = error.type === 'HUB_OFFLINE' || error.type === 'HUB_ERROR';
function handleLogout() {
void logout();
}
</script>
<div class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center p-8">
<div class="flex max-w-md flex-col items-center gap-6 text-center">
<!-- Icon -->
{#if isHubOffline}
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-yellow-600 dark:text-yellow-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
</div>
{:else}
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-destructive/10">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-destructive"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"
/>
</svg>
</div>
{/if}
<!-- Heading -->
<div class="flex flex-col gap-2">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">
{#if isHubOffline}
Servicio de licencias no disponible
{:else}
Acceso suspendido
{/if}
</h1>
<p class="text-sm text-muted-foreground">
{error.message}
</p>
</div>
<!-- Help text -->
<div class="rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
{#if isHubOffline}
El servidor de licencias no está disponible en este momento. Por favor, inténtalo de nuevo
en unos minutos o contacta a soporte si el problema persiste.
{:else if error.type === 'LICENSE_ERROR'}
Tu organización no cuenta con una licencia activa para acceder al sistema. Contacta a tu
administrador o al equipo de soporte para regularizar tu suscripción.
{:else}
No tienes permisos para acceder al sistema. Contacta a tu administrador.
{/if}
</div>
<!-- Actions -->
<div class="flex gap-3">
{#if isHubOffline}
<Button variant="outline" onclick={() => window.location.reload()}>
Reintentar
</Button>
{/if}
<Button variant="destructive" onclick={handleLogout}>
Cerrar sesión
</Button>
</div>
</div>
</div>

View File

@@ -1,27 +1,27 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card/index.js";
import * as Card from "$lib/components/ui/card/index.ts";
import {
FieldGroup,
Field,
FieldLabel,
FieldDescription,
} from "$lib/components/ui/field/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
} from "$lib/components/ui/field/index.ts";
import { Input } from "$lib/components/ui/input/index.ts";
import { Button } from "$lib/components/ui/button/index.ts";
import { cn } from "$lib/utils.ts";
import faviconUrl from '$lib/assets/favicon.svg';
import type { HTMLAttributes } from "svelte/elements";
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { loginWithProvider } from '$lib/sso';
import { loginWithProvider } from '$lib/sso.ts';
import { onMount, tick } from 'svelte';
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
const id = $props.id();
let username = $state('demo');
let password = $state('demo123');
let username = $state('');
let password = $state('');
let tenantSlug = $state('');
let loading = $state(false);
// step 1 = credenciales, step 2 = selección de organización
@@ -32,12 +32,18 @@
// Descubrimiento de tenants
type TenantInfo = { id: number; name: string; slug: string };
let tenants = $state<TenantInfo[]>([]);
let discoveryError = $state('');
const error = $derived(page.form?.error || '');
const error = $derived(discoveryError || page.form?.error || '');
// Limpiar todo el localStorage y cookies al montar el componente de login
onMount(() => {
clearAllData();
// Si viene ?tenant= en la URL (ej: después del registro), pre-seleccionar
const urlTenant = new URL(window.location.href).searchParams.get('tenant');
if (urlTenant) {
tenantSlug = urlTenant;
}
});
// Función para limpiar cookies del cliente
@@ -61,22 +67,25 @@
}
async function fetchTenants(): Promise<TenantInfo[]> {
discoveryError = '';
try {
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
// Llama a /login SIN tenant_slug: el backend verifica credenciales primero,
// luego devuelve las orgs. Sin contraseña válida no se revela nada.
const res = await fetch(`${apiBase}/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
const data = await res.json();
// { status: "choose_tenant", tenants: [...] }
// Múltiples tenants: { status: "choose_tenant", tenants: [...] }
if (data.tenants) return data.tenants;
// Un solo tenant: Hub devuelve token directo con data.tenant
if (data.access_token && data.tenant) return [data.tenant];
} else {
discoveryError = data.detail || 'Error de autenticación';
}
} catch {
// ignore — el server action mostrará el error de autenticación
discoveryError = 'Error de conexión con el servidor';
}
return [];
}
@@ -132,8 +141,11 @@
} else if (tenants.length > 1) {
// Varias orgs: mostrar selector
step = 2;
} else if (discoveryError) {
// Error claro del Hub (sin licencia, credenciales inválidas, etc.)
// No hacer submit — el error ya se muestra en discoveryError
} else {
// 0 orgs: enviar igual, el backend rechazará
// 0 orgs sin error: enviar igual, el backend rechazará
readyToSubmit = true;
await tick();
formEl?.requestSubmit();
@@ -142,11 +154,12 @@
}
loading = true;
return async ({ update, result }) => {
await update();
await update({ reset: false });
loading = false;
readyToSubmit = false;
if (result.type === 'failure') {
clearClientCookies();
step = 1;
}
};
}}

View File

@@ -49,7 +49,7 @@
<Sidebar.Root {collapsible} {...restProps}>
<Sidebar.Header>
<TeamSwitcher />
<TeamSwitcher {userTenants} />
</Sidebar.Header>
<Sidebar.Content>
<NavMain items={data.navMain} />

View File

@@ -7,9 +7,20 @@
import CheckIcon from '@lucide/svelte/icons/check';
import { companyStore } from '$lib/stores/company.svelte';
import { getBackendAssetUrl } from '$lib/utils';
import { invalidateAll } from '$app/navigation';
interface Tenant {
id: number;
name: string;
slug: string;
}
let { userTenants = [] }: { userTenants: Tenant[] } = $props();
const sidebar = useSidebar();
let switchingTenant = $state(false);
// Derivar la URL del logo usando el endpoint específico
let activeCompanyLogoUrl = $derived(
companyStore.activeCompany?.logo
@@ -24,11 +35,70 @@
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
);
// Fallback en degradé cuando no hay logo cargado
const fallbackBg =
'radial-gradient(circle at 30% 30%, rgba(0,0,0,0.08), rgba(0,0,0,0.12)), linear-gradient(135deg, rgba(99,102,241,0.12), rgba(14,165,233,0.18))';
function readCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
return null;
}
const logoBg = $derived(activeCompanyLogoUrl ? `url(${activeCompanyLogoUrl})` : fallbackBg);
let activeTenantPubId = $derived.by<number | null>(() => {
const fromCookie = readCookie('sso_tenant_pub');
if (fromCookie && !Number.isNaN(Number(fromCookie))) return Number(fromCookie);
return null;
});
async function switchTenant(tenant: Tenant) {
if (switchingTenant) return;
switchingTenant = true;
try {
const res = await fetch('/api-sveltekit/auth/switch-tenant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenant_id: tenant.id }),
credentials: 'include',
});
if (res.ok) {
companyStore.clear();
await invalidateAll();
} else {
const err = await res.json().catch(() => ({}));
console.error('[team-switcher] switch-tenant error:', err);
}
} catch (e) {
console.error('[team-switcher] fetch error:', e);
} finally {
switchingTenant = false;
}
}
function normalizeIdentity(value: string | undefined | null): string {
return (value ?? '').trim().toLowerCase();
}
let tenantIdentitySet = $derived.by(() => {
const set = new Set<string>();
for (const tenant of userTenants) {
set.add(normalizeIdentity(tenant.name));
set.add(normalizeIdentity(tenant.slug));
}
set.delete('');
return set;
});
// Excluir del listado de companias cualquier registro que realmente represente al tenant.
let myCompanies = $derived(
companyStore.companies.filter((company) => !tenantIdentitySet.has(normalizeIdentity(company.name)))
);
$effect(() => {
const active = companyStore.activeCompany;
if (!active) return;
if (!tenantIdentitySet.has(normalizeIdentity(active.name))) return;
if (myCompanies.length === 0) return;
void companyStore.setActiveCompany(myCompanies[0], true);
});
</script>
<Sidebar.Menu>
@@ -99,18 +169,42 @@
side={sidebar.isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis Compañías</DropdownMenu.Label>
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
{#if userTenants.length === 0}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">Sin tenant asignado</span>
</DropdownMenu.Item>
{:else}
{#each userTenants as tenant (tenant.id)}
<DropdownMenu.Item
onSelect={() => switchTenant(tenant)}
class="cursor-pointer gap-2 p-2"
disabled={switchingTenant}
>
<div class="flex size-6 items-center justify-center rounded-md border bg-muted">
<BuildingIcon class="size-3.5" />
</div>
<span class="truncate font-medium">{tenant.name}</span>
{#if activeTenantPubId === tenant.id}
<CheckIcon class="ml-auto size-4 text-primary" />
{/if}
</DropdownMenu.Item>
{/each}
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis compañías</DropdownMenu.Label>
{#if companyStore.loading}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">Cargando...</span>
</DropdownMenu.Item>
{:else if companyStore.companies.length === 0}
{:else if myCompanies.length === 0}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">No hay compañías disponibles</span>
<span class="text-muted-foreground">No tienes compañías disponibles</span>
</DropdownMenu.Item>
{:else}
{#each companyStore.companies as company, index (company.id)}
{#each myCompanies as company, index (company.id)}
<DropdownMenu.Item
onSelect={() => companyStore.setActiveCompany(company)}
class="cursor-pointer gap-2 p-2"

View File

@@ -82,10 +82,11 @@ export function clearAuthTokens(cookies: Cookies) {
/**
* Crea headers de autorización con el token Bearer
*/
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>) {
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, tenantOverride?: string) {
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
...additionalHeaders
};
}
@@ -162,6 +163,9 @@ export async function authenticatedFetch(
// Construir URL completa
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
// Leer tenant override de cookie SSO (flujo multi-tenant relay)
const tenantOverride = cookies.get('sso_tenant_id');
// Crear AbortController para timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
@@ -174,7 +178,7 @@ export async function authenticatedFetch(
const isFormData = options.body instanceof FormData;
const headers = isFormData
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
: createAuthHeaders(accessToken, options.headers as Record<string, string>);
: createAuthHeaders(accessToken, options.headers as Record<string, string>, tenantOverride);
let response = await fetch(url, {
...options,
@@ -205,7 +209,7 @@ export async function authenticatedFetch(
// Si el body es FormData, no incluir Content-Type
const newHeaders = isFormData
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
: createAuthHeaders(newToken, options.headers as Record<string, string>);
: createAuthHeaders(newToken, options.headers as Record<string, string>, tenantOverride);
response = await fetch(url, {
...options,

View File

@@ -112,8 +112,14 @@ class CompanyStore {
}
} else {
console.error('Error loading companies:', response.error);
// Si falla la carga (ej: 401), limpiar el store
if (response.status === 401) {
if (response.status === 402) {
const { toast } = await import('svelte-sonner');
toast.error('Licencia inactiva', {
duration: 8000,
description: response.error || 'Tu licencia no está activa para este tenant. Contacta al administrador.'
});
this.clear();
} else if (response.status === 401) {
this.clear();
}
}

View File

@@ -1,41 +1,82 @@
/**
* Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente.
*
* Flujo:
* 1. Cliente llama POST /api-sveltekit/auth/switch-tenant con { tenant_slug }
* 2. Este servidor lee access_token y refresh_token de las cookies (HttpOnly).
* 3. Llama al backend /v1/auth/switch-tenant con ambos tokens.
* 4. Si es exitoso, actualiza las cookies con los nuevos tokens.
* 5. Retorna ok al cliente para que recargue la página.
* Dos modos:
* - { tenant_id } → flujo SSO relay: solo actualiza cookie sso_tenant_id (override de tenant)
* - { tenant_slug } → flujo login clásico: re-emite tokens KC para el nuevo tenant
*/
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
const { tenant_slug } = await request.json();
const body = await request.json();
const { tenant_id, tenant_slug } = body as { tenant_id?: number; tenant_slug?: string };
if (!tenant_slug) {
return json({ error: 'tenant_slug is required' }, { status: 400 });
if (!tenant_id && !tenant_slug) {
return json({ error: 'tenant_id or tenant_slug is required' }, { status: 400 });
}
const { accessToken, refreshToken } = getAuthTokens(cookies);
if (!accessToken || !refreshToken) {
if (!accessToken) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
if (tenant_id) {
try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (!tenantsRes.ok) {
return json({ error: 'Could not validate tenant access' }, { status: 403 });
}
const tenants: { id: number }[] = await tenantsRes.json();
const hasAccess = tenants.some((t) => t.id === tenant_id);
if (!hasAccess) {
return json({ error: 'Access denied to tenant' }, { status: 403 });
}
const isProduction = process.env.NODE_ENV === 'production';
cookies.set('sso_tenant_id', String(tenant_id), {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
cookies.set('sso_tenant_pub', String(tenant_id), {
path: '/',
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
cookies.delete('active_company_id', { path: '/' });
return json({ ok: true });
} catch (error) {
console.error('[switch-tenant] SSO mode error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}
// Modo login clásico: re-emitir tokens KC para el nuevo tenant
if (!refreshToken) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
try {
const baseUrl = getServerApiUrl();
const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken })
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }),
});
if (!response.ok) {
@@ -44,15 +85,13 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
}
const data = await response.json();
// Actualizar cookies con los nuevos tokens del nuevo tenant
setAuthTokens(cookies, data.access_token, data.refresh_token);
// Limpiar la compañía activa para que el dashboard recargue con el nuevo tenant
cookies.delete('active_company_id', { path: '/' });
cookies.delete('sso_tenant_id', { path: '/' });
cookies.delete('sso_tenant_pub', { path: '/' });
return json({ ok: true });
} catch (error) {
console.error('[switch-tenant] Error:', error);
console.error('[switch-tenant] Classic mode error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,101 @@
/**
* SSO auto-login page for Anexo76.
* The Hub App Launcher redirects here with ?relay=<token> after generating a relay token.
* This server-side load function exchanges the relay token for KC tokens via the
* Hub backend, sets HttpOnly cookies, and redirects to /dashboard.
*/
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, cookies }) => {
const relayToken = url.searchParams.get('relay');
console.log('[SSO] relay token presente:', !!relayToken);
if (!relayToken) {
throw redirect(303, '/login?error=sso_missing_token');
}
// SSO exchange must call Hub backend, not Anexo76 backend.
// Use INTERNAL_HUB_URL for server-to-server communication.
let hubUrl = process.env.INTERNAL_HUB_URL;
if (!hubUrl) {
hubUrl = process.env.VITE_HUB_URL;
// Fallback: replace localhost with hub-backend for Docker
hubUrl = hubUrl?.replace('localhost', 'host.docker.internal').replace('127.0.0.1', 'host.docker.internal');
}
const baseUrl = hubUrl?.endsWith('/') ? hubUrl : `${hubUrl}/`;
let response: Response;
try {
response = await fetch(`${baseUrl}api/v1/auth/sso-exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relay_token: relayToken }),
});
} catch (err) {
throw redirect(303, '/login?error=sso_hub_unreachable');
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const detail = body?.detail || 'sso_exchange_failed';
throw redirect(303, `/login?error=${encodeURIComponent(detail)}`);
}
const tokens = await response.json();
console.log('[SSO] exchange exitoso, tokens recibidos:', {
hasAccessToken: !!tokens.access_token,
accessTokenLen: tokens.access_token?.length,
hasRefreshToken: !!tokens.refresh_token,
tenant_id: tokens.tenant_id,
tenant_slug: tokens.tenant_slug,
});
const isProduction = process.env.NODE_ENV === 'production';
console.log('[SSO] NODE_ENV:', process.env.NODE_ENV, '→ isProduction:', isProduction);
// access_token — NO HttpOnly (client JS reads it for Bearer headers)
cookies.set('access_token', tokens.access_token, {
path: '/',
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
// refresh_token — HttpOnly (never exposed to JS)
if (tokens.refresh_token) {
cookies.set('refresh_token', tokens.refresh_token, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
});
}
// sso_tenant_id — HttpOnly cookie con el tenant seleccionado.
// El backend lo pasa como X-Tenant-Override en Hub /auth/me para que
// devuelva el tenant correcto aunque el KC token tenga otro tenant baked in.
if (tokens.tenant_id) {
cookies.set('sso_tenant_id', String(tokens.tenant_id), {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
// sso_tenant_pub — companion no-HttpOnly para que el cliente JS pueda
// leer el tenant override e incluirlo como X-Tenant-Override en fetch directo al backend.
// No es un secreto (solo un ID numérico; Hub valida UserTenant en cada request).
cookies.set('sso_tenant_pub', String(tokens.tenant_id), {
path: '/',
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
}
console.log('[SSO] cookies configuradas, redirigiendo a /dashboard');
throw redirect(303, '/dashboard');
};

View File

@@ -0,0 +1,11 @@
<script lang="ts">
// This page is never rendered — the server-side load always redirects.
// It exists only to satisfy SvelteKit's file-based routing requirement.
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="flex flex-col items-center gap-4 text-center">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
<p class="text-sm text-slate-500">Iniciando sesión automáticamente…</p>
</div>
</div>

View File

@@ -1,16 +1,17 @@
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { LayoutServerLoad } from './$types';
import {
validateAuth,
getUserCompanies,
getAuthTokens,
clearAuthTokens,
authenticatedFetch
getUserCompanies,
clearAuthTokens
} from '$lib/server/api';
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
// Verificar si existe el token en las cookies
const { accessToken } = getAuthTokens(cookies);
console.log('[dashboard layout] access_token presente:', !!accessToken, '| url:', url.pathname);
// Si no hay token, redirigir al login, pero excluir la ruta /login para evitar bucle
if (!accessToken && url.pathname !== '/login') {
@@ -28,18 +29,29 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
// Cargar las compañías del usuario en el servidor (SSR)
const companies = await getUserCompanies(cookies, fetch);
// Cargar los tenants del usuario para el selector de organización
let userTenants: { id: number; name: string; slug: string; is_active: boolean }[] = [];
// Si la cookie active_company_id apunta a una compañía que ya no existe, limpiarla
const cookieCompanyId = cookies.get('active_company_id');
if (cookieCompanyId) {
const cookieId = parseInt(cookieCompanyId);
const stillExists = companies.some((c) => c.id === cookieId);
if (!stillExists) {
cookies.delete('active_company_id', { path: '/' });
}
}
// Cargar los tenants del usuario desde Hub (fuente de verdad multi-tenant)
let userTenants: { id: number; name: string; slug: string }[] = [];
try {
const tenantsRes = await authenticatedFetch(
`v1/core/user-tenants/${userData.sub}`,
{},
cookies,
fetch
);
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantOverride = cookies.get('sso_tenant_id');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
},
});
if (tenantsRes.ok) {
const tenantsData = await tenantsRes.json();
userTenants = tenantsData.tenants ?? [];
userTenants = await tenantsRes.json();
}
} catch {
// No bloquear el dashboard si falla la carga de tenants
@@ -70,7 +82,6 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
}
// Para cualquier otro error (conexión, etc), limpiar token y redirigir
console.error('🔐 [Dashboard] Error validando token:', error);
clearAuthTokens(cookies);
throw redirect(303, redirectOnFail);
}

View File

@@ -20,8 +20,10 @@
import type { SessionExpiredDetail } from '$lib/session-manager';
import { authStore } from '$lib/auth';
import { logout, getKeycloakInstance } from '$lib/auth';
import LicenseErrorScreen from '$lib/components/license-error-screen.svelte';
let { data, children }: { data: LayoutData; children: any } = $props();
type LicenseError = { type: string; message: string; status: number };
let { data, children }: { data: LayoutData & { licenseError?: LicenseError }; children: any } = $props();
let csvImportBanner = $state(false);
let csvImportBannerLabel = $state<string | null>(null);
@@ -132,6 +134,9 @@
});
</script>
{#if data.licenseError}
<LicenseErrorScreen error={data.licenseError} />
{:else}
<Sidebar.Provider>
<AppSidebar />
<Sidebar.Inset class="overflow-x-hidden">
@@ -187,3 +192,4 @@
<!-- Diálogo de advertencia de sesión por inactividad -->
<SessionTimeoutWarning />
{/if}

View File

@@ -30,8 +30,8 @@ export const actions = {
}
try {
const baseUrl = getServerApiUrl();
const loginUrl = `${baseUrl}v1/auth/login`;
const hubUrl = process.env.INTERNAL_HUB_URL || 'http://host.docker.internal:8001';
const loginUrl = `${hubUrl}/api/v1/auth/login`;
const requestBody = {
username,

View File

@@ -1,14 +1,49 @@
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies }) => {
export const POST: RequestHandler = async ({ cookies, request }) => {
const refreshToken = cookies.get('refresh_token');
// Post-logout siempre va al workspace login, no al login local de Anexo76.
// Desde el workspace el usuario puede volver a autenticarse con Microsoft
// y el relay lo traerá de vuelta automáticamente.
// HUB_URL es la URL pública del workspace (ej: https://workspace.aduanasoft.com)
const hubPublicUrl = (env.HUB_URL || '').replace(/\/+$/, '');
const workspaceLoginUrl = hubPublicUrl
? `${hubPublicUrl}/login`
: `${new URL(request.url).origin}/login`;
// Eliminar todas las cookies de autenticación
cookies.delete('access_token', { path: '/' });
cookies.delete('refresh_token', { path: '/' });
// Eliminar la cookie de la compañía activa
cookies.delete('active_company_id', { path: '/' });
cookies.delete('sso_tenant_id', { path: '/' });
cookies.delete('sso_tenant_pub', { path: '/' });
// Redirigir al login
throw redirect(303, '/login');
// Llamar al Hub para revocar el refresh token.
// La navegación final siempre debe volver al login local de anexo76
// sin redirigir al endpoint de logout de Keycloak.
if (refreshToken) {
try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const res = await fetch(`${hubUrl}/api/v1/auth/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refresh_token: refreshToken,
post_logout_redirect_uri: workspaceLoginUrl,
}),
});
if (res.ok) {
await res.json().catch(() => ({}));
}
} catch (err: any) {
// Si falla la llamada al Hub, caer al workspace login de todos modos
}
}
throw redirect(303, workspaceLoginUrl);
};

View File

@@ -1,6 +1,28 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { api } from '$lib/api';
import { onMount } from 'svelte';
// Parámetros del URL — se rellenan desde el link de invitación
let inviteToken = $state('');
let inviteTenantSlug = $state('');
let inviteEmail = $state('');
let isInviteFlow = $state(false);
onMount(() => {
const params = new URL(window.location.href).searchParams;
inviteToken = params.get('invite_token') ?? '';
inviteTenantSlug = params.get('tenant') ?? '';
inviteEmail = params.get('email') ?? '';
isInviteFlow = Boolean(inviteToken && inviteTenantSlug);
if (isInviteFlow) {
// Pre-rellenar campos bloqueados desde la invitación
formData.tenant_slug = inviteTenantSlug;
if (inviteEmail) formData.email = inviteEmail;
}
});
let formData = $state({
username: '',
@@ -9,25 +31,24 @@
confirmPassword: '',
first_name: '',
last_name: '',
tenant_slug: 'aduanasoft' // Por defecto
tenant_slug: 'aduanasoft'
});
let loading = $state(false);
let error = $state('');
let passwordError = $state('');
let success = $state(false);
async function handleRegister(e: Event) {
e.preventDefault();
error = '';
passwordError = '';
// Validar que las contraseñas coincidan
if (formData.password !== formData.confirmPassword) {
passwordError = 'Las contraseñas no coinciden';
return;
}
// Validar longitud de contraseña
if (formData.password.length < 8) {
passwordError = 'La contraseña debe tener al menos 8 caracteres';
return;
@@ -36,22 +57,30 @@
loading = true;
try {
const response = await api.auth.register({
const payload: Record<string, string> = {
username: formData.username,
email: formData.email,
password: formData.password,
first_name: formData.first_name,
last_name: formData.last_name,
tenant_slug: formData.tenant_slug
});
tenant_slug: formData.tenant_slug,
};
if (inviteToken) {
payload.invite_token = inviteToken;
}
const response = await api.auth.register(payload as any);
if (response.error) {
throw new Error(response.error);
}
// Registro exitoso, redirigir al login
alert(`¡Registro exitoso! Bienvenido ${response.data.username}`);
goto('/login');
success = true;
// Redirigir al login con el tenant pre-seleccionado tras unos segundos
setTimeout(() => {
goto(`/login?tenant=${encodeURIComponent(formData.tenant_slug)}`);
}, 2000);
} catch (err: any) {
error = err.message || 'Error al registrar usuario';
} finally {
@@ -77,6 +106,45 @@
</p>
</div>
<!-- Banner de invitación -->
{#if isInviteFlow}
<div class="mt-4 rounded-md bg-blue-50 border border-blue-200 p-4">
<div class="flex">
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<div>
<p class="text-sm font-medium text-blue-800">Invitación válida</p>
<p class="text-sm text-blue-700 mt-1">
Estás registrándote en <strong>{inviteTenantSlug}</strong>.
El enlace caduca en 48 horas y es de un solo uso.
</p>
</div>
</div>
</div>
{/if}
<!-- Pantalla de éxito -->
{#if success}
<div class="mt-8 rounded-lg bg-white px-6 py-8 shadow text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900">¡Cuenta creada!</h3>
<p class="mt-2 text-sm text-gray-600">
Te hemos enviado un correo de verificación a <strong>{formData.email}</strong>.
Confirma tu email antes de iniciar sesión.
</p>
<a
href="/login"
class="mt-6 inline-block rounded-md bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Ir al login
</a>
</div>
{:else}
<!-- Formulario de registro -->
<div class="mt-8">
<div class="rounded-lg bg-white px-6 py-8 shadow">
@@ -108,9 +176,13 @@
id="email"
bind:value={formData.email}
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
readonly={isInviteFlow && Boolean(inviteEmail)}
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 {isInviteFlow && inviteEmail ? 'bg-gray-50 text-gray-500 cursor-not-allowed' : ''}"
placeholder="usuario@ejemplo.com"
/>
{#if isInviteFlow && inviteEmail}
<p class="mt-1 text-xs text-gray-500">El email está fijado por la invitación.</p>
{/if}
</div>
<!-- Nombre -->
@@ -187,15 +259,25 @@
<label for="tenant_slug" class="block text-sm font-medium text-gray-700">
Empresa
</label>
<select
id="tenant_slug"
bind:value={formData.tenant_slug}
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
>
<option value="aduanasoft">AduanaSoft</option>
<!-- Agregar más tenants aquí -->
</select>
{#if isInviteFlow}
<input
type="text"
id="tenant_slug"
value={formData.tenant_slug}
readonly
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm bg-gray-50 text-gray-500 cursor-not-allowed"
/>
<p class="mt-1 text-xs text-gray-500">Fijado por la invitación.</p>
{:else}
<select
id="tenant_slug"
bind:value={formData.tenant_slug}
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
>
<option value="aduanasoft">AduanaSoft</option>
</select>
{/if}
</div>
<!-- Errores -->
@@ -233,5 +315,6 @@
</div>
</div>
</div>
{/if}
</div>
</div>