feature/app-selector

This commit is contained in:
2026-05-26 16:59:32 -06:00
parent 904fa965c8
commit dbf68986da
34 changed files with 792 additions and 169 deletions

View File

@@ -0,0 +1,46 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
import {
extractAllowedSystemsFromToken,
fetchAllowedSystems,
isValidSystem,
mergeTokenClaims,
setActiveSystemCookie
} from '$lib/server/system-gate';
export const POST: RequestHandler = async ({ cookies, request }) => {
try {
const { system } = await request.json();
if (!isValidSystem(system)) {
return json({ error: 'Sistema inválido' }, { status: 400 });
}
const accessToken = getAccessTokenFromCookies(cookies);
if (!accessToken) {
return json({ error: 'No autenticado' }, { status: 401 });
}
const tokenClaims = mergeTokenClaims(null, accessToken);
let allowedSystems = extractAllowedSystemsFromToken(tokenClaims);
if (allowedSystems.length === 0) {
const rawCompanyId = cookies.get('active_company_id');
const companyId = rawCompanyId ? Number.parseInt(rawCompanyId, 10) : NaN;
if (Number.isFinite(companyId)) {
allowedSystems = await fetchAllowedSystems(cookies, fetch, companyId);
}
}
if (!allowedSystems.includes(system)) {
return json({ error: 'No tienes acceso a ese sistema' }, { status: 403 });
}
setActiveSystemCookie(cookies, system);
return json({ success: true, system });
} catch {
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -8,6 +8,7 @@ import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
import { isValidSystem, setActiveSystemCookie } from '$lib/server/system-gate';
// Disable client-side rendering to prevent SvelteKit from making a second
// __data.json request that would consume the one-time relay token twice.
@@ -15,7 +16,8 @@ export const csr = false;
export const load: PageServerLoad = async ({ url, cookies }) => {
const relayToken = url.searchParams.get('relay');
console.log('[SSO] relay token presente:', !!relayToken);
const requestedSystem = url.searchParams.get('active_system');
console.log('[SSO] relay token presente:', !!relayToken, '| active_system:', requestedSystem ?? '(none)');
if (!relayToken) {
redirectToWorkspaceLogin(cookies, url);
@@ -183,7 +185,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
maxAge: 60 * 60 * 24 * 7,
});
}
console.log('[SSO] cookies configuradas, redirigiendo a /dashboard');
console.log('[SSO] cookies configuradas, preparando redirect a /dashboard con active_system:', requestedSystem ?? '(none)');
// Ejecutar lazy-link server-side: crear UserTenant si hay invite pendiente.
// Se llama con el Bearer token recién obtenido. Best-effort, no bloquea el SSO.
@@ -202,5 +204,9 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
}).catch(() => {});
} catch { /* non-blocking */ }
if (isValidSystem(requestedSystem)) {
setActiveSystemCookie(cookies, requestedSystem);
}
throw redirect(303, '/dashboard');
};

View File

