feature/app-selector
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
73
frontend/src/lib/components/sidebar/app-launcher.svelte
Normal file
73
frontend/src/lib/components/sidebar/app-launcher.svelte
Normal 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>
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
130
frontend/src/lib/server/system-gate.ts
Normal file
130
frontend/src/lib/server/system-gate.ts
Normal 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());
|
||||
}
|
||||
70
frontend/src/lib/stores/system.svelte.ts
Normal file
70
frontend/src/lib/stores/system.svelte.ts
Normal 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();
|
||||
Reference in New Issue
Block a user