From dbf68986dae2e42c92c2a7e77e28f2ec747d4ae4 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 26 May 2026 16:59:32 -0600 Subject: [PATCH] feature/app-selector --- ..._add_system_column_to_classes_and_parts.py | 36 +++++ backend/api/v1/common/tenant_crud_routes.py | 26 +++- backend/api/v1/modules/a76/classes/dto.py | 12 +- backend/api/v1/modules/a76/classes/models.py | 3 + backend/api/v1/modules/a76/classes/routes.py | 55 +++++--- backend/api/v1/modules/a76/classes/service.py | 12 +- backend/api/v1/modules/a76/invoices/routes.py | 20 ++- .../api/v1/modules/a76/invoices/services.py | 4 + backend/api/v1/modules/a76/parts/dto.py | 3 + backend/api/v1/modules/a76/parts/models.py | 3 + backend/api/v1/modules/a76/parts/service.py | 2 + .../api/v1/modules/core/permissions/routes.py | 29 +++- .../v1/modules/core/permissions/schemas.py | 4 + .../v1/modules/core/permissions/seed_v2.py | 9 ++ backend/core/middleware.py | 4 +- backend/core/security.py | 11 ++ frontend/src/lib/api.ts | 9 ++ frontend/src/lib/auth.ts | 21 ++- .../classes/forms/FixedAssetClassForm.svelte | 101 ++++++++------ .../dashboard/invoices/edit/save-invoice.ts | 8 +- .../components/sidebar/app-launcher.svelte | 73 ++++++++++ .../lib/components/sidebar/app-sidebar.svelte | 2 + .../src/lib/components/sidebar/modules.ts | 4 + .../lib/components/sidebar/nav-main.svelte | 17 ++- frontend/src/lib/server/api.ts | 22 ++- frontend/src/lib/server/system-gate.ts | 130 ++++++++++++++++++ frontend/src/lib/stores/system.svelte.ts | 70 ++++++++++ .../system/set-active/+server.ts | 46 +++++++ frontend/src/routes/auth/sso/+page.server.ts | 10 +- .../src/routes/dashboard/+layout.server.ts | 40 ++++-- frontend/src/routes/dashboard/+layout.svelte | 31 +++-- frontend/src/routes/dashboard/+layout.ts | 4 +- .../goods/fixed-asset-classes/+page.svelte | 130 +++++++++++------- .../routes/dashboard/goods/parts/+page.svelte | 10 +- 34 files changed, 792 insertions(+), 169 deletions(-) create mode 100644 backend/alembic/versions/a7b8c9d0e1f2_add_system_column_to_classes_and_parts.py create mode 100644 frontend/src/lib/components/sidebar/app-launcher.svelte create mode 100644 frontend/src/lib/server/system-gate.ts create mode 100644 frontend/src/lib/stores/system.svelte.ts create mode 100644 frontend/src/routes/api-sveltekit/system/set-active/+server.ts diff --git a/backend/alembic/versions/a7b8c9d0e1f2_add_system_column_to_classes_and_parts.py b/backend/alembic/versions/a7b8c9d0e1f2_add_system_column_to_classes_and_parts.py new file mode 100644 index 00000000..2cb668fb --- /dev/null +++ b/backend/alembic/versions/a7b8c9d0e1f2_add_system_column_to_classes_and_parts.py @@ -0,0 +1,36 @@ +"""add system column to classes and parts + +Revision ID: a7b8c9d0e1f2 +Revises: d2e3f4a5b6c7 +Create Date: 2026-05-26 10:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "a7b8c9d0e1f2" +down_revision: Union[str, None] = "d2e3f4a5b6c7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Columna system en a76.classes — indica si la clase pertenece a SCAF (fixed_asset) o SCAII (inventory) + op.add_column( + "classes", + sa.Column("system", sa.String(length=12), nullable=False, server_default="fixed_asset"), + schema="a76", + ) + # Columna system en a76.parts — misma discriminación por sistema + op.add_column( + "parts", + sa.Column("system", sa.String(length=12), nullable=False, server_default="fixed_asset"), + schema="a76", + ) + + +def downgrade() -> None: + op.drop_column("classes", "system", schema="a76") + op.drop_column("parts", "system", schema="a76") diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index 5329a9dd..6297394c 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -3,7 +3,7 @@ import logging import inspect from core.database import get_core_db -from core.security import get_current_user, is_hub_admin, resolve_tenant_id_required, validate_access_to_resource +from core.security import get_current_user, is_hub_admin, resolve_tenant_id_required, validate_access_to_resource, get_active_system from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request from api.v1.common.catalog_validation_errors import CatalogValidationError @@ -175,11 +175,16 @@ class TenantCRUDRoutes( # Excluimos los parámetros estándar de paginación y control standard_params = {"company_id", "all_companies", "page", "page_size", "sort_by", "sort_order"} filters = { - k: v - for k, v in request.query_params.items() + k: v + for k, v in request.query_params.items() if k not in standard_params and v is not None and v != "" } + # Inyectar active_system (header/cookie) si no viene por query param + active_system = get_active_system(request) + if active_system and "system" not in filters: + filters["system"] = active_system + # Determine what parameters the service method accepts sig = inspect.signature(self.service.get_all) kwargs = {} @@ -383,6 +388,7 @@ class TenantCRUDRoutes( description=f"Create a new {self.resource_name}", ) async def create_child_resource( + request: Request, company_id: int = Query(..., description="Company ID"), data: create_schema = Body(...), # type: ignore db: Session = Depends(self.db_dependency), @@ -396,6 +402,12 @@ class TenantCRUDRoutes( self.require_all, ) + # Inyectar sistema activo en el campo system si el recurso lo soporta + if self.enable_filters: + active_system = get_active_system(request) + if active_system and hasattr(data, "system"): + data = data.model_copy(update={"system": active_system}) + # For child resources, parent_id validation would go here try: resource = self.service.create(db, data, tenant_id, company_id) @@ -429,6 +441,7 @@ class TenantCRUDRoutes( description=f"Create a new {self.resource_name}", ) async def create_parent_resource( + request: Request, company_id: int = Query(..., description="Company ID"), data: create_schema = Body(...), # type: ignore db: Session = Depends(self.db_dependency), @@ -441,6 +454,13 @@ class TenantCRUDRoutes( self.create_permissions, self.require_all, ) + + # Inyectar sistema activo en el campo system si el recurso lo soporta + if self.enable_filters: + active_system = get_active_system(request) + if active_system and hasattr(data, "system"): + data = data.model_copy(update={"system": active_system}) + try: resource = self.service.create(db, data, tenant_id, company_id) return resource diff --git a/backend/api/v1/modules/a76/classes/dto.py b/backend/api/v1/modules/a76/classes/dto.py index 5cfb7601..91fd7c0d 100644 --- a/backend/api/v1/modules/a76/classes/dto.py +++ b/backend/api/v1/modules/a76/classes/dto.py @@ -46,6 +46,10 @@ class ClassCreateDTO(BaseModel): is_active: Optional[bool] = Field( True, description="Indicates if the class is active (default: true)" ) + system: str = Field( + default="fixed_asset", max_length=12, + description="Sistema: 'fixed_asset' (SCAF) o 'inventory' (SCAII)" + ) class Config: from_attributes = True @@ -125,6 +129,10 @@ class ClassUpdateDTO(BaseModel): is_active: Optional[bool] = Field( True, description="Indicates if the class is active (default: true)" ) + system: Optional[str] = Field( + None, max_length=12, + description="Sistema: 'fixed_asset' (SCAF) o 'inventory' (SCAII)" + ) model_config = ConfigDict(from_attributes=True, extra='forbid') # Explicitly forbid extra fields @@ -146,6 +154,7 @@ class ClassResponseDTO(BaseModel): physical_review: Optional[int] = None iva_exempt_fraction: Optional[str] = None is_active: Optional[bool] = None + system: str = "fixed_asset" created_at: datetime updated_at: datetime @@ -226,9 +235,10 @@ class ClassWithFADataResponse(BaseModel): sub_key: Optional[str] = None physical_review: Optional[int] = None iva_exempt_fraction: Optional[str] = None + system: str = "fixed_asset" created_at: datetime updated_at: datetime - + # FA-specific fields (embedded from a24.fa_classes) fa_class_id: Optional[int] = None import_tariff_code: Optional[str] = None diff --git a/backend/api/v1/modules/a76/classes/models.py b/backend/api/v1/modules/a76/classes/models.py index 0ceadbf5..3b9c5950 100644 --- a/backend/api/v1/modules/a76/classes/models.py +++ b/backend/api/v1/modules/a76/classes/models.py @@ -88,6 +88,9 @@ class Class(Base, TenantScopedMixin, TimestampMixin): default=True, server_default="true", nullable=False ) # Campo para habilitar/deshabilitar clases sin eliminarlas + # Sistema al que pertenece la clase: 'fixed_asset' (SCAF) o 'inventory' (SCAII) + system: Mapped[str] = mapped_column(String(12), nullable=False, server_default="fixed_asset") + # Relationships material_type: Mapped[Optional["MaterialType"]] = relationship( foreign_keys=[material_key] diff --git a/backend/api/v1/modules/a76/classes/routes.py b/backend/api/v1/modules/a76/classes/routes.py index 0bce94a2..490c7a16 100644 --- a/backend/api/v1/modules/a76/classes/routes.py +++ b/backend/api/v1/modules/a76/classes/routes.py @@ -3,11 +3,11 @@ Endpoints API para gestión de clases SCAII y SCAF """ from typing import Any, Dict, Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from core.database import get_core_db -from core.security import get_current_user +from core.security import get_current_user, get_active_system from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource from .dto import ( @@ -37,6 +37,7 @@ router.include_router(imports_router, prefix="/imports", tags=["a76 / classes / tags=["a76 / classes"], ) async def get_classes_with_fa_data( + request: Request, company_id: int = Query(..., description="Company ID"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(50, ge=1, le=1000, description="Page size"), @@ -54,28 +55,31 @@ async def get_classes_with_fa_data( This endpoint is optimized for the fixed-asset-classes view. """ tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.view"]) - + skip = (page - 1) * page_size - filters: Optional[Dict[str, Any]] = None - if any([class_code, description, material_key, fraction]): - filters = {} - if class_code: - filters["class_code"] = class_code - if description: - filters["description"] = description - if material_key: - filters["material_key"] = material_key - if fraction: - filters["fraction"] = fraction - + filters: Dict[str, Any] = {} + if class_code: + filters["class_code"] = class_code + if description: + filters["description"] = description + if material_key: + filters["material_key"] = material_key + if fraction: + filters["fraction"] = fraction + + # Sistema activo desde header/cookie (TenantMiddleware no setea request.state en /api/) + active_system = get_active_system(request) + if active_system: + filters["system"] = active_system + classes_with_fa, total = ClassService.get_all_with_fa_data( db=db, tenant_id=tenant_id, company_id=company_id, skip=skip, limit=page_size, - filters=filters, + filters=filters if filters else None, sort_by=sort_by, sort_order=sort_order, ) @@ -96,17 +100,30 @@ async def get_classes_with_fa_data( tags=["a76 / classes"], ) async def create_fa_class( + request: Request, class_data: ClassCreateDTOFA, company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """Create a fixed asset class (both base class and FA extension)""" - + active_system = get_active_system(request) + if active_system == "inventory": + raise HTTPException( + status_code=403, + detail=( + "Solo se pueden crear clases de activo fijo con el módulo de Activo Fijo (SCAF) activo. " + "Cambie de aplicación e intente de nuevo." + ), + ) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.create"]) - + + # Siempre persistir como activo fijo; no depender del default del DTO ni del body del cliente + class_data = class_data.model_copy(update={"system": "fixed_asset"}) + result = ClassService.create_fa_class(db, class_data, tenant_id, company_id) - + return result # Now include generic CRUD routes diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py index acd7e717..5dd6c532 100644 --- a/backend/api/v1/modules/a76/classes/service.py +++ b/backend/api/v1/modules/a76/classes/service.py @@ -82,7 +82,9 @@ class ClassService: query = query.filter( Class.physical_review == filters["physical_review"] ) - + if filters.get("system"): + query = query.filter(Class.system == filters["system"]) + # Apply sorting if sort_by: column = getattr(Class, sort_by, None) @@ -161,7 +163,9 @@ class ClassService: ) if filters.get("fraction"): query = query.filter(Class.fraction.ilike(f"%{filters['fraction']}%")) - + if filters.get("system"): + query = query.filter(Class.system == filters["system"]) + # Apply sorting if sort_by: # Check if sort_by belongs to Class or QClasses @@ -203,6 +207,7 @@ class ClassService: "sub_key": base_class.sub_key, "physical_review": base_class.physical_review, "iva_exempt_fraction": base_class.iva_exempt_fraction, + "system": base_class.system, "created_at": base_class.created_at, "updated_at": base_class.updated_at, # FA extension fields (None if no FA record exists) @@ -399,7 +404,7 @@ class ClassService: base_fields = { "class_code", "description_es", "description_en", "material_key", "unit_of_measure", "fraction", "us_fraction", - "sub_key", "physical_review", "iva_exempt_fraction" + "sub_key", "physical_review", "iva_exempt_fraction", "system" } base_data = {k: v for k, v in class_data.model_dump().items() if k in base_fields} @@ -442,6 +447,7 @@ class ClassService: "sub_key": base_class.sub_key, "physical_review": base_class.physical_review, "iva_exempt_fraction": base_class.iva_exempt_fraction, + "system": base_class.system, "created_at": base_class.created_at, "updated_at": base_class.updated_at, # FA extension fields diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index 04028763..5ec8d56c 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -4,8 +4,8 @@ from typing import Dict, Any, Literal, Optional from core.config import settings from core.database import get_core_db from core.exceptions import BaseAPIException -from core.security import collect_user_role_names, get_current_user, validate_access_to_resource -from fastapi import APIRouter, Depends, HTTPException, Query, Path +from core.security import collect_user_role_names, get_current_user, validate_access_to_resource, get_active_system +from fastapi import APIRouter, Depends, HTTPException, Query, Path, Request from sqlalchemy import func, or_, and_ from sqlalchemy.orm import Session @@ -181,6 +181,7 @@ def get_remesa_suggestion( @router.post("/invoices/", response_model=schemas.InvoiceHeaderResponse) def create_invoice( + request: Request, data: schemas.InvoiceHeaderCreate, company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), @@ -188,11 +189,16 @@ def create_invoice( ): try: tenant_id = validate_access_to_resource(db, company_id, current_user) - + + # Sistema autoritativo desde el contexto activo (header/cookie) + active_system = get_active_system(request) + if active_system: + data = data.model_copy(update={"system": active_system}) + # Validamos usando los datos que vienen en el body (payload) perm_base = get_invoice_permission_base(data.operation_type, data.invoice_type) validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.create"]) - + return services.InvoiceService.create(db, data, tenant_id, company_id) except HTTPException: raise @@ -506,6 +512,7 @@ def interface_cp_genesis( @router.get("/invoices/", response_model=schemas.InvoiceHeaderListResponse) def list_invoices( + request: Request, company_id: int = Query(..., description="Company ID"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(50, ge=1, le=200, description="Items per page"), @@ -575,9 +582,10 @@ def list_invoices( "pedimento": pedimento, "project_number": project_number, "year": year, - "allowed_types": allowed_filters + "allowed_types": allowed_filters, + "system": get_active_system(request), } - + filters = {k: v for k, v in filters.items() if v is not None} items, total = services.InvoiceService.get_all( diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 368d8b04..5465fe34 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -369,6 +369,10 @@ class InvoiceService: if not filters.get("invoice_type") and ot_exp_val == "exp": query = query.filter(models.InvoiceHeader.invoice_type != "REPAR") + # Filtro por sistema activo (SCAF / SCAII) + if filters.get("system"): + query = query.filter(models.InvoiceHeader.system == filters["system"]) + # Filtro por permisos granulares (allowed_types) if "allowed_types" in filters: allowed = filters["allowed_types"] diff --git a/backend/api/v1/modules/a76/parts/dto.py b/backend/api/v1/modules/a76/parts/dto.py index 9320e585..9064ad9d 100644 --- a/backend/api/v1/modules/a76/parts/dto.py +++ b/backend/api/v1/modules/a76/parts/dto.py @@ -174,6 +174,9 @@ class PartBase(BaseModel): is_active: bool = True part_photo: Optional[str] = None + # Sistema al que pertenece la parte: 'fixed_asset' (SCAF) o 'inventory' (SCAII) + system: str = Field(default="fixed_asset", max_length=12) + # Anidados fa_data: Optional[FaDataDTO] = None inv_data: Optional[InvDataDTO] = None diff --git a/backend/api/v1/modules/a76/parts/models.py b/backend/api/v1/modules/a76/parts/models.py index 0f1e8c03..1d2f35e4 100644 --- a/backend/api/v1/modules/a76/parts/models.py +++ b/backend/api/v1/modules/a76/parts/models.py @@ -97,6 +97,9 @@ class Part(Base, TenantScopedMixin, TimestampMixin): ) part_photo: Mapped[Optional[str]] = mapped_column(String(255)) + # Sistema al que pertenece la parte: 'fixed_asset' (SCAF) o 'inventory' (SCAII) + system: Mapped[str] = mapped_column(String(12), nullable=False, server_default="fixed_asset") + creation_date: Mapped[Optional[int]] = mapped_column() modification_date: Mapped[Optional[int]] = mapped_column() modification_date_iso: Mapped[Optional[datetime]] = mapped_column(DateTime) diff --git a/backend/api/v1/modules/a76/parts/service.py b/backend/api/v1/modules/a76/parts/service.py index 01b0b747..4e6c1528 100644 --- a/backend/api/v1/modules/a76/parts/service.py +++ b/backend/api/v1/modules/a76/parts/service.py @@ -96,6 +96,8 @@ class PartService: Part.commercial_part_number.ilike(search) ) ) + if filters.get("system"): + query = query.filter(Part.system == filters["system"]) # Otros filtros... # Apply sorting diff --git a/backend/api/v1/modules/core/permissions/routes.py b/backend/api/v1/modules/core/permissions/routes.py index 31a55e21..c0f68866 100644 --- a/backend/api/v1/modules/core/permissions/routes.py +++ b/backend/api/v1/modules/core/permissions/routes.py @@ -104,18 +104,45 @@ async def get_my_permissions( # Nota: bootstrap_super_admin ya hace commit e intenta no duplicar si el rol ya existe permission_service.bootstrap_super_admin(user_id, company_id) - # 4. Obtener permisos finales + # 4. Sincronizar permisos de sistema desde claim de Keycloak (si viene en el token) + # El claim 'allowed_systems' puede contener ["fixed_asset"], ["inventory"], o ambos. + # Solo sincroniza si el claim está presente; si no, los permisos de sistema se asignan + # manualmente desde el panel de administración. + _VALID_SYSTEMS = frozenset(("fixed_asset", "inventory")) + keycloak_systems = current_user.get("allowed_systems") or [] + if keycloak_systems: + for sys_code in keycloak_systems: + if sys_code in _VALID_SYSTEMS: + try: + permission_service.grant_direct_permission( + user_id=user_id, + company_id=company_id, + permission_code=f"system.{sys_code}.access", + assigned_by="keycloak_sync", + ) + except ValueError: + # El permiso de sistema aún no existe en BD (sync pendiente); se ignora. + pass + + # 5. Obtener permisos finales permissions = permission_service.get_user_permissions(user_id, company_id) # 5. Obtener roles roles = permission_service.get_user_roles(user_id, company_id) role_names = [role.name for role in roles] + # 6. Derivar sistemas permitidos desde los permisos de sistema + allowed_systems = [ + sys for sys in ("inventory", "fixed_asset") + if f"system.{sys}.access" in permissions + ] + return UserPermissionsResponse( user_id=user_id, company_id=company_id, permissions=list(permissions), roles=role_names, + allowed_systems=allowed_systems, ) diff --git a/backend/api/v1/modules/core/permissions/schemas.py b/backend/api/v1/modules/core/permissions/schemas.py index c3c03b36..1f259fcb 100644 --- a/backend/api/v1/modules/core/permissions/schemas.py +++ b/backend/api/v1/modules/core/permissions/schemas.py @@ -60,6 +60,10 @@ class UserPermissionsResponse(BaseModel): roles: List[str] = Field( default_factory=list, description="Lista de nombres de roles del usuario" ) + allowed_systems: List[str] = Field( + default_factory=list, + description="Sistemas a los que el usuario tiene acceso: fixed_asset, inventory", + ) class UserCompanyRoleResponse(BaseModel): diff --git a/backend/api/v1/modules/core/permissions/seed_v2.py b/backend/api/v1/modules/core/permissions/seed_v2.py index 00e92e10..3ec46fd3 100644 --- a/backend/api/v1/modules/core/permissions/seed_v2.py +++ b/backend/api/v1/modules/core/permissions/seed_v2.py @@ -200,6 +200,14 @@ permissions_csv = [ ("csv_upload.process", "Procesar Cargas Masivas CSV", "csv_upload", "process"), ] +# ============================================================================ +# ACCESO A SISTEMAS (SCAII / SCAF) +# ============================================================================ +permissions_system_access = [ + ("system.inventory.access", "Acceso al sistema Inventario (SCAII)", "system", "access"), + ("system.fixed_asset.access", "Acceso al sistema Activo Fijo (SCAF)", "system", "access"), +] + def register_core_permissions(): """Registra los permisos granulados de la aplicación según Sidebar.""" registry.register_many(permissions_audit) @@ -222,6 +230,7 @@ def register_core_permissions(): registry.register_many(permissions_settings) registry.register_many(permissions_help) registry.register_many(permissions_csv) + registry.register_many(permissions_system_access) # Ejecutar registro al importar este módulo register_core_permissions() diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 5c5a6117..caa441f4 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from .config import settings -from .security import get_tenant_from_token, verify_token +from .security import get_tenant_from_token, verify_token, get_active_system logger = logging.getLogger(__name__) @@ -53,6 +53,7 @@ def _extract_company_id(request: Request) -> Optional[int]: except (TypeError, ValueError): return None + class TenantMiddleware(BaseHTTPMiddleware): """ Middleware original para extraer tenant_id y user_info del token. @@ -96,6 +97,7 @@ class TenantMiddleware(BaseHTTPMiddleware): request.state.tenant_id = tenant_id request.state.user_info = user_info request.state.company_id = _extract_company_id(request) + request.state.active_system = get_active_system(request) except Exception as e: logger.error(f"❌ Tenant validation error: {str(e)}") return JSONResponse( diff --git a/backend/core/security.py b/backend/core/security.py index 1db3dacc..85fca6c0 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -33,6 +33,17 @@ _tenant_id_hub_by_local: Dict[int, int] = {} # Security scheme security = HTTPBearer() +# Sistemas válidos del selector de app (SCAF / SCAII) +_VALID_SYSTEMS = frozenset(("fixed_asset", "inventory")) + + +def get_active_system(request: Request) -> Optional[str]: + """Sistema activo (SCAF/SCAII): header ``X-Active-System`` (autoritativo del cliente) + con fallback a la cookie ``active_system``. No depende de ``request.state`` porque + ``TenantMiddleware`` hace short-circuit en rutas ``/api/``.""" + value = request.headers.get("x-active-system") or request.cookies.get("active_system") + return value if value in _VALID_SYSTEMS else None + async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, Any]: """ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0a89942e..d79f63c6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -229,6 +229,15 @@ function buildAuthHeaders(baseHeaders: Record = {}): Record c.startsWith('active_system=')) + ?.split('=')[1]; + if (activeSystem) { + headers['X-Active-System'] = activeSystem; + } } return headers; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 77d6acc5..4c6b28fc 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -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 { 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 { 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() }); } diff --git a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte index 49305d14..fad6c2fa 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte @@ -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 @@
- -
-
- -
+ {#if !isInventoryMode} + +
+
+ +
+ + % + +
+
+ +
+ - % -
+
+ + +
+ +
+ +
- -
- - -
-
- - -
- -
- - -
-
+ {/if}
@@ -930,7 +947,7 @@ - CATALOGO DE ACTIVO FIJO + {isInventoryMode ? 'CATALOGO DE MATERIALES' : 'CATALOGO DE ACTIVO FIJO'}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts index 8081ff9d..e1eefbbd 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -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 + 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 = { + 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(); + } + + + + + {#snippet child({ props })} + + {/snippet} + + + +

Tus aplicaciones

+
+ {#each systemStore.allowedSystems as sys (sys)} + {@const label = SYSTEM_LABELS[sys]} + {@const Icon = ICONS[sys]} + {@const active = systemStore.activeSystem === sys} + {@const canSwitch = systemStore.canSwitch} + + {/each} +
+
+
diff --git a/frontend/src/lib/components/sidebar/app-sidebar.svelte b/frontend/src/lib/components/sidebar/app-sidebar.svelte index 92d92fd3..bb3f4855 100644 --- a/frontend/src/lib/components/sidebar/app-sidebar.svelte +++ b/frontend/src/lib/components/sidebar/app-sidebar.svelte @@ -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"; diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 85c98e3b..347ac491 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -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[]; } diff --git a/frontend/src/lib/components/sidebar/nav-main.svelte b/frontend/src/lib/components/sidebar/nav-main.svelte index d8bdf508..d8139101 100644 --- a/frontend/src/lib/components/sidebar/nav-main.svelte +++ b/frontend/src/lib/components/sidebar/nav-main.svelte @@ -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; diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index fb09ca23..4ec1b752 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -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, tenantOverride?: string) { +export function createAuthHeaders(token: string, additionalHeaders?: Record, 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 || {}) } - : createAuthHeaders(accessToken, options.headers as Record, tenantOverride); + ? { + 'Authorization': `Bearer ${accessToken}`, + ...(activeSystem ? { 'X-Active-System': activeSystem } : {}), + ...(options.headers as Record || {}) + } + : createAuthHeaders(accessToken, options.headers as Record, 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 || {}) } - : createAuthHeaders(newToken, options.headers as Record, tenantOverride); + ? { + 'Authorization': `Bearer ${newToken}`, + ...(activeSystem ? { 'X-Active-System': activeSystem } : {}), + ...(options.headers as Record || {}) + } + : createAuthHeaders(newToken, options.headers as Record, tenantOverride, activeSystem); response = await fetch(url, { ...options, diff --git a/frontend/src/lib/server/system-gate.ts b/frontend/src/lib/server/system-gate.ts new file mode 100644 index 00000000..8bc2238c --- /dev/null +++ b/frontend/src/lib/server/system-gate.ts @@ -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(['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 | 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; + } catch { + return null; + } +} + +/** Combina claims del JWT con la respuesta de /auth/me (Hub). */ +export function mergeTokenClaims( + userData: Record | null | undefined, + accessToken: string +): Record { + 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 | 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( + 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 { + 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 | 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()); +} diff --git a/frontend/src/lib/stores/system.svelte.ts b/frontend/src/lib/stores/system.svelte.ts new file mode 100644 index 00000000..c227f546 --- /dev/null +++ b/frontend/src/lib/stores/system.svelte.ts @@ -0,0 +1,70 @@ +export type SystemType = 'fixed_asset' | 'inventory'; + +export const SYSTEM_LABELS: Record = { + fixed_asset: { name: 'Módulo de Activo Fijo', code: 'SCAF' }, + inventory: { name: 'Módulo Control de Inventarios', code: 'SCAII' }, +}; + +const VALID_SYSTEMS = new Set(['fixed_asset', 'inventory']); + +class SystemStore { + _activeSystem = $state(null); + _allowedSystems = $state([]); + _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 { + 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(); diff --git a/frontend/src/routes/api-sveltekit/system/set-active/+server.ts b/frontend/src/routes/api-sveltekit/system/set-active/+server.ts new file mode 100644 index 00000000..e8ecb5b2 --- /dev/null +++ b/frontend/src/routes/api-sveltekit/system/set-active/+server.ts @@ -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 }); + } +}; diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index 9a5863a5..de466ea3 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -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'); }; diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index 68bdd38c..1669f3dc 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -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 }; diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 4c04a394..d5984901 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -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 @@
-
+ {#if systemStore.allowedSystems.length > 0} +
+ +
+ {/if}
{ companies: data.companies, authenticated: data.authenticated, userTenants: data.userTenants ?? [], - activeCompanyId: data.activeCompanyId + activeCompanyId: data.activeCompanyId, + activeSystem: data.activeSystem ?? null, + allowedSystems: data.allowedSystems ?? [] }; }; diff --git a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte index eb994e0d..abf8562c 100644 --- a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte +++ b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte @@ -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([]); let status = $state(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 @@
-

Clases de Activo Fijo

-

Gestiona y consulta las clases de activo fijo

+

+ {isInventoryMode ? 'Clases de Inventario' : 'Clases de Activo Fijo'} +

+

+ {isInventoryMode + ? 'Gestiona y consulta las clases del sistema de inventario' + : 'Gestiona y consulta las clases de activo fijo'} +