@@ -10,6 +10,14 @@ import {
import {
redirectToWorkspaceLogin
} from '$lib/server/workspace-auth';
import {
extractAllowedSystemsFromToken,
mergeTokenClaims,
resolveActiveCompanyId,
resolveSystemGate,
setActiveSystemCookie,
redirectToWorkspaceBase
} from '$lib/server/system-gate';
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
// Verificar si existe el token en las cookies
@@ -31,15 +39,22 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
const userData = await validateAuth(cookies, fetch, redirectOnFail);
// 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: '/' });
}
const tokenClaims = mergeTokenClaims(userData, accessToken);
const gate = resolveSystemGate({
tokenClaims,
cookieSystem: cookies.get('active_system') ?? null,
requestedSystem: url.searchParams.get('active_system') ?? null
});
const activeCompanyId = resolveActiveCompanyId(cookies, companies);
const allowedSystemsFromToken = extractAllowedSystemsFromToken(tokenClaims);
if (gate.action === 'redirect_workspace') {
redirectToWorkspaceBase();
}
// Si el JWT no trae el claim allowed_systems pero el gate resolvió un sistema,
// usar el sistema activo como mínimo para que el store pueda inicializarse.
const allowedSystems =
allowedSystemsFromToken.length > 0 ? allowedSystemsFromToken : [gate.activeSystem];
setActiveSystemCookie(cookies, gate.activeSystem);
// Cargar los tenants del usuario desde Hub (fuente de verdad multi-tenant)
let userTenants: { id: number; name: string; slug: string }[] = [];
@@ -59,14 +74,13 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
// No bloquear el dashboard si falla la carga de tenants
}
// Obtener la compañía activa de la cookie para persistencia
const activeCompanyId = cookies.get('active_company_id');
return {
authenticated: true,
user: { ...userData, token: accessToken },
user: { ...userData, token: accessToken, allowedSystems },
companies,
activeCompanyId: activeCompanyId ? parseInt(activeCompanyId) : undefined,
activeCompanyId: activeCompanyId ?? undefined,
activeSystem: gate.activeSystem,
allowedSystems,
userTenants,
error: undefined
};

View File

@@ -3,6 +3,7 @@
import { invalidateAll, goto } from '$app/navigation';
import type { LayoutData } from './$types';
import AppSidebar from '$lib/components/sidebar/app-sidebar.svelte';
import AppLauncher from '$lib/components/sidebar/app-launcher.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';
@@ -21,6 +22,7 @@
import { authStore, markPermissionsHydrated } from '$lib/auth';
import { logout, getKeycloakInstance } from '$lib/auth';
import LicenseErrorScreen from '$lib/components/license-error-screen.svelte';
import { systemStore, type SystemType } from '$lib/stores/system.svelte';
type LicenseError = { type: string; message: string; status: number };
let { data, children }: { data: LayoutData & { licenseError?: LicenseError }; children: any } = $props();
@@ -90,17 +92,26 @@
legacyAvatarUrl: data.user.legacyAvatarUrl ?? data.user.legacy_avatar_url ?? data.user.avatar_url ?? null,
tenantId: data.user.tenant_id,
roles: data.user.roles ?? [],
permissions: data.user.permissions ?? []
permissions: data.user.permissions ?? [],
allowedSystems: data.allowedSystems ?? data.user.allowedSystems ?? []
});
}
syncAuthStoreFromData();
systemStore.initialize(
(data.allowedSystems ?? []) as SystemType[],
(data.activeSystem ?? null) as string | null
);
// Re-sincronizar si data.user cambia (por ejemplo, tras invalidateAll o cambio de compañía)
$effect(() => {
// Dependencia explícita para que el effect reaccione al swap de data.user
data.user;
syncAuthStoreFromData();
systemStore.initialize(
(data.allowedSystems ?? []) as SystemType[],
(data.activeSystem ?? null) as string | null
);
});
// ── Manejar expiración de sesión ────────────────────────────────────────
@@ -181,20 +192,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>
{#if systemStore.allowedSystems.length > 0}
<div class="ml-auto px-4">
<AppLauncher />
</div>
{/if}
</header>
<div
id="dashboard-main-content"

View File

@@ -7,6 +7,8 @@ export const load: LayoutLoad = async ({ data }) => {
companies: data.companies,
authenticated: data.authenticated,
userTenants: data.userTenants ?? [],
activeCompanyId: data.activeCompanyId
activeCompanyId: data.activeCompanyId,
activeSystem: data.activeSystem ?? null,
allowedSystems: data.allowedSystems ?? []
};
};

View File

