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

@@ -229,6 +229,15 @@ function buildAuthHeaders(baseHeaders: Record<string, string> = {}): Record<stri
if (tenantPub) {
headers['X-Tenant-Override'] = tenantPub;
}
// active_system (SCAF/SCAII): cookie no-HttpOnly → header explícito para el backend.
const activeSystem = document.cookie
.split('; ')
.find((c) => c.startsWith('active_system='))
?.split('=')[1];
if (activeSystem) {
headers['X-Active-System'] = activeSystem;
}
}
return headers;

View File

@@ -35,6 +35,7 @@ export interface User {
tenantId?: number;
roles: string[];
permissions: string[];
allowedSystems: string[]; // sistemas a los que tiene acceso: "fixed_asset" | "inventory"
// Cache management
profileSyncedAt?: number; // timestamp en ms para cache TTL
}
@@ -346,6 +347,7 @@ const updateAuthState = async () => {
tenantId,
roles,
permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms,
allowedSystems: previousUser?.allowedSystems ?? [],
profileSyncedAt: previousUser?.profileSyncedAt
};
@@ -481,7 +483,7 @@ export async function syncCompanyPermissions(companyId: number): Promise<void> {
if (!browser || !Number.isFinite(companyId)) return;
try {
const { api } = await import('./api');
const res = await api.get<{ permissions: string[] }>(
const res = await api.get<{ permissions: string[]; allowed_systems?: string[] }>(
`/v1/core/permissions/me?company_id=${companyId}`
);
if (res.error || res.data === undefined) return;
@@ -489,10 +491,24 @@ export async function syncCompanyPermissions(companyId: number): Promise<void> {
if (!Array.isArray(perms)) return;
const state = get(authStore);
if (!state.user) return;
const { systemStore } = await import('./stores/system.svelte');
const allowedSystems = (res.data.allowed_systems ?? []) as import('./stores/system.svelte').SystemType[];
authStore.setUser({
...state.user,
permissions: perms
permissions: perms,
// Preservar allowedSystems del SSR si el API no los retorna (JWT sin claim, seed pendiente)
allowedSystems: allowedSystems.length > 0 ? allowedSystems : (state.user.allowedSystems ?? [])
});
// Solo reinicializar el systemStore si el API retorna sistemas explícitos.
// Si está vacío, preservar el estado establecido por SSR para evitar resetear activeSystem a null.
if (allowedSystems.length > 0) {
const cookieSystem = typeof document !== 'undefined'
? document.cookie.match(/(?:^|;\s*)active_system=([^;]+)/)?.[1] ?? null
: null;
systemStore.initialize(allowedSystems, cookieSystem);
}
} catch (e) {
console.warn('[auth] syncCompanyPermissions:', e);
} finally {
@@ -591,6 +607,7 @@ const loadUserInfo = async (token: string) => {
tenantId: d.tenant_id ?? previousUser?.tenantId,
roles: d.roles ?? previousUser?.roles ?? [],
permissions: d.permissions ?? previousUser?.permissions ?? [],
allowedSystems: previousUser?.allowedSystems ?? [],
profileSyncedAt: Date.now()
});
}

View File

@@ -36,9 +36,18 @@
initialData?: any;
externalError?: string;
onClearError?: () => void;
mode?: 'fixed_asset' | 'inventory';
}
let { onSave, onCancel, initialData, externalError = '', onClearError }: Props = $props();
let {
onSave,
onCancel,
initialData,
externalError = '',
onClearError,
mode = 'fixed_asset'
}: Props = $props();
const isInventoryMode = $derived(mode === 'inventory');
// Tipo para unidad de medida con todos los campos
interface UnitOfMeasure {
@@ -286,7 +295,9 @@
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
const response = await materialTypesApi.list(companyId, 1, 100, 'ACTIVO FIJO');
const response = isInventoryMode
? await materialTypesApi.list(companyId, 1, 100)
: await materialTypesApi.list(companyId, 1, 100, 'ACTIVO FIJO');
if (response.data) {
materialTypes = response.data.items;
}
@@ -546,7 +557,9 @@
}
if (!formData.material_key?.trim()) {
errors.material_key = 'El tipo de activo fijo es obligatorio';
errors.material_key = isInventoryMode
? 'El tipo de material es obligatorio'
: 'El tipo de activo fijo es obligatorio';
}
if (!formData.unit_of_measure?.trim()) {
@@ -585,7 +598,9 @@
break;
case 'material_key':
if (!formData.material_key?.trim()) {
errors.material_key = 'El tipo de activo fijo es obligatorio';
errors.material_key = isInventoryMode
? 'El tipo de material es obligatorio'
: 'El tipo de activo fijo es obligatorio';
} else {
delete errors.material_key;
}
@@ -685,7 +700,7 @@
<div class="space-y-2">
<Label for="material_key" class="font-bold">
Tipo de Activo Fijo: <span class="text-red-500">*</span>
{isInventoryMode ? 'Tipo de Material' : 'Tipo de Activo Fijo'}: <span class="text-red-500">*</span>
</Label>
<div class="flex items-center gap-2">
<Input
@@ -833,48 +848,50 @@
</div>
</div>
<!-- Tasa depreciación y ECCN (misma fila que en pantalla legacy) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="annual_depreciation_rate">Tasa Anual de Depreciación:</Label>
<div class="flex items-center gap-2">
{#if !isInventoryMode}
<!-- Campos exclusivos del flujo de Activo Fijo -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="annual_depreciation_rate">Tasa Anual de Depreciación:</Label>
<div class="flex items-center gap-2">
<Input
id="annual_depreciation_rate"
type="number"
bind:value={formData.annual_depreciation_rate}
placeholder="0.00"
class="flex-1"
step="0.01"
/>
<span class="text-sm">%</span>
<Button type="button" variant="outline" size="icon" onclick={openDepreciationSearch}>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-2">
<Label for="eccn_code">ECCN:</Label>
<Input
id="annual_depreciation_rate"
type="number"
bind:value={formData.annual_depreciation_rate}
placeholder="0.00"
class="flex-1"
step="0.01"
id="eccn_code"
bind:value={formData.eccn_code}
placeholder="Código ECCN"
class="w-full uppercase"
maxlength={20}
/>
<span class="text-sm">%</span>
<Button type="button" variant="outline" size="icon" onclick={openDepreciationSearch}>
</div>
</div>
<!-- Clave FDA -->
<div class="space-y-2">
<Label for="fda_key">Clave FDA:</Label>
<div class="flex items-center gap-2">
<Input id="fda_key" bind:value={formData.fda_key} placeholder="Clave FDA" class="flex-1" />
<Button type="button" variant="outline" size="icon" onclick={openFDASearch}>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-2">
<Label for="eccn_code">ECCN:</Label>
<Input
id="eccn_code"
bind:value={formData.eccn_code}
placeholder="Código ECCN"
class="w-full uppercase"
maxlength={20}
/>
</div>
</div>
<!-- Clave FDA -->
<div class="space-y-2">
<Label for="fda_key">Clave FDA:</Label>
<div class="flex items-center gap-2">
<Input id="fda_key" bind:value={formData.fda_key} placeholder="Clave FDA" class="flex-1" />
<Button type="button" variant="outline" size="icon" onclick={openFDASearch}>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
{/if}
<!-- Fracción exenta de IVA (Sí / No + código opcional máx. 4) -->
<div class="space-y-2">
@@ -930,7 +947,7 @@
<Dialog.Root bind:open={showMaterialDialog}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>CATALOGO DE ACTIVO FIJO</Dialog.Title>
<Dialog.Title>{isInventoryMode ? 'CATALOGO DE MATERIALES' : 'CATALOGO DE ACTIVO FIJO'}</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div>

View File

@@ -2,6 +2,7 @@ import { goto } from '$app/navigation';
import { invoicesApi, type CreateInvoiceData, type UpdateInvoiceData } from '$lib/api/dashboard/a76/invoices';
import { m } from '$lib/i18n/messages';
import { normalizeInvoiceFieldPath } from './focus-invoice-field';
import { systemStore } from '$lib/stores/system.svelte';
interface FormDataSet {
InvoiceTopFieldsFormData: any;
@@ -161,9 +162,14 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise<SaveInvo
function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateInvoiceData {
const { InvoiceTopFieldsFormData, generalFormData, observationFormData, itemsFormData, othersFormData, continuationFormData } = formData;
// Sistema activo del selector de app (SCAF / SCAII).
// Si por alguna razón no está inicializado, mantenemos el comportamiento actual.
const activeSystem = systemStore.activeSystem;
const resolvedSystem = activeSystem ?? 'fixed_asset';
const payload: any = {
// Datos generales desde InvoiceTopFieldsFormData
system: 'fixed_asset',
system: resolvedSystem,
operation_type: InvoiceTopFieldsFormData?.operation_type || undefined,
invoice_type: InvoiceTopFieldsFormData?.invoice_type || undefined,
document_type: generalFormData?.document_type || undefined,

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import CheckIcon from '@lucide/svelte/icons/check';
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid';
import PackageIcon from '@lucide/svelte/icons/package';
import BarChart3Icon from '@lucide/svelte/icons/bar-chart-3';
import { systemStore, SYSTEM_LABELS, type SystemType } from '$lib/stores/system.svelte';
import { invalidateAll } from '$app/navigation';
const ICONS: Record<SystemType, any> = {
fixed_asset: PackageIcon,
inventory: BarChart3Icon,
};
async function switchSystem(sys: SystemType) {
if (sys === systemStore.activeSystem || systemStore.switching) return;
const ok = await systemStore.setActiveSystem(sys);
if (ok) await invalidateAll();
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<button
{...props}
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md
text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground
transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring
data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
title="Aplicaciones"
aria-label="Abrir selector de aplicaciones"
>
<LayoutGridIcon class="size-4" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-64 rounded-xl p-3"
align="end"
side="bottom"
sideOffset={8}
>
<p class="mb-3 px-1 text-xs font-medium text-muted-foreground">Tus aplicaciones</p>
<div class="grid grid-cols-2 gap-2">
{#each systemStore.allowedSystems as sys (sys)}
{@const label = SYSTEM_LABELS[sys]}
{@const Icon = ICONS[sys]}
{@const active = systemStore.activeSystem === sys}
{@const canSwitch = systemStore.canSwitch}
<button
onclick={() => switchSystem(sys)}
disabled={!canSwitch || systemStore.switching}
class="flex flex-col items-center gap-1.5 rounded-lg p-3 text-center transition-colors
disabled:cursor-default
{canSwitch ? 'hover:bg-accent' : ''}
{active ? 'ring-2 ring-primary bg-accent/50' : ''}
{systemStore.switching ? 'opacity-50' : ''}"
>
<div class="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Icon class="size-5" />
</div>
<span class="text-sm font-medium leading-tight">{label.name}</span>
<span class="text-xs text-muted-foreground">{label.code}</span>
{#if active}
<CheckIcon class="size-3 text-primary" />
{/if}
</button>
{/each}
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -8,6 +8,8 @@
import NavProjects from "./nav-projects.svelte";
import NavUser from "./nav-user.svelte";
import TeamSwitcher from "./team-switcher.svelte";
import AppLauncher from "./app-launcher.svelte";
import { systemStore } from "$lib/stores/system.svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import type { ComponentProps } from "svelte";

View File

@@ -23,10 +23,13 @@ import {
import { m } from '$lib/i18n/messages';
import { Title } from '../ui/alert';
export type SystemContext = 'fixed_asset' | 'inventory';
export interface NavItem {
title: string;
url: string;
permission?: string;
systemContext?: SystemContext;
}
export interface NavMainItem {
@@ -35,6 +38,7 @@ export interface NavMainItem {
icon: any;
isActive?: boolean;
permission?: string;
systemContext?: SystemContext;
items?: NavItem[];
}

View File

@@ -5,6 +5,7 @@
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { authStore, userHasPermission } from '$lib/auth';
import { systemStore } from '$lib/stores/system.svelte';
import { page } from '$app/state';
let {
@@ -16,10 +17,12 @@
icon?: any;
isActive?: boolean;
permission?: string;
systemContext?: 'fixed_asset' | 'inventory';
items?: {
title: string;
url: string;
permission?: string;
systemContext?: 'fixed_asset' | 'inventory';
}[];
}[];
} = $props();
@@ -29,13 +32,20 @@
return pathname === url || pathname.startsWith(url + '/');
}
// Filtrar items según permisos (si el item tiene la propiedad 'permission')
function matchesSystem(ctx: 'fixed_asset' | 'inventory' | undefined): boolean {
if (!ctx) return true;
if (!systemStore.activeSystem) return false;
return ctx === systemStore.activeSystem;
}
// Filtrar items según permisos y sistema activo
const filteredItems = $derived(
items
.map((item) => ({
...item,
items: item.items?.filter((subItem) => {
if (subItem.permission && !userHasPermission($authStore.user, subItem.permission)) return false;
if (!matchesSystem(subItem.systemContext)) return false;
return true;
})
}))
@@ -43,7 +53,10 @@
// 1. Filtrar por permiso explícito del item principal
if (item.permission && !userHasPermission($authStore.user, item.permission)) return false;
// 2. Ocultar categorías (url="#") que se quedaron sin sub-items visibles
// 2. Filtrar por sistema activo
if (!matchesSystem(item.systemContext)) return false;
// 3. Ocultar categorías (url="#") que se quedaron sin sub-items visibles
if (item.url === '#' && item.items && item.items.length === 0) return false;
return true;

View File

@@ -81,16 +81,18 @@ export function clearAuthTokens(cookies: Cookies) {
cookies.delete('refresh_token', { path: '/' });
cookies.delete('id_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
cookies.delete('active_system', { path: '/' });
}
/**
* Crea headers de autorización con el token Bearer
*/
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, tenantOverride?: string) {
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, tenantOverride?: string, activeSystem?: string) {
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
...(activeSystem ? { 'X-Active-System': activeSystem } : {}),
...additionalHeaders
};
}
@@ -169,6 +171,8 @@ export async function authenticatedFetch(
// Leer tenant override de cookie SSO (flujo multi-tenant relay)
const tenantOverride = cookies.get('sso_tenant_id');
// Sistema activo (SCAF/SCAII) para reenviar al backend vía header
const activeSystem = cookies.get('active_system');
// Crear AbortController para timeout
const controller = new AbortController();
@@ -181,8 +185,12 @@ export async function authenticatedFetch(
// Si el body es FormData, no incluir Content-Type (el navegador lo establece con el boundary)
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>, tenantOverride);
? {
'Authorization': `Bearer ${accessToken}`,
...(activeSystem ? { 'X-Active-System': activeSystem } : {}),
...(options.headers as Record<string, string> || {})
}
: createAuthHeaders(accessToken, options.headers as Record<string, string>, tenantOverride, activeSystem);
let response = await fetch(url, {
...options,
@@ -212,8 +220,12 @@ 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>, tenantOverride);
? {
'Authorization': `Bearer ${newToken}`,
...(activeSystem ? { 'X-Active-System': activeSystem } : {}),
...(options.headers as Record<string, string> || {})
}
: createAuthHeaders(newToken, options.headers as Record<string, string>, tenantOverride, activeSystem);
response = await fetch(url, {
...options,

View File

@@ -0,0 +1,130 @@
import { redirect, type Cookies } from '@sveltejs/kit';
import type { SystemType } from '$lib/stores/system.svelte';
import { authenticatedFetch } from '$lib/server/api';
import { getWorkspaceBaseUrl } from '$lib/server/workspace-auth';
const VALID_SYSTEMS = new Set<SystemType>(['fixed_asset', 'inventory']);
export function isValidSystem(value: string | null | undefined): value is SystemType {
return typeof value === 'string' && VALID_SYSTEMS.has(value as SystemType);
}
function parseSystemsArray(raw: unknown): SystemType[] {
if (!Array.isArray(raw)) return [];
return raw.filter((s): s is SystemType => typeof s === 'string' && isValidSystem(s));
}
/** Decodifica el payload del JWT (sin verificar firma; el token ya fue validado vía Hub). */
export function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
if (parts.length < 2) return null;
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
const json = Buffer.from(padded, 'base64').toString('utf8');
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
}
/** Combina claims del JWT con la respuesta de /auth/me (Hub). */
export function mergeTokenClaims(
userData: Record<string, unknown> | null | undefined,
accessToken: string
): Record<string, unknown> {
const jwtClaims = decodeJwtPayload(accessToken) ?? {};
return { ...jwtClaims, ...(userData ?? {}) };
}
/**
* Sistemas permitidos según el token (claim `allowed_systems`).
* El Hub/Keycloak lo incluye cuando el usuario entra desde Workspace.
*/
export function extractAllowedSystemsFromToken(
tokenClaims: Record<string, unknown> | null | undefined
): SystemType[] {
if (!tokenClaims) return [];
return parseSystemsArray(tokenClaims.allowed_systems ?? tokenClaims.allowedSystems);
}
export function setActiveSystemCookie(cookies: Cookies, system: SystemType) {
cookies.set('active_system', system, {
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 días
sameSite: 'lax',
httpOnly: false,
secure: process.env.NODE_ENV === 'production'
});
}
export function resolveActiveCompanyId<T extends { id: number }>(
cookies: Cookies,
companies: T[]
): number | null {
const cookieCompanyId = cookies.get('active_company_id');
if (cookieCompanyId) {
const cookieId = Number.parseInt(cookieCompanyId, 10);
if (Number.isFinite(cookieId) && companies.some((c) => c.id === cookieId)) return cookieId;
}
return companies.length > 0 ? companies[0].id : null;
}
/** Permisos RBAC por compañía (fallback / validación en set-active). */
export async function fetchAllowedSystems(
cookies: Cookies,
fetch: typeof globalThis.fetch,
companyId: number
): Promise<SystemType[]> {
const res = await authenticatedFetch(
`v1/core/permissions/me?company_id=${companyId}`,
{ method: 'GET' },
cookies,
fetch
);
if (!res.ok) return [];
const data = (await res.json()) as { allowed_systems?: unknown };
return parseSystemsArray(data.allowed_systems);
}
export type SystemGateResult =
| { action: 'redirect_workspace' }
| { action: 'proceed'; activeSystem: SystemType };
/**
* Gate obligatorio. Prioridad:
* 1. requestedSystem del URL (Hub tiene autoridad — viene del relay firmado one-time)
* 2. cookieSystem validado contra allowedSystems del JWT
* 3. Primer sistema de allowedSystems del JWT
* 4. redirect_workspace si nada resuelve
*
* requestedSystem se acepta incluso si el JWT no trae allowed_systems (Keycloak sin claim):
* el backend valida RBAC en cada request de API.
*/
export function resolveSystemGate(params: {
tokenClaims: Record<string, unknown> | null | undefined;
cookieSystem?: string | null;
requestedSystem?: string | null;
}): SystemGateResult {
const requestedSystem = params.requestedSystem;
if (isValidSystem(requestedSystem)) {
return { action: 'proceed', activeSystem: requestedSystem };
}
const allowedSystems = extractAllowedSystemsFromToken(params.tokenClaims);
const cookieSystem = params.cookieSystem;
if (isValidSystem(cookieSystem)) {
return { action: 'proceed', activeSystem: cookieSystem };
}
if (allowedSystems.length > 0) {
return { action: 'proceed', activeSystem: allowedSystems[0] };
}
return { action: 'redirect_workspace' };
}
export function redirectToWorkspaceBase(): never {
throw redirect(303, getWorkspaceBaseUrl());
}

View File

@@ -0,0 +1,70 @@
export type SystemType = 'fixed_asset' | 'inventory';
export const SYSTEM_LABELS: Record<SystemType, { name: string; code: string }> = {
fixed_asset: { name: 'Módulo de Activo Fijo', code: 'SCAF' },
inventory: { name: 'Módulo Control de Inventarios', code: 'SCAII' },
};
const VALID_SYSTEMS = new Set<string>(['fixed_asset', 'inventory']);
class SystemStore {
_activeSystem = $state<SystemType | null>(null);
_allowedSystems = $state<SystemType[]>([]);
_switching = $state(false);
get activeSystem() {
return this._activeSystem;
}
get allowedSystems() {
return this._allowedSystems;
}
get canSwitch() {
return this._allowedSystems.length > 1;
}
get switching() {
return this._switching;
}
get activeLabel() {
return this._activeSystem ? SYSTEM_LABELS[this._activeSystem] : null;
}
initialize(allowedSystems: SystemType[], cookieValue: string | null) {
this._allowedSystems = allowedSystems;
if (cookieValue && VALID_SYSTEMS.has(cookieValue) && allowedSystems.includes(cookieValue as SystemType)) {
this._activeSystem = cookieValue as SystemType;
} else if (allowedSystems.length === 1) {
this._activeSystem = allowedSystems[0];
} else {
this._activeSystem = null;
}
}
async setActiveSystem(system: SystemType): Promise<boolean> {
if (!this._allowedSystems.includes(system) || this._switching) return false;
this._switching = true;
try {
const res = await fetch('/api-sveltekit/system/set-active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system }),
credentials: 'include',
});
if (res.ok) {
this._activeSystem = system;
return true;
}
return false;
} catch {
return false;
} finally {
this._switching = false;
}
}
clear() {
this._activeSystem = null;
this._allowedSystems = [];
}
}
export const systemStore = new SystemStore();

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">