@@ -9,6 +9,7 @@
import { classesApi, type A76Class } from '$lib/api/dashboard/a76/classes';
import { faClassesApi, type FAClass } from '$lib/api/dashboard/a24/fa_classes';
import { companyStore } from '$lib/stores/company.svelte';
import { systemStore } from '$lib/stores/system.svelte';
import DataTable from '$lib/components/dashboard/goods/classes/data-table.svelte';
import { columns } from '$lib/components/dashboard/goods/classes/columns';
import { currentUser } from '$lib/auth';
@@ -66,6 +67,9 @@
let isSaving = $state(false);
let sorting = $state<import("@tanstack/table-core").SortingState>([]);
let status = $state<number>(200);
const activeSystem = $derived(systemStore.activeSystem);
const isInventoryMode = $derived(activeSystem === 'inventory');
const isFixedAssetMode = $derived(activeSystem !== 'inventory');
// Permisos
const canView = $derived(canViewGoodsClasses($currentUser));
@@ -139,7 +143,7 @@
const seq = ++loadSeq;
isLoading = true;
try {
const response = await classesApi.getWithFAData({
const commonParams = {
company_id: companyId,
page: currentPage,
page_size: PAGE_SIZE,
@@ -155,7 +159,10 @@
material_key: debouncedFilters.material_key.trim()
}),
...(debouncedFilters.fraction.trim() && { fraction: debouncedFilters.fraction.trim() })
});
};
const response = isInventoryMode
? await classesApi.list(commonParams)
: await classesApi.getWithFAData(commonParams);
if (seq !== loadSeq) return;
@@ -174,7 +181,9 @@
} else if (error?.status) {
status = error.status;
}
toast.error('Error al cargar las clases de activo fijo');
toast.error(
isInventoryMode ? 'Error al cargar las clases de inventario' : 'Error al cargar las clases de activo fijo'
);
} finally {
if (seq === loadSeq) {
isLoading = false;
@@ -266,7 +275,11 @@
function handleRowDoubleClick(cls: A76Class) {
if (!canEdit) {
toast.error('No tienes permiso para editar clases de activo fijo');
toast.error(
isInventoryMode
? 'No tienes permiso para editar clases de inventario'
: 'No tienes permiso para editar clases de activo fijo'
);
return;
}
const fixedClass = cls as FixedAssetClassExtended;
@@ -389,8 +402,14 @@
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">Clases de Activo Fijo</h1>
<p class="text-muted-foreground">Gestiona y consulta las clases de activo fijo</p>
<h1 class="text-2xl font-bold tracking-tight">
{isInventoryMode ? 'Clases de Inventario' : 'Clases de Activo Fijo'}
</h1>
<p class="text-muted-foreground">
{isInventoryMode
? 'Gestiona y consulta las clases del sistema de inventario'
: 'Gestiona y consulta las clases de activo fijo'}
</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" class="h-9" onclick={handleRefresh} disabled={isLoading}>
@@ -649,7 +668,9 @@
onInteractOutside={(e) => e.preventDefault()}
>
<Dialog.Header class="border-b p-6 pb-4">
<Dialog.Title>{selectedClass ? 'Editar' : 'Nueva'} Clase de Activo Fijo</Dialog.Title>
<Dialog.Title>
{selectedClass ? 'Editar' : 'Nueva'} {isInventoryMode ? 'Clase de Inventario' : 'Clase de Activo Fijo'}
</Dialog.Title>
</Dialog.Header>
<!-- Mensaje de error de validación -->
@@ -685,6 +706,7 @@
<div class="flex-1 overflow-auto p-6">
<FixedAssetClassForm
mode={isInventoryMode ? 'inventory' : 'fixed_asset'}
initialData={selectedClass}
externalError={validationError}
onClearError={() => (validationError = '')}
@@ -736,50 +758,52 @@
companyId
);
// También actualizar la extensión FA
if (selectedClass.fa_class_id) {
await faClassesApi.update(
selectedClass.fa_class_id,
{
depreciation_rate:
cleanData.annual_depreciation_rate !== undefined &&
cleanData.annual_depreciation_rate !== null &&
cleanData.annual_depreciation_rate !== ''
? Number(cleanData.annual_depreciation_rate)
: cleanData.depreciation_rate !== undefined &&
cleanData.depreciation_rate !== null &&
cleanData.depreciation_rate != null
? Number(cleanData.depreciation_rate)
: null,
import_tariff_code: mxFraccionNorm || null,
import_tariff_type: cleanData.import_tariff_type || null,
export_tariff_code: cleanData.export_tariff_code || null,
export_tariff_type: cleanData.export_tariff_type || null,
fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null,
eccn_code: cleanData.eccn_code || null
},
companyId
);
} else {
await faClassesApi.create(
{
class_id: selectedClass.id,
depreciation_rate:
cleanData.annual_depreciation_rate !== undefined &&
cleanData.annual_depreciation_rate !== null &&
cleanData.annual_depreciation_rate !== ''
? Number(cleanData.annual_depreciation_rate)
: cleanData.depreciation_rate !== undefined &&
cleanData.depreciation_rate !== null &&
cleanData.depreciation_rate != null
? Number(cleanData.depreciation_rate)
: null,
fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null,
eccn_code: cleanData.eccn_code || null,
class_enabled: true
},
companyId
);
if (isFixedAssetMode) {
// También actualizar la extensión FA cuando estamos en SCAF
if (selectedClass.fa_class_id) {
await faClassesApi.update(
selectedClass.fa_class_id,
{
depreciation_rate:
cleanData.annual_depreciation_rate !== undefined &&
cleanData.annual_depreciation_rate !== null &&
cleanData.annual_depreciation_rate !== ''
? Number(cleanData.annual_depreciation_rate)
: cleanData.depreciation_rate !== undefined &&
cleanData.depreciation_rate !== null &&
cleanData.depreciation_rate != null
? Number(cleanData.depreciation_rate)
: null,
import_tariff_code: mxFraccionNorm || null,
import_tariff_type: cleanData.import_tariff_type || null,
export_tariff_code: cleanData.export_tariff_code || null,
export_tariff_type: cleanData.export_tariff_type || null,
fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null,
eccn_code: cleanData.eccn_code || null
},
companyId
);
} else {
await faClassesApi.create(
{
class_id: selectedClass.id,
depreciation_rate:
cleanData.annual_depreciation_rate !== undefined &&
cleanData.annual_depreciation_rate !== null &&
cleanData.annual_depreciation_rate !== ''
? Number(cleanData.annual_depreciation_rate)
: cleanData.depreciation_rate !== undefined &&
cleanData.depreciation_rate !== null &&
cleanData.depreciation_rate != null
? Number(cleanData.depreciation_rate)
: null,
fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null,
eccn_code: cleanData.eccn_code || null,
class_enabled: true
},
companyId
);
}
}
// ¡IMPORTANTE! fetchApi NO lanza excepciones, retorna { error, status }
@@ -815,7 +839,9 @@
class_enabled: true
};
response = await classesApi.createFA(payload, companyId);
response = isInventoryMode
? await classesApi.create(payload, companyId)
: await classesApi.createFA(payload, companyId);
if (response.error) {
console.error('❌ Error del servidor:', response.error);

View File

@@ -14,6 +14,7 @@
import { obtenerAtajosListaMercancias } from '$lib/config/shortcuts/dashboard/goods/list';
import { currentUser } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import { systemStore, SYSTEM_LABELS } from '$lib/stores/system.svelte';
import {
canCreateGoodsParts,
canDeleteGoodsParts,
@@ -36,6 +37,9 @@
let status = $state<number>(200);
let showDeleteDialog = $state(false);
const activeSystem = $derived(systemStore.activeSystem);
const activeSystemLabel = $derived(activeSystem ? SYSTEM_LABELS[activeSystem] : null);
// Permisos
const canView = $derived(canViewGoodsParts($currentUser));
const canCreate = $derived(canCreateGoodsParts($currentUser));
@@ -187,7 +191,11 @@
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">Catálogo de Partes</h1>
<p class="text-muted-foreground">
Gestiona y consulta las partes de inventario y activo fijo
{#if activeSystemLabel}
{activeSystemLabel.name} ({activeSystemLabel.code})
{:else}
Gestiona y consulta las partes de inventario y activo fijo
{/if}
</p>
</div>
<div class="flex items-center gap-3">