From da088ef6e62b07eee5ee55878435d672b71cae90 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 8 May 2026 15:23:45 -0600 Subject: [PATCH 01/11] feature/correccion-de-actualizar --- .../invoices/imports/process/sub_process/review_classes.py | 6 +++--- backend/tests/fixtures/builders.py | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_classes.py b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_classes.py index db009bd6..935bbca3 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_classes.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_classes.py @@ -138,7 +138,7 @@ def review_classes( 3a. If no class errors: validate fractions for ALL lines. 3b. If class errors exist: add a CLASE error per invalid line and validate fractions only for those lines. - 4. If the invoice has not been reviewed by the company, flag lines whose + 4. If invoice logistics does not mark equipment_reviewed, flag lines whose class requires physical review (Class.physical_review == 1). """ # 1. Lines whose class is not assigned / not found in the catalog @@ -178,7 +178,7 @@ def review_classes( _validate_line_fraction(db, line, errors) # 4. Physical review check - if not invoice.compliance_mx.was_reviewed_by_company: + if not (invoice.logistics and invoice.logistics.equipment_reviewed): review_required_lines = ( db.query(LineItem) .join(Class, LineItem.class_id == Class.id) @@ -201,7 +201,7 @@ def review_classes( ), solution=[ f"Revisar el Equipo de la Partida: {line.line_number} " - "y Asignar Como Revisado a Nivel Factura." + "y marcar Equipo revisado en Continuación (logística) a nivel factura." ], code="CLASE", ) diff --git a/backend/tests/fixtures/builders.py b/backend/tests/fixtures/builders.py index 6486d36b..15cf6800 100644 --- a/backend/tests/fixtures/builders.py +++ b/backend/tests/fixtures/builders.py @@ -343,6 +343,7 @@ def create_import_invoice_with_line( tenant_id=tenant_id, company_id=company_id, weight_type="kgs", + equipment_reviewed=True, ) db.add(invoice) db.flush() @@ -413,6 +414,7 @@ def create_export_invoice_with_line( tenant_id=tenant_id, company_id=company_id, weight_type="kgs", + equipment_reviewed=True, ) db.add(invoice) db.flush() From 1dbc75107f9f8a12590591d581654d587db60ae0 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 8 May 2026 17:03:29 -0500 Subject: [PATCH 02/11] feat: enhance user avatar handling and synchronization with Workspace - Updated sidebar component to merge user data from Keycloak and Workspace, including avatar URLs. - Refactored nav-user component to utilize new avatar resolution logic and display user information more effectively. - Introduced a new utility function to resolve user avatar URLs, prioritizing Workspace avatars. - Implemented backend changes to support synchronization of user profile data, including avatar URLs from Workspace. - Added database migration to include new fields for Workspace profile synchronization in user_tenants. - Created a new client for fetching user profiles from Workspace. - Updated dashboard components to reflect changes in user data structure and avatar handling. - Removed avatar upload functionality from the profile update process, relying on Workspace for avatar management. - Added tests for avatar resolution logic to ensure correct prioritization of avatar sources. --- ...orkspace_profile_fields_to_user_tenants.py | 56 ++++ backend/api/v1/modules/core/auth/dto.py | 1 + backend/api/v1/modules/core/auth/service.py | 228 ++++++++++++++- .../api/v1/modules/core/user_tenant/models.py | 13 + backend/api/v1/modules/core/users/routes.py | 21 +- backend/api/v1/modules/core/users/service.py | 61 +++- backend/core/config.py | 16 +- backend/core/security.py | 15 + backend/core/workspace_profile_client.py | 80 +++++ backend/core/workspace_profile_sync.py | 114 ++++++++ frontend/src/lib/auth.ts | 273 ++++++++++++++++-- .../lib/components/sidebar/app-sidebar.svelte | 41 ++- .../lib/components/sidebar/nav-user.svelte | 64 +++- frontend/src/lib/server/api.ts | 46 ++- frontend/src/lib/utils.avatar.test.ts | 24 ++ .../src/lib/utils.getBackendAssetUrl.test.ts | 10 +- frontend/src/lib/utils.ts | 129 ++++++++- frontend/src/routes/auth/sso/+page.server.ts | 7 +- frontend/src/routes/dashboard/+layout.svelte | 11 + .../routes/dashboard/account/+page.server.ts | 37 +-- .../src/routes/dashboard/account/+page.svelte | 73 +---- 21 files changed, 1170 insertions(+), 150 deletions(-) create mode 100644 backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py create mode 100644 backend/core/workspace_profile_client.py create mode 100644 backend/core/workspace_profile_sync.py create mode 100644 frontend/src/lib/utils.avatar.test.ts diff --git a/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py b/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py new file mode 100644 index 00000000..7608f783 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py @@ -0,0 +1,56 @@ +"""add workspace profile fields to user_tenants + +Revision ID: c3d4e5f6a7b +Revises: b2c3d4e5f6a7 +Create Date: 2026-05-08 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "c3d4e5f6a7b" +down_revision: Union[str, None] = "b2c3d4e5f6a7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "user_tenants", + sa.Column( + "workspace_user_id", + sa.String(length=255), + nullable=True, + comment="User ID (sub) proveniente de Workspace", + ), + schema="core", + ) + op.add_column( + "user_tenants", + sa.Column( + "workspace_avatar_url", + sa.String(length=500), + nullable=True, + comment="Avatar URL sincronizado desde Workspace", + ), + schema="core", + ) + op.add_column( + "user_tenants", + sa.Column( + "workspace_profile_synced_at", + sa.DateTime(timezone=True), + nullable=True, + comment="Última sincronización de perfil con Workspace", + ), + schema="core", + ) + + +def downgrade() -> None: + op.drop_column("user_tenants", "workspace_profile_synced_at", schema="core") + op.drop_column("user_tenants", "workspace_avatar_url", schema="core") + op.drop_column("user_tenants", "workspace_user_id", schema="core") diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index 519bdd46..a9bad09d 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -63,6 +63,7 @@ class UserInfoResponseDTO(BaseModel): preferred_username: Optional[str] = None tenant_id: Optional[int] = None tenant_slug: Optional[str] = None + avatar_url: Optional[str] = None roles: list[str] = [] permissions: list[str] = [] diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index c8c013b2..b64f2eca 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -1,6 +1,7 @@ import logging import httpx -from typing import Any, Dict +from typing import Any, Dict, Optional +from jose import JWTError, jwt from core.config import settings from fastapi import HTTPException @@ -23,6 +24,96 @@ class AuthService: def __init__(self, db: Session): self.db = db + @staticmethod + def _clean_text(value: Any) -> Optional[str]: + if isinstance(value, str): + cleaned = value.strip() + if cleaned: + return cleaned + return None + + def _pick_text(self, *candidates: Any) -> Optional[str]: + for candidate in candidates: + value = self._clean_text(candidate) + if value: + return value + return None + + def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]: + try: + claims = jwt.get_unverified_claims(access_token) + return claims if isinstance(claims, dict) else {} + except JWTError: + return {} + except Exception: + return {} + + async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]: + """ + Fallback de datos de usuario consultando el Hub admin API. + Es opcional y no debe romper /me si falla. + """ + if not keycloak_user_id: + return None + if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD: + return None + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + login_resp = await client.post( + f"{settings.HUB_URL}api/v1/auth/login", + json={ + "username": settings.HUB_ADMIN_EMAIL, + "password": settings.HUB_ADMIN_PASSWORD, + }, + ) + if login_resp.status_code != 200: + return None + + admin_token = login_resp.json().get("access_token") + if not admin_token: + return None + + user_resp = await client.get( + f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + if user_resp.status_code == 200: + payload = user_resp.json() + return payload if isinstance(payload, dict) else None + except Exception as exc: + logger.debug("kc_admin_user_lookup_failed: %s", exc) + + return None + + def _extract_avatar_url(self, *sources: Any) -> Optional[str]: + for source in sources: + if not isinstance(source, dict): + continue + + direct = self._pick_text( + source.get("avatar_url"), + source.get("avatarUrl"), + source.get("picture"), + source.get("photo"), + ) + if direct: + return direct + + attrs = source.get("attributes") + if isinstance(attrs, dict): + attr_candidate = attrs.get("avatar_url") + if isinstance(attr_candidate, list) and attr_candidate: + value = self._clean_text(attr_candidate[0]) + if value: + return value + if isinstance(attr_candidate, str): + value = self._clean_text(attr_candidate) + if value: + return value + + return None + async def login( self, login_data: LoginRequestDTO, @@ -55,6 +146,38 @@ class AuthService: except Exception as exc: logger.warning("Lazy-link invite check failed (non-blocking): %s", exc) + # Sync de perfil/avatar desde Workspace usando el mismo bearer. + # No bloquea login si Workspace no responde. + access_token = data.get("access_token") + if access_token: + from core.workspace_profile_sync import sync_workspace_profile_for_user + from core.workspace_profile_client import WorkspaceProfileClient + + workspace_profile = None + try: + workspace_profile = await WorkspaceProfileClient().get_me(access_token) + except Exception as exc: + logger.warning( + "workspace_profile_sync_failed", + extra={ + "event": "workspace_profile_sync_failed", + "phase": "login", + "error": str(exc), + }, + ) + workspace_profile = None + + await sync_workspace_profile_for_user( + self.db, + access_token=access_token, + keycloak_user_id=(workspace_profile or {}).get("sub") + or data.get("sub") + or data.get("user_id"), + tenant_id=data.get("tenant_id"), + workspace_profile=workspace_profile, + force=True, + ) + # AUDIT LOG: Login Success try: from api.v1.modules.a76.audit_log.services.service import AuditService @@ -102,7 +225,37 @@ class AuthService: ) if response.status_code == 200: - return TokenResponseDTO(**response.json()) + data = response.json() + from core.workspace_profile_sync import sync_workspace_profile_for_user + from core.workspace_profile_client import WorkspaceProfileClient + + workspace_profile = None + try: + workspace_profile = await WorkspaceProfileClient().get_me( + data.get("access_token", "") + ) + except Exception as exc: + logger.warning( + "workspace_profile_sync_failed", + extra={ + "event": "workspace_profile_sync_failed", + "phase": "refresh", + "error": str(exc), + }, + ) + workspace_profile = None + + await sync_workspace_profile_for_user( + self.db, + access_token=data.get("access_token"), + keycloak_user_id=(workspace_profile or {}).get("sub") + or data.get("sub") + or data.get("user_id"), + tenant_id=data.get("tenant_id"), + workspace_profile=workspace_profile, + force=True, + ) + return TokenResponseDTO(**data) raise HTTPException(status_code=401, detail="Invalid or expired refresh token") @@ -115,9 +268,78 @@ class AuthService: Obtiene información del usuario desde el Hub """ from core.security import verify_token + from core.workspace_profile_sync import sync_workspace_profile_for_user # Aprovechamos la verificación (y cache) de security.py user_info = await verify_token(access_token) - return UserInfoResponseDTO(**user_info) + + kc_user = self._decode_kc_user_from_token(access_token) + keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub")) + + needs_admin_fallback = any( + not self._clean_text(user_info.get(field)) + for field in ("email", "preferred_username") + ) or self._extract_avatar_url(user_info) is None + + kc_admin_user = None + if needs_admin_fallback: + kc_admin_user = await self._get_kc_admin_user(keycloak_user_id) + + first_name = self._pick_text( + user_info.get("first_name"), + user_info.get("given_name"), + kc_user.get("given_name"), + kc_user.get("first_name"), + (kc_admin_user or {}).get("firstName"), + (kc_admin_user or {}).get("first_name"), + ) + last_name = self._pick_text( + user_info.get("last_name"), + user_info.get("family_name"), + kc_user.get("family_name"), + kc_user.get("last_name"), + (kc_admin_user or {}).get("lastName"), + (kc_admin_user or {}).get("last_name"), + ) + full_name = self._pick_text( + f"{first_name} {last_name}" if first_name and last_name else None, + first_name, + last_name, + ) + + enriched_user_info = dict(user_info) + enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub") + enriched_user_info["email"] = self._pick_text( + user_info.get("email"), + (kc_admin_user or {}).get("email"), + kc_user.get("email"), + ) + enriched_user_info["preferred_username"] = self._pick_text( + user_info.get("preferred_username"), + user_info.get("username"), + kc_user.get("preferred_username"), + kc_user.get("username"), + (kc_admin_user or {}).get("username"), + ) + enriched_user_info["avatar_url"] = self._extract_avatar_url( + user_info, + kc_user, + kc_admin_user or {}, + ) + enriched_user_info["name"] = self._pick_text( + user_info.get("name"), + full_name, + kc_user.get("name"), + enriched_user_info.get("preferred_username"), + ) + + await sync_workspace_profile_for_user( + self.db, + access_token=access_token, + keycloak_user_id=enriched_user_info.get("sub"), + tenant_id=enriched_user_info.get("tenant_id"), + workspace_profile=enriched_user_info, + ) + return UserInfoResponseDTO(**enriched_user_info) async def logout(self, logout_data: LogoutRequestDTO) -> dict: """ diff --git a/backend/api/v1/modules/core/user_tenant/models.py b/backend/api/v1/modules/core/user_tenant/models.py index 33138d6e..d25c00be 100644 --- a/backend/api/v1/modules/core/user_tenant/models.py +++ b/backend/api/v1/modules/core/user_tenant/models.py @@ -2,6 +2,7 @@ Modelo de relación entre usuarios (Keycloak) y tenants """ +from datetime import datetime from typing import TYPE_CHECKING, Optional from api.v1.common.base_models import TenantScopedMixin, TimestampMixin @@ -13,6 +14,7 @@ from sqlalchemy import ( String, Text, UniqueConstraint, + DateTime, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -56,6 +58,17 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin): avatar_url: Mapped[Optional[str]] = mapped_column( String(500), nullable=True, comment="URL de la imagen de perfil" ) + workspace_user_id: Mapped[Optional[str]] = mapped_column( + String(255), nullable=True, comment="User ID (sub) proveniente de Workspace" + ) + workspace_avatar_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True, comment="Avatar URL sincronizado desde Workspace" + ) + workspace_profile_synced_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="Última sincronización de perfil con Workspace", + ) # Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub; # se sincroniza al editar perfil desde Anexo76) first_name: Mapped[Optional[str]] = mapped_column( diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index b6dfbe7a..8856d43d 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -154,6 +154,7 @@ def get_user_avatar_image( @router.get("/me/profile", response_model=UserResponseDTO) async def get_my_profile( + request: Request, current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db), ): @@ -180,7 +181,17 @@ async def get_my_profile( ) service = UserService(db, user_tenant.tenant_id, user_tenant.company_id) - return await service.get_current_user_profile(keycloak_user_id, current_user=current_user) + auth_header = request.headers.get("Authorization") or "" + access_token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() or None + ) + return await service.get_current_user_profile( + keycloak_user_id, + current_user=current_user, + access_token=access_token, + ) @router.put("/me/profile", response_model=UserResponseDTO) @@ -214,6 +225,14 @@ async def update_my_profile( status_code=400, detail="User does not belong to any tenant" ) + # Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub + # y no debe mutarse localmente en Anexo76. + if current_user.get("sub"): + raise HTTPException( + status_code=409, + detail="Avatar is managed by Workspace for this user", + ) + auth_header = request.headers.get("Authorization") or "" access_token = ( auth_header[7:].strip() diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index 961e4bfa..9d7f1b4b 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -2,6 +2,7 @@ import logging import httpx from datetime import datetime from typing import Any, Dict, List, Optional +from urllib.parse import urlparse from fastapi import HTTPException from sqlalchemy import and_, func @@ -15,6 +16,30 @@ from ..user_tenant.models import UserTenant logger = logging.getLogger(__name__) +def _is_valid_http_url(url: Optional[str]) -> bool: + if not url or not isinstance(url, str): + return False + parsed = urlparse(url.strip()) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + +def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]: + if not user_tenant or not user_tenant.avatar_url: + return None + avatar_out = str(user_tenant.avatar_url) + + if avatar_out.startswith("http://") or avatar_out.startswith("https://"): + return avatar_out if _is_valid_http_url(avatar_out) else None + + from core.s3_keys import public_user_avatar_api_path + + # Entregamos siempre el endpoint público del backend para assets locales/S3. + return public_user_avatar_api_path( + user_tenant.tenant_id, + user_tenant.keycloak_user_id, + ) + + def _normalize_user( user_data: Dict[str, Any], role: Optional[str] = None, @@ -43,18 +68,20 @@ def _normalize_user( # Agregar campos de perfil si user_tenant está disponible if user_tenant: - avatar_out = user_tenant.avatar_url - if avatar_out: - from core.s3_keys import public_user_avatar_api_path + workspace_avatar = ( + user_tenant.workspace_avatar_url + if _is_valid_http_url(user_tenant.workspace_avatar_url) + else None + ) + legacy_avatar = _legacy_avatar_public_url(user_tenant) + avatar_out = workspace_avatar or legacy_avatar - # Siempre devolver la URL pública del endpoint de servicio de imágenes, - # independientemente de si es clave S3 (tenants/...) o ruta local (/uploads/...). - avatar_out = public_user_avatar_api_path( - user_tenant.tenant_id, user_tenant.keycloak_user_id - ) normalized.update( { "avatar_url": avatar_out, + "workspace_avatar_url": workspace_avatar, + "legacy_avatar_url": legacy_avatar, + "workspace_user_id": user_tenant.workspace_user_id, "phone": user_tenant.phone, "bio": user_tenant.bio, "preferences": user_tenant.preferences or {}, @@ -629,11 +656,27 @@ class UserService: "usage_percentage": round(usage_percentage, 2), } - async def get_current_user_profile(self, keycloak_user_id: str, current_user: Dict[str, Any] = None) -> Dict[str, Any]: + async def get_current_user_profile( + self, + keycloak_user_id: str, + current_user: Dict[str, Any] = None, + access_token: Optional[str] = None, + ) -> Dict[str, Any]: """Obtiene el perfil completo del usuario actual""" # Use the already-verified JWT claims dict — do NOT call verify_token(uuid) user_info = current_user or {"id": keycloak_user_id} + # Perfil "me": sincronización con cache corto (5 min). + if access_token: + from core.workspace_profile_sync import sync_workspace_profile_for_user + + await sync_workspace_profile_for_user( + self.db, + access_token=access_token, + keycloak_user_id=keycloak_user_id, + tenant_id=self.tenant_id, + ) + user_tenant = self.db.query(UserTenant).filter( and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True) ).first() diff --git a/backend/core/config.py b/backend/core/config.py index 68755842..752933e1 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -41,6 +41,9 @@ class Settings(BaseSettings): # Hub de Aduanasoft — requerido siempre (SaaS y self-hosted) HUB_URL: str = "http://localhost:8001" + # Base API del Hub/Workspace para endpoint /v1/auth/me (fuente de verdad de perfil) + HUB_API_BASE_URL: str = "" + HUB_PROFILE_SYNC_TIMEOUT_MS: int = 3000 # Cuenta de servicio Hub — usada para operaciones admin (ej. sync de nombre a Keycloak) HUB_ADMIN_EMAIL: str = "" HUB_ADMIN_PASSWORD: str = "" @@ -48,7 +51,7 @@ class Settings(BaseSettings): # URL pública del frontend — usada en links de email (invitaciones, etc.) APP_PUBLIC_URL: str = "http://localhost:3000" - @field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", mode="before") + @field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before") @classmethod def strip_quotes(cls, v: str) -> str: if v and isinstance(v, str): @@ -116,6 +119,17 @@ class Settings(BaseSettings): """ return self.CSV_IMPORT_STORAGE == "minio" or self.S3_FILE_STORAGE + @property + def hub_api_base_url(self) -> str: + """ + Base URL para endpoints /v1 del Workspace/Hub. + Si HUB_API_BASE_URL no está definido, deriva de HUB_URL + /api. + """ + custom = (self.HUB_API_BASE_URL or "").strip().rstrip("/") + if custom: + return custom + return f"{self.HUB_URL.rstrip('/')}/api" + # Instancia global de configuración settings = Settings() diff --git a/backend/core/security.py b/backend/core/security.py index 328ebdce..353eea9c 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -344,6 +344,21 @@ async def get_current_user( ) user_info["tenant_id"] = effective_tenant_id + # Rehidratación de sesión: sincronización no bloqueante de avatar/perfil + # con cache corto para evitar llamadas excesivas al Hub. + try: + from core.workspace_profile_sync import sync_workspace_profile_for_user + + await sync_workspace_profile_for_user( + db, + access_token=token, + keycloak_user_id=user_info.get("sub"), + tenant_id=user_info.get("tenant_id"), + workspace_profile=user_info, + ) + except Exception as exc: + logger.warning("workspace_profile_sync_failed_on_get_current_user: %s", exc) + return user_info diff --git a/backend/core/workspace_profile_client.py b/backend/core/workspace_profile_client.py new file mode 100644 index 00000000..bcc31d7e --- /dev/null +++ b/backend/core/workspace_profile_client.py @@ -0,0 +1,80 @@ +import asyncio +import logging +from typing import Any, Optional + +import httpx + +from core.config import settings + +logger = logging.getLogger(__name__) + + +class WorkspaceProfileClient: + """Cliente para consultar perfil del usuario en Workspace Hub (/v1/auth/me).""" + + def __init__( + self, + base_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + retries: int = 2, + transport: Optional[httpx.BaseTransport] = None, + ): + self.base_url = (base_url or settings.hub_api_base_url).rstrip("/") + self.timeout_s = max(0.1, float(timeout_ms or settings.HUB_PROFILE_SYNC_TIMEOUT_MS) / 1000.0) + self.retries = max(0, int(retries)) + self.transport = transport + + async def get_me(self, access_token: str) -> dict[str, Any]: + if not access_token: + raise ValueError("access_token is required") + + headers = {"Authorization": f"Bearer {access_token}"} + url = f"{self.base_url}/v1/auth/me" + + last_error: Optional[Exception] = None + for attempt in range(self.retries + 1): + try: + async with httpx.AsyncClient( + timeout=self.timeout_s, + transport=self.transport, + ) as client: + response = await client.get(url, headers=headers) + + if response.status_code == 200: + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("Invalid workspace profile payload") + return payload + + if response.status_code in (401, 403, 404): + # Errores de autenticación/autorización o endpoint no disponible: + # no vale la pena reintentar. + raise httpx.HTTPStatusError( + f"Workspace profile request failed with status {response.status_code}", + request=response.request, + response=response, + ) + + # Reintentar solo para errores transitorios 5xx. + if response.status_code >= 500 and attempt < self.retries: + await asyncio.sleep(0.15 * (attempt + 1)) + continue + + raise httpx.HTTPStatusError( + f"Workspace profile request failed with status {response.status_code}", + request=response.request, + response=response, + ) + + except (httpx.TimeoutException, httpx.NetworkError) as exc: + last_error = exc + if attempt >= self.retries: + break + await asyncio.sleep(0.15 * (attempt + 1)) + except Exception as exc: + last_error = exc + break + + if last_error: + raise last_error + raise RuntimeError("Workspace profile request failed") diff --git a/backend/core/workspace_profile_sync.py b/backend/core/workspace_profile_sync.py new file mode 100644 index 00000000..9f34e899 --- /dev/null +++ b/backend/core/workspace_profile_sync.py @@ -0,0 +1,114 @@ +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Optional +from urllib.parse import urlparse + +from sqlalchemy.orm import Session + +from api.v1.modules.core.user_tenant.models import UserTenant +from core.workspace_profile_client import WorkspaceProfileClient + +logger = logging.getLogger(__name__) + +SYNC_TTL_SECONDS = 300 + + +def _is_valid_http_url(url: Optional[str]) -> bool: + if not url or not isinstance(url, str): + return False + parsed = urlparse(url.strip()) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + +def _is_fresh(ts: Optional[datetime], ttl_seconds: int = SYNC_TTL_SECONDS) -> bool: + if not ts: + return False + now = datetime.now(timezone.utc) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + return ts >= (now - timedelta(seconds=ttl_seconds)) + + +async def sync_workspace_profile_for_user( + db: Session, + *, + access_token: Optional[str], + keycloak_user_id: Optional[str], + tenant_id: Optional[int] = None, + company_id: Optional[int] = None, + workspace_profile: Optional[dict[str, Any]] = None, + force: bool = False, +) -> None: + """ + Sincroniza sub/avatar_url desde Workspace hacia core.user_tenants. + Nunca lanza excepción para no bloquear login ni requests autenticados. + """ + if not access_token or not keycloak_user_id: + return + + try: + query = db.query(UserTenant).filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + if tenant_id is not None: + query = query.filter(UserTenant.tenant_id == int(tenant_id)) + if company_id is not None: + query = query.filter(UserTenant.company_id == int(company_id)) + + target = query.first() + if not target: + return + + if not force and _is_fresh(target.workspace_profile_synced_at): + return + + payload = workspace_profile + if payload is None: + client = WorkspaceProfileClient() + payload = await client.get_me(access_token) + + workspace_sub = payload.get("sub") + if not workspace_sub: + logger.warning( + "workspace_profile_sync_warning", + extra={ + "event": "workspace_profile_sync_warning", + "reason": "missing_sub", + "keycloak_user_id": keycloak_user_id, + "tenant_id": target.tenant_id, + }, + ) + return + + avatar_url = payload.get("avatar_url") + sanitized_avatar = avatar_url.strip() if isinstance(avatar_url, str) else None + if sanitized_avatar and not _is_valid_http_url(sanitized_avatar): + logger.warning( + "workspace_profile_sync_warning", + extra={ + "event": "workspace_profile_sync_warning", + "reason": "invalid_avatar_url", + "keycloak_user_id": keycloak_user_id, + "tenant_id": target.tenant_id, + }, + ) + sanitized_avatar = None + + target.workspace_user_id = str(workspace_sub) + target.workspace_avatar_url = sanitized_avatar + target.workspace_profile_synced_at = datetime.now(timezone.utc) + db.add(target) + db.commit() + except Exception as exc: + db.rollback() + logger.warning( + "workspace_profile_sync_failed", + extra={ + "event": "workspace_profile_sync_failed", + "error": str(exc), + "keycloak_user_id": keycloak_user_id, + "tenant_id": tenant_id, + "company_id": company_id, + }, + ) diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 490d7c7b..c91b5047 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -9,7 +9,7 @@ */ import Keycloak from 'keycloak-js'; -import { writable, derived } from 'svelte/store'; +import { writable, derived, get } from 'svelte/store'; import { browser } from '$app/environment'; import { clearAccessTokenOnDocument, @@ -26,9 +26,17 @@ export interface User { username: string; email?: string; name?: string; + firstName?: string | null; + lastName?: string | null; + displayName?: string | null; + avatarUrl?: string | null; + workspaceAvatarUrl?: string | null; + legacyAvatarUrl?: string | null; tenantId?: number; roles: string[]; permissions: string[]; + // Cache management + profileSyncedAt?: number; // timestamp en ms para cache TTL } export interface AuthState { @@ -49,6 +57,51 @@ const keycloakConfig = { }; let keycloakInstance: Keycloak | null = null; +const AUTH_USER_SESSION_KEY = 'anexo76_auth_user_v1'; + +function pickAvatar(...candidates: Array): string | null { + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; +} + +function pickText(...candidates: Array): string | null { + for (const candidate of candidates) { + if (typeof candidate === 'string') { + const value = candidate.trim(); + if (value.length > 0) { + return value; + } + } + } + return null; +} + +function readUserFromSession(): User | null { + if (!browser) return null; + try { + const raw = sessionStorage.getItem(AUTH_USER_SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as User; + if (!parsed || typeof parsed !== 'object') return null; + if (!parsed.id || !parsed.username) return null; + return parsed; + } catch { + return null; + } +} + +function persistUserInSession(user: User | null): void { + if (!browser) return; + if (!user) { + sessionStorage.removeItem(AUTH_USER_SESSION_KEY); + return; + } + sessionStorage.setItem(AUTH_USER_SESSION_KEY, JSON.stringify(user)); +} // ───────────────────────────────────────────────────────── // Auth store (tokens solo en memoria) @@ -68,7 +121,10 @@ const createAuthStore = () => { update((s) => ({ ...s, isAuthenticated: authenticated })), setLoading: (loading: boolean) => update((s) => ({ ...s, isLoading: loading })), - setUser: (user: User | null) => update((s) => ({ ...s, user })), + setUser: (user: User | null) => { + persistUserInSession(user); + update((s) => ({ ...s, user })); + }, setToken: (token: string | null) => update((s) => ({ ...s, token })), /** ⚠️ Los tokens ya NO se guardan en localStorage; solo en memoria. */ setTokens: (accessToken: string, _refreshToken?: string) => { @@ -77,12 +133,15 @@ const createAuthStore = () => { // el cliente no lo almacena ni lo lee en ningún momento. }, reset: () => - set({ - isAuthenticated: false, - isLoading: false, - user: null, - token: null - }) + { + persistUserInSession(null); + set({ + isAuthenticated: false, + isLoading: false, + user: null, + token: null + }); + } }; }; @@ -114,6 +173,11 @@ export const initAuth = async (): Promise => { try { authStore.setLoading(true); + const sessionUser = readUserFromSession(); + if (sessionUser) { + authStore.setUser(sessionUser); + } + // Restaurar token desde cookie no-HttpOnly (password login flow) const cookieToken = getAccessTokenFromDocument(); if (cookieToken) { @@ -166,6 +230,7 @@ let previousTenantId: number | undefined = undefined; const updateAuthState = async () => { if (!keycloakInstance?.authenticated) { authStore.reset(); + persistUserInSession(null); return; } @@ -189,14 +254,43 @@ const updateAuthState = async () => { currentPerms = currentState.user?.permissions || []; } catch { } + const previousUser = get(authStore).user; + const firstName = pickText(profile.firstName, parsed?.given_name, previousUser?.firstName); + const lastName = pickText(profile.lastName, parsed?.family_name, previousUser?.lastName); + const fullNameFromParts = pickText( + firstName && lastName ? `${firstName} ${lastName}` : null, + firstName, + lastName + ); + const username = pickText( + profile.username, + parsed?.preferred_username, + parsed?.username, + previousUser?.username + ) ?? ''; + const name = pickText( + profile.firstName || profile.lastName ? `${profile.firstName ?? ''} ${profile.lastName ?? ''}` : null, + fullNameFromParts, + parsed?.name, + previousUser?.name, + username + ) ?? username; + const user: User = { - id: profile.id ?? '', - username: profile.username ?? '', - email: profile.email, - name: `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim(), + id: pickText(profile.id, parsed?.sub, previousUser?.id) ?? '', + username, + email: pickText(profile.email, parsed?.email, previousUser?.email) ?? undefined, + name, + firstName, + lastName, + displayName: pickText(name, previousUser?.displayName, username), + avatarUrl: pickAvatar(previousUser?.avatarUrl), + workspaceAvatarUrl: pickAvatar(previousUser?.workspaceAvatarUrl), + legacyAvatarUrl: pickAvatar(previousUser?.legacyAvatarUrl), tenantId, roles, - permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms + permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms, + profileSyncedAt: previousUser?.profileSyncedAt }; authStore.setAuthenticated(true); @@ -338,15 +432,74 @@ const loadUserInfo = async (token: string) => { const { api } = await import('./api'); const response = await api.auth.me(); if (response.data) { + const previousUser = get(authStore).user; const d = response.data; + const workspaceAvatarUrl = pickAvatar( + d.workspaceAvatarUrl, + d.workspace_avatar_url, + d.avatar_url, + d.avatarUrl, + d.picture, + d.photo, + previousUser?.workspaceAvatarUrl + ); + const legacyAvatarUrl = pickAvatar( + d.legacyAvatarUrl, + d.legacy_avatar_url, + d.avatar, + d.photo, + d.picture, + previousUser?.legacyAvatarUrl + ); + const avatarUrl = pickAvatar(workspaceAvatarUrl, legacyAvatarUrl, previousUser?.avatarUrl); + + // Merge no destructivo: nunca pisar datos válidos con campos vacíos + const firstName = pickText(d.first_name, d.firstName, previousUser?.firstName); + const lastName = pickText(d.last_name, d.lastName, previousUser?.lastName); + const username = pickText( + d.preferred_username, + d.username, + previousUser?.username + ) ?? ''; + const nameFromParts = pickText( + firstName && lastName ? `${firstName} ${lastName}` : null, + firstName, + lastName + ); + const name = pickText( + d.name, + nameFromParts, + previousUser?.name, + username + ) ?? username; + const displayName = pickText( + d.displayName, + d.display_name, + name, + username + ) ?? username; + const email = pickText(d.email, previousUser?.email) ?? undefined; + const userId = pickText(d.sub, d.id, previousUser?.id) ?? ''; + + console.debug('[avatar][auth.loadUserInfo] /v1/auth/me avatar_url recibido:', workspaceAvatarUrl ?? '(null)'); + console.debug('[avatar][auth.loadUserInfo] avatar final para authStore:', avatarUrl ?? '(null)'); + console.debug('[profile][auth.loadUserInfo] first_name:', firstName ?? '(null)', 'last_name:', lastName ?? '(null)', 'avatar_url:', workspaceAvatarUrl ?? '(null)'); + authStore.setUser({ - id: d.sub ?? '', - username: d.preferred_username ?? d.username ?? '', - email: d.email, - name: d.name, - tenantId: d.tenant_id, - roles: d.roles ?? [], - permissions: d.permissions ?? [] + id: userId, + username, + email, + name, + firstName, + lastName, + displayName, + avatarUrl, + workspaceAvatarUrl, + legacyAvatarUrl, + tenantId: d.tenant_id ?? previousUser?.tenantId, + roles: d.roles ?? previousUser?.roles ?? [], + permissions: d.permissions ?? previousUser?.permissions ?? [], + profileSyncedAt: Date.now() }); } } catch (err) { @@ -354,6 +507,85 @@ const loadUserInfo = async (token: string) => { } }; +/** + * Sincroniza el perfil del usuario desde /v1/auth/me + * - Valida cache TTL (5 minutos) antes de hacer fetch + * - Extrae first_name, last_name, avatar_url + * - Retorna objeto con campos de perfil para UI o update de store + * + * Uso: + * ``` + * const profile = await syncUserProfile(accessToken); + * if (profile) { + * // profile.firstName, profile.lastName, profile.displayName, profile.avatarUrl + * } + * ``` + */ +export const syncUserProfile = async (accessToken?: string): Promise<{ + firstName: string | null; + lastName: string | null; + displayName: string | null; + avatarUrl: string | null; + workspaceAvatarUrl: string | null; + legacyAvatarUrl: string | null; + rawProfileSyncedAt: number; +} | null> => { + if (!browser) return null; + + try { + // Validar cache TTL: 5 minutos (300000ms) + const PROFILE_CACHE_TTL = 5 * 60 * 1000; + const { get } = await import('svelte/store'); + const currentState = get(authStore); + const now = Date.now(); + + if ( + currentState.user?.profileSyncedAt && + (now - currentState.user.profileSyncedAt) < PROFILE_CACHE_TTL + ) { + console.debug('[profile][sync] Cache válido, no re-fetching /v1/auth/me'); + return { + firstName: currentState.user.firstName ?? null, + lastName: currentState.user.lastName ?? null, + displayName: currentState.user.displayName ?? null, + avatarUrl: currentState.user.avatarUrl ?? null, + workspaceAvatarUrl: currentState.user.workspaceAvatarUrl ?? null, + legacyAvatarUrl: currentState.user.legacyAvatarUrl ?? null, + rawProfileSyncedAt: currentState.user.profileSyncedAt + }; + } + + const token = accessToken || getToken(); + if (!token) { + console.warn('[profile][sync] No token disponible para sincronizar'); + return null; + } + + // Llamar a loadUserInfo que hace fetch a /v1/auth/me + await loadUserInfo(token); + + // Retornar los nuevos valores desde el store + const updatedState = get(authStore); + if (updatedState.user) { + console.debug('[profile][sync] Perfil sincronizado exitosamente'); + return { + firstName: updatedState.user.firstName ?? null, + lastName: updatedState.user.lastName ?? null, + displayName: updatedState.user.displayName ?? null, + avatarUrl: updatedState.user.avatarUrl ?? null, + workspaceAvatarUrl: updatedState.user.workspaceAvatarUrl ?? null, + legacyAvatarUrl: updatedState.user.legacyAvatarUrl ?? null, + rawProfileSyncedAt: updatedState.user.profileSyncedAt ?? 0 + }; + } + + return null; + } catch (err) { + console.error('[profile][sync] Error sincronizando perfil:', err); + return null; + } +}; + // ───────────────────────────────────────────────────────── // Logout // ───────────────────────────────────────────────────────── @@ -376,6 +608,7 @@ export const logout = async () => { // Limpiar estado en memoria authStore.reset(); + persistUserInSession(null); clearAccessTokenOnDocument(); // La cookie HttpOnly del refresh_token la limpia el servidor diff --git a/frontend/src/lib/components/sidebar/app-sidebar.svelte b/frontend/src/lib/components/sidebar/app-sidebar.svelte index b9774567..92d92fd3 100644 --- a/frontend/src/lib/components/sidebar/app-sidebar.svelte +++ b/frontend/src/lib/components/sidebar/app-sidebar.svelte @@ -3,6 +3,7 @@ import { page } from "$app/state"; import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js"; import { getSidebarData } from "$lib/components/sidebar/modules"; + import { currentUser } from "$lib/auth"; import NavMain from "./nav-main.svelte"; import NavProjects from "./nav-projects.svelte"; import NavUser from "./nav-user.svelte"; @@ -21,19 +22,51 @@ // Obtener datos del sidebar con traducciones const sidebarData = getSidebarData(); + const mergedUser = $derived((page.data.user as any) || $currentUser || null); // Combinar los datos estáticos del sidebar con los datos del usuario de Keycloak const data = $derived({ ...sidebarData, - user: page.data.user + user: mergedUser ? { - name: _displayName(page.data.user), - email: page.data.user.email || "", - avatar: page.data.user.avatar_url || "/avatars/default.jpg", + name: _displayName(mergedUser), + email: mergedUser.email || "", + username: mergedUser.preferred_username || mergedUser.username || "", + firstName: mergedUser.first_name || mergedUser.firstName || mergedUser.given_name || null, + lastName: mergedUser.last_name || mergedUser.lastName || mergedUser.family_name || null, + displayName: + mergedUser.displayName || + _displayName(mergedUser) || + mergedUser.preferred_username || + mergedUser.username || + "", + avatarUrl: + mergedUser.workspaceAvatarUrl || + mergedUser.workspace_avatar_url || + mergedUser.avatarUrl || + mergedUser.avatar_url || + mergedUser.legacyAvatarUrl || + mergedUser.legacy_avatar_url || + null, + workspaceAvatarUrl: + mergedUser.workspaceAvatarUrl || + mergedUser.workspace_avatar_url || + null, + legacyAvatarUrl: + mergedUser.legacyAvatarUrl || + mergedUser.legacy_avatar_url || + mergedUser.avatar_url || + null, } : sidebarData.user, }); + $effect(() => { + if (mergedUser) { + console.debug('[avatar][sidebar] avatar final en page.data.user:', data.user?.avatarUrl ?? '(null)'); + } + }); + function _displayName(u: any): string { const first = u.first_name || u.given_name || ""; const last = u.last_name || u.family_name || ""; diff --git a/frontend/src/lib/components/sidebar/nav-user.svelte b/frontend/src/lib/components/sidebar/nav-user.svelte index ece25af4..bd73c6b3 100644 --- a/frontend/src/lib/components/sidebar/nav-user.svelte +++ b/frontend/src/lib/components/sidebar/nav-user.svelte @@ -19,11 +19,21 @@ import { page } from '$app/state'; import { goto } from '$app/navigation'; import { browser } from '$app/environment'; - import { getBackendAssetUrl } from '$lib/utils'; + import { resolveUserAvatarUrl } from '$lib/utils'; import AppVersion from '$lib/components/app-version.svelte'; let { user, tenants = [] }: { - user: { name: string; email: string; avatar: string }; + user: { + name: string; + email: string; + username?: string; + firstName?: string | null; + lastName?: string | null; + displayName?: string | null; + avatarUrl?: string | null; + workspaceAvatarUrl?: string | null; + legacyAvatarUrl?: string | null; + }; tenants: { id: number; name: string; slug: string }[]; } = $props(); const sidebar = useSidebar(); @@ -31,14 +41,40 @@ // Tenant activo (viene en el JWT como atributo tenant_slug) let currentTenantSlug = $derived((page.data.user as any)?.tenant_slug ?? ''); - // Estado de cambio de tenant + // State for tenant switching let switchingTenant = $state(false); - // URL completa del avatar - let avatarUrl = $derived(getBackendAssetUrl(user.avatar) || '/avatars/default.jpg'); + let avatarLoadFailed = $state(false); - // Iniciales del usuario (2 primeras letras) - let initials = $derived(user.name.slice(0, 2).toUpperCase()); + // URL de avatar con prioridad: Workspace -> legado + let avatarUrl = $derived( + avatarLoadFailed + ? '' + : resolveUserAvatarUrl( + user.workspaceAvatarUrl ?? null, + user.legacyAvatarUrl ?? user.avatarUrl ?? null + ) + ); + + $effect(() => { + console.debug('[avatar][nav-user] URL final usada por Avatar.Image:', avatarUrl || '(fallback)'); + }); + + // Nombre a mostrar: displayName > firstName + lastName > name > username + let displayName = $derived( + user.displayName || + (user.firstName && user.lastName ? `${user.firstName} ${user.lastName}`.trim() : null) || + user.name || + user.username || + 'User' + ); + + // Iniciales del usuario (2 primeras letras de displayName) + let initials = $derived(displayName.slice(0, 2).toUpperCase()); + + function handleAvatarError() { + avatarLoadFailed = true; + } // Estado reactivo del idioma actual let currentLocale = $derived(page.data.locale || 'en'); @@ -142,11 +178,11 @@ {...props} > - + {initials}
- {user.name} + {displayName} {user.email}
@@ -162,11 +198,11 @@
- - {initials} - -
- {user.name} + + {initials} + +
+ {displayName} {user.email}
diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index 190f44ca..3cd200c9 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -255,6 +255,15 @@ export async function validateAuth( fetch: typeof globalThis.fetch, redirectOnFail?: string ): Promise { + const pickAvatar = (...candidates: Array): string | null => { + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; + }; + try { const response = await authenticatedFetch( 'v1/auth/me', @@ -273,6 +282,13 @@ export async function validateAuth( } const keycloakData = await response.json(); + const workspaceAvatarFromAuthMe = pickAvatar( + keycloakData.avatar_url, + keycloakData.avatarUrl, + keycloakData.picture, + keycloakData.photo + ); + console.debug('[avatar][validateAuth] /v1/auth/me avatar_url recibido:', workspaceAvatarFromAuthMe ?? '(null)'); // Obtener perfil adicional del usuario (avatar, bio, etc.) try { @@ -285,6 +301,23 @@ export async function validateAuth( if (profileResponse.ok) { const profileData = await profileResponse.json(); + const workspaceAvatarFromProfile = pickAvatar( + profileData.workspaceAvatarUrl, + profileData.workspace_avatar_url + ); + const legacyAvatar = pickAvatar( + profileData.legacyAvatarUrl, + profileData.legacy_avatar_url, + profileData.avatarUrl, + profileData.avatar_url, + profileData.avatar, + profileData.photo, + profileData.picture + ); + const finalWorkspaceAvatar = pickAvatar(workspaceAvatarFromAuthMe, workspaceAvatarFromProfile); + const finalAvatar = pickAvatar(finalWorkspaceAvatar, legacyAvatar); + console.debug('[avatar][validateAuth] avatar final resuelto:', finalAvatar ?? '(null)'); + // Combinar datos de Keycloak con datos del perfil. // Prioridad para nombre: caché local del perfil > JWT claims. return { @@ -294,7 +327,12 @@ export async function validateAuth( email: profileData.email || keycloakData.email || '', first_name: profileData.first_name || keycloakData.first_name || keycloakData.given_name || '', last_name: profileData.last_name || keycloakData.last_name || keycloakData.family_name || '', - avatar_url: profileData.avatar_url || null, + avatar_url: finalAvatar, + avatarUrl: finalAvatar, + workspace_avatar_url: finalWorkspaceAvatar, + workspaceAvatarUrl: finalWorkspaceAvatar, + legacy_avatar_url: legacyAvatar, + legacyAvatarUrl: legacyAvatar, phone: profileData.phone || null, bio: profileData.bio || null, preferences: profileData.preferences || {} @@ -306,12 +344,18 @@ export async function validateAuth( // Fallback: map raw JWT claim names to the expected field names const nameParts = (keycloakData.name || '').split(' '); + const finalAvatar = workspaceAvatarFromAuthMe; + console.debug('[avatar][validateAuth] fallback auth/me avatar final:', finalAvatar ?? '(null)'); return { ...keycloakData, id: keycloakData.id || keycloakData.sub, username: keycloakData.username || keycloakData.preferred_username || '', first_name: keycloakData.first_name || keycloakData.given_name || nameParts[0] || '', last_name: keycloakData.last_name || keycloakData.family_name || nameParts.slice(1).join(' ') || '', + avatar_url: finalAvatar, + avatarUrl: finalAvatar, + workspace_avatar_url: finalAvatar, + workspaceAvatarUrl: finalAvatar, }; } catch (error) { // Si es un redirect, re-lanzarlo diff --git a/frontend/src/lib/utils.avatar.test.ts b/frontend/src/lib/utils.avatar.test.ts new file mode 100644 index 00000000..dbcd7aea --- /dev/null +++ b/frontend/src/lib/utils.avatar.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { resolveUserAvatarUrl } from './utils'; + +describe('resolveUserAvatarUrl', () => { + it('prioriza avatar de Workspace cuando es URL absoluta', () => { + const value = resolveUserAvatarUrl('https://hub.example.com/media/avatar.png', '/uploads/legacy.png'); + expect(value).toBe('https://hub.example.com/media/avatar.png'); + }); + + it('acepta avatar de Workspace relativo cuando no hay VITE_HUB_URL', () => { + const value = resolveUserAvatarUrl('/media/avatar.png', '/uploads/legacy.png'); + expect(value).toBe('/media/avatar.png'); + }); + + it('usa avatar legado cuando Workspace no existe', () => { + const value = resolveUserAvatarUrl(null, '/uploads/legacy.png'); + expect(value).toBe('http://localhost:8000/uploads/legacy.png'); + }); + + it('retorna vacio para fallback visual cuando no hay ninguna imagen', () => { + const value = resolveUserAvatarUrl(null, null); + expect(value).toBe(''); + }); +}); diff --git a/frontend/src/lib/utils.getBackendAssetUrl.test.ts b/frontend/src/lib/utils.getBackendAssetUrl.test.ts index 140a91ed..47121ea9 100644 --- a/frontend/src/lib/utils.getBackendAssetUrl.test.ts +++ b/frontend/src/lib/utils.getBackendAssetUrl.test.ts @@ -15,8 +15,16 @@ describe('getBackendAssetUrl', () => { expect(getBackendAssetUrl('http://ejemplo.com/archivo.png')).toBe('http://ejemplo.com/archivo.png') }) + it('reescribe host interno de Docker a host publico', () => { + expect(getBackendAssetUrl('http://hub-backend:8000/api/static/avatars/file.png')).toBe('http://localhost:8000/api/static/avatars/file.png') + }) + + it('reescribe URL sin protocolo con host interno', () => { + expect(getBackendAssetUrl('hub-backend:8000/api/static/avatars/file.png')).toBe('http://localhost:8000/api/static/avatars/file.png') + }) + it('evita duplicar /api en la URL', () => { - expect(getBackendAssetUrl('/api/v1/items', 'http://localhost:8000/api')).toBe('http://localhost:8000/api/v1/items') + expect(getBackendAssetUrl('/api/v1/items')).toBe('http://localhost:8000/api/v1/items') }) it('construye URL completa para ruta relativa (VITE_API_URL por defecto host:8000)', () => { diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index b57dde05..fca43238 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -5,6 +5,70 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }; +function isInternalDockerHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + if (!host) return false; + if (host === 'localhost' || host === '127.0.0.1') return false; + if (host === 'backend' || host === 'hub-backend' || host === 'host.docker.internal') return true; + // Nombres de servicio Docker suelen no contener punto. + return !host.includes('.'); +} + +function getApiPublicBaseOrigin(): string { + const raw = (import.meta.env.VITE_API_URL || '').trim(); + if (raw) { + try { + const parsed = new URL(raw); + if (isInternalDockerHost(parsed.hostname)) { + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + return 'http://localhost:8000'; + } + return `${parsed.protocol}//${parsed.host}`; + } catch { + return raw.replace(/\/+$/, ''); + } + } + + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + + return 'http://localhost:8000'; +} + +function parseAbsoluteLikeUrl(value: string): URL | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + try { + return new URL(trimmed); + } catch { + // Soporta formato host:puerto/ruta sin protocolo. + if (/^[a-z0-9.-]+:\d+\//i.test(trimmed)) { + try { + return new URL(`http://${trimmed}`); + } catch { + return null; + } + } + return null; + } +} + +function rewriteInternalHostToPublicUrl(value: string): string { + const parsed = parseAbsoluteLikeUrl(value); + if (!parsed) return value; + + if (isInternalDockerHost(parsed.hostname)) { + const publicOrigin = getApiPublicBaseOrigin(); + return `${publicOrigin}${parsed.pathname}${parsed.search}${parsed.hash}`; + } + + return value; +} + /** * Convierte una ruta relativa del backend en una URL completa * @param path Ruta relativa (ej: "/uploads/avatars/file.png") o absoluta API (ej: "/api/v1/...") @@ -15,7 +79,11 @@ export function getBackendAssetUrl(path: string | null | undefined): string { // Si ya es una URL completa, retornarla tal cual if (path.startsWith('http://') || path.startsWith('https://')) { - return path; + return rewriteInternalHostToPublicUrl(path); + } + + if (/^[a-z0-9.-]+:\d+\//i.test(path)) { + return rewriteInternalHostToPublicUrl(path); } const normalized = path.startsWith('/') ? path : `/${path}`; @@ -35,6 +103,65 @@ export function getBackendAssetUrl(path: string | null | undefined): string { return `${baseUrl}/${cleanPath}`; } +export function isSafeHttpUrl(value: string | null | undefined): boolean { + if (!value) return false; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +function getHubAssetBaseUrl(): string { + const hubBase = (import.meta.env.VITE_HUB_URL || '').trim(); + if (!hubBase) return ''; + return hubBase.replace(/\/+$/, ''); +} + +function normalizeWorkspaceAvatarUrl(value: string | null | undefined): string { + if (!value || typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + + if (isSafeHttpUrl(trimmed)) { + return rewriteInternalHostToPublicUrl(trimmed); + } + + if (/^[a-z0-9.-]+:\d+\//i.test(trimmed)) { + return rewriteInternalHostToPublicUrl(trimmed); + } + + // Workspace puede devolver rutas relativas (ej: /media/avatar.png). + if (trimmed.startsWith('/')) { + const hubBase = getHubAssetBaseUrl(); + if (hubBase) { + return `${hubBase}${trimmed}`; + } + // Si no hay HUB_URL pública, intentar resolver en el mismo origen. + return trimmed; + } + + return ''; +} + +/** + * Prioridad de avatar de usuario: + * 1) workspaceAvatarUrl (http/https válido) + * 2) avatar local legado + * 3) fallback visual del componente Avatar + */ +export function resolveUserAvatarUrl( + workspaceAvatarUrl: string | null | undefined, + legacyAvatarUrl: string | null | undefined +): string { + const normalizedWorkspaceAvatar = normalizeWorkspaceAvatarUrl(workspaceAvatarUrl); + if (normalizedWorkspaceAvatar) { + return normalizedWorkspaceAvatar; + } + return getBackendAssetUrl(legacyAvatarUrl); +} + /** * Obtiene únicamente el nombre del archivo a partir de una ruta/URL. */ diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index a7978f7b..4c045a9a 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -32,10 +32,11 @@ export const load: PageServerLoad = async ({ url, cookies }) => { } // SSO exchange must call the Hub that GENERATED the relay token. - // HUB_URL is the canonical public Hub (workspace.aduanasoft.com) — where the - // App Launcher runs and where relay tokens are stored. - // INTERNAL_HUB_URL is a local mirror only used for token validation in the backend. + // This fetch runs server-side (inside the Docker container), so we must use + // INTERNAL_HUB_URL (host.docker.internal) when available — "localhost" inside + // a container never reaches the host where the workspace Hub is running. const hubUrl = ( + process.env.INTERNAL_HUB_URL || process.env.HUB_URL || process.env.VITE_HUB_URL || 'http://localhost:8001' diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 4228e45f..24aa41fd 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -78,11 +78,22 @@ } if (data.user) { + const resolvedAvatarUrl = + data.user.workspaceAvatarUrl || + data.user.workspace_avatar_url || + data.user.avatarUrl || + data.user.avatar_url || + data.user.legacyAvatarUrl || + data.user.legacy_avatar_url || + null; authStore.setUser({ id: data.user.sub ?? data.user.id ?? '', username: data.user.preferred_username ?? data.user.username ?? '', email: data.user.email, name: data.user.name, + avatarUrl: resolvedAvatarUrl, + workspaceAvatarUrl: data.user.workspaceAvatarUrl ?? data.user.workspace_avatar_url ?? null, + 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 ?? [] diff --git a/frontend/src/routes/dashboard/account/+page.server.ts b/frontend/src/routes/dashboard/account/+page.server.ts index 1be0be04..4b0d6c3d 100644 --- a/frontend/src/routes/dashboard/account/+page.server.ts +++ b/frontend/src/routes/dashboard/account/+page.server.ts @@ -17,46 +17,13 @@ export const load: PageServerLoad = async ({ parent }) => { export const actions: Actions = { updateProfile: async ({ request, cookies, fetch }) => { - const formData = await request.formData(); - - // Manejar subida de avatar si existe - const avatarFile = formData.get('avatar') as File | null; - let avatarUrl: string | null = null; - - - if (avatarFile && avatarFile instanceof File && avatarFile.size > 0) { - try { - const uploadFormData = new FormData(); - uploadFormData.append('file', avatarFile); - - const uploadResponse = await authenticatedFetch( - 'v1/core/users/me/avatar', - { - method: 'POST', - body: uploadFormData - }, - cookies, - fetch, - '/login' - ); - - if (uploadResponse.ok) { - const result = await uploadResponse.json(); - avatarUrl = result.avatar_url; - } else { - const errorText = await uploadResponse.text(); - console.error('Upload failed:', errorText); - } - } catch (err) { - console.error('Error uploading avatar:', err); - } - } + const formData = await request.formData(); // Construir objeto de actualización desde FormData const updateData: Record = {}; for (const [key, value] of formData.entries()) { - if (key === 'avatar') continue; // Skip avatar file + if (key === 'avatar') continue; if (value && value !== '') { updateData[key] = value; } diff --git a/frontend/src/routes/dashboard/account/+page.svelte b/frontend/src/routes/dashboard/account/+page.svelte index 80c6cd8e..f86b6913 100644 --- a/frontend/src/routes/dashboard/account/+page.svelte +++ b/frontend/src/routes/dashboard/account/+page.svelte @@ -5,7 +5,7 @@ import { Label } from '$lib/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/ui/avatar'; - import { getBackendAssetUrl } from '$lib/utils'; + import { resolveUserAvatarUrl } from '$lib/utils'; import type { PageData, ActionData } from './$types'; let { data, form }: { data: PageData; form: ActionData } = $props(); @@ -18,8 +18,7 @@ let saving = $state(false); let success = $state(''); let error = $state(''); - let avatarFile = $state(null); - let avatarPreview = $state(''); + let avatarLoadFailed = $state(false); // Effect para manejar errores del servidor $effect(() => { @@ -32,8 +31,6 @@ $effect(() => { if (form?.success) { success = 'Perfil actualizado exitosamente'; - avatarPreview = ''; - avatarFile = null; setTimeout(() => { success = ''; }, 3000); @@ -44,34 +41,6 @@ } }); - async function handleAvatarChange(event: Event) { - const target = event.target as HTMLInputElement; - const file = target.files?.[0]; - - if (file) { - // Validate file type - if (!file.type.startsWith('image/')) { - error = 'Por favor selecciona una imagen válida'; - return; - } - - // Validate file size (max 2MB) - if (file.size > 2 * 1024 * 1024) { - error = 'La imagen debe ser menor a 2MB'; - return; - } - - avatarFile = file; - - // Create preview - const reader = new FileReader(); - reader.onload = (e) => { - avatarPreview = e.target?.result as string; - }; - reader.readAsDataURL(file); - } - } - function getInitials(profile: typeof data.profile): string { if (!profile) return '??'; const first = profile.first_name?.[0] || ''; @@ -79,8 +48,12 @@ return (first + last).toUpperCase() || profile.username?.[0]?.toUpperCase() || '?'; } + function handleAvatarError() { + avatarLoadFailed = true; + } + let currentAvatarUrl = $derived( - avatarPreview || getBackendAssetUrl(profile?.avatar_url) || '' + avatarLoadFailed ? '' : resolveUserAvatarUrl(profile?.workspace_avatar_url, profile?.legacy_avatar_url || profile?.avatar_url) ); @@ -109,11 +82,6 @@ action="?/updateProfile" enctype="multipart/form-data" use:enhance={({ formData }) => { - - // Agregar archivo si existe - if (avatarFile) { - formData.append('avatar', avatarFile); - } saving = true; error = ''; @@ -131,38 +99,29 @@ Foto de Perfil - Actualiza tu imagen de perfil + Esta imagen se administra desde Workspace
-
-
- -

- Haz clic en la imagen o selecciona un archivo. JPG, PNG o GIF. Máximo 2MB. -

-
+

+ La foto de perfil se sincroniza desde Workspace en login, refresh de sesión y carga de perfil. +

+

+ Para cambiarla, actualízala en Workspace y vuelve a iniciar sesión. +

From 96fd14582de3d6f978a025efacde6e164d9598cf Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 8 May 2026 17:37:27 -0500 Subject: [PATCH 03/11] fix: update down_revision for workspace profile fields migration --- .../c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py b/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py index 7608f783..446e9e31 100644 --- a/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py +++ b/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py @@ -12,7 +12,7 @@ import sqlalchemy as sa from alembic import op revision: str = "c3d4e5f6a7b" -down_revision: Union[str, None] = "b2c3d4e5f6a7" +down_revision: Union[str, None] = "ca7d3c4e8b2a" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None From 06998f61d421d272be6963309c8d2ec37bf1a64b Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 8 May 2026 17:40:56 -0500 Subject: [PATCH 04/11] Ahora si guarda y edita de forma correcta --- .../transportistas/common/fk_loader.py | 88 +++--- .../transportistas/validators/create.py | 27 +- .../a76/transportation/catalog_parity.py | 3 +- .../transportation/transporters/services.py | 2 + backend/core/database.py | 16 +- frontend/src/lib/api.ts | 59 +++- .../drivers/create-edit-dialog.svelte | 2 +- .../trailers/create-edit-dialog.svelte | 2 +- .../transporters/create-edit-dialog.svelte | 291 ++++++++++++++++-- .../vehicles/create-edit-dialog.svelte | 2 +- 10 files changed, 400 insertions(+), 92 deletions(-) diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py index 4e2f7f32..ad60c9b2 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py @@ -3,6 +3,7 @@ Carga de conjuntos FK para validación de import CSV de transportistas. Clarion: GTransportista (ClaveTrans), GPaises (Pais_Ame), GEstados (Descripcion), relación Estado-País. """ from typing import Set, Tuple, Optional +from sqlalchemy.orm import Session import logging from core.database import CoreSessionLocal @@ -13,6 +14,7 @@ logger = logging.getLogger(__name__) def load_transportistas_fk_sets( tenant_id: Optional[int] = None, company_id: Optional[int] = None, + db: Optional[Session] = None, ) -> Tuple[ Set[str], Set[str], @@ -32,50 +34,56 @@ def load_transportistas_fk_sets( state_descriptions_upper: Set[str] = set() state_country_set: Set[Tuple[str, str]] = set() - try: - with CoreSessionLocal() as session: - from api.v1.modules.a76.transportation.transporters.models import Transporter - from api.v1.modules.public.reference_data.countries.models import Country - from api.v1.modules.public.reference_data.states.models import State + def _load(session: Session): + from api.v1.modules.a76.transportation.transporters.models import Transporter + from api.v1.modules.public.reference_data.countries.models import Country + from api.v1.modules.public.reference_data.states.models import State - if tenant_id is not None and company_id is not None: - for row in ( - session.query(Transporter.transporter_key) - .filter( - Transporter.tenant_id == tenant_id, - Transporter.company_id == company_id, - ) - .all() - ): - if row[0] and (row[0] or "").strip(): - existing_transporter_keys.add((row[0] or "").strip().upper()) - - for row in session.query(Country.ame_key).all(): - if row[0]: - valid_country_ame.add((row[0] or "").strip().upper()) - - for state in session.query(State).all(): - country = ( - session.query(Country) - .filter(Country.m3_key == state.m3_key) - .first() + if tenant_id is not None and company_id is not None: + for row in ( + session.query(Transporter.transporter_key) + .filter( + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, ) - ame = None - if country and (country.ame_key or "").strip(): - ame = (country.ame_key or "").strip().upper() + .all() + ): + if row[0] and (row[0] or "").strip(): + existing_transporter_keys.add((row[0] or "").strip().upper()) - desc = (state.description or "").strip() - if desc: - state_descriptions_upper.add(desc.upper()) - if ame: - state_country_set.add((ame, desc.upper())) + for row in session.query(Country.ame_key).all(): + if row[0]: + valid_country_ame.add((row[0] or "").strip().upper()) - mex_key = (state.mex_key or "").strip() - if mex_key: - mk = mex_key.upper() - state_descriptions_upper.add(mk) - if ame: - state_country_set.add((ame, mk)) + for state in session.query(State).all(): + country = ( + session.query(Country) + .filter(Country.m3_key == state.m3_key) + .first() + ) + ame = None + if country and (country.ame_key or "").strip(): + ame = (country.ame_key or "").strip().upper() + + desc = (state.description or "").strip() + if desc: + state_descriptions_upper.add(desc.upper()) + if ame: + state_country_set.add((ame, desc.upper())) + + mex_key = (state.mex_key or "").strip() + if mex_key: + mk = mex_key.upper() + state_descriptions_upper.add(mk) + if ame: + state_country_set.add((ame, mk)) + + try: + if db: + _load(db) + else: + with CoreSessionLocal() as session: + _load(session) except Exception as e: logger.warning("Transportistas import: could not load FK sets: %s", e) diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py index d3daa7e8..7dd6b16e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py @@ -37,24 +37,31 @@ def validate_row_transporter( clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper() use_partial = actualizar and bool(clave and clave in existing) + if not use_partial and clave and clave in existing: + errors.append({ + "line": line_num, + "col": "CLAVE TRANSPORTISTA", + "msg": f"La clave '{row.get('CLAVE TRANSPORTISTA')}' ya existe en el catálogo." + }) + if use_partial: errors.extend( valida_parcial_transportistas( - row, - line_num, - valid_country_ame=valid_country_ame, - state_descriptions_upper=state_descriptions_upper, - state_country_set=state_country_set, + row, + line_num, + valid_country_ame=valid_country_ame, + state_descriptions_upper=state_descriptions_upper, + state_country_set=state_country_set, ) ) else: errors.extend( valida_toda_transportistas( - row, - line_num, - valid_country_ame=valid_country_ame, - state_descriptions_upper=state_descriptions_upper, - state_country_set=state_country_set, + row, + line_num, + valid_country_ame=valid_country_ame, + state_descriptions_upper=state_descriptions_upper, + state_country_set=state_country_set, ) ) return errors diff --git a/backend/api/v1/modules/a76/transportation/catalog_parity.py b/backend/api/v1/modules/a76/transportation/catalog_parity.py index 6f097144..700977ec 100644 --- a/backend/api/v1/modules/a76/transportation/catalog_parity.py +++ b/backend/api/v1/modules/a76/transportation/catalog_parity.py @@ -277,6 +277,7 @@ def transporter_model_to_row(t) -> Dict[str, Any]: def validate_transporter_row_for_api( + db: Session, tenant_id: int, company_id: int, row: Dict[str, Any], @@ -292,7 +293,7 @@ def validate_transporter_row_for_api( valid_country_ame, state_descriptions_upper, state_country_set, - ) = load_transportistas_fk_sets(tenant_id, company_id) + ) = load_transportistas_fk_sets(tenant_id, company_id, db=db) clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper() # existing set from loader is uppercased keys for this company diff --git a/backend/api/v1/modules/a76/transportation/transporters/services.py b/backend/api/v1/modules/a76/transportation/transporters/services.py index 8f40888d..a5494e8f 100644 --- a/backend/api/v1/modules/a76/transportation/transporters/services.py +++ b/backend/api/v1/modules/a76/transportation/transporters/services.py @@ -104,6 +104,7 @@ class TransporterService: """Create a new transporter""" data = transporter_data.model_dump() validate_transporter_row_for_api( + db, tenant_id, company_id, transporter_fields_to_csv_row(data), @@ -163,6 +164,7 @@ class TransporterService: } merged.update(update_data) validate_transporter_row_for_api( + db, tenant_id, company_id, transporter_fields_to_csv_row(merged), diff --git a/backend/core/database.py b/backend/core/database.py index 95e937d3..b1889310 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -150,8 +150,13 @@ def get_core_db(request: Request = None) -> Generator[Session, None, None]: yield db finally: db.close() - rls_tenant_var.reset(token_t) - rls_company_var.reset(token_c) + try: + rls_tenant_var.reset(token_t) + rls_company_var.reset(token_c) + except ValueError: + # Ignorar ValueError de contextvars en dependencias síncronas + # debido a que AnyIO puede ejecutar el teardown en un contexto diferente. + pass async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]: @@ -168,8 +173,11 @@ async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSess finally: await session.close() finally: - rls_tenant_var.reset(token_t) - rls_company_var.reset(token_c) + try: + rls_tenant_var.reset(token_t) + rls_company_var.reset(token_c) + except ValueError: + pass @contextmanager diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d34ecb93..ff387ee0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -331,18 +331,48 @@ async function fetchApi( // Manejo especial para errores 422 (validation error) if (response.status === 422) { // HTTPException(detail={ message, errors }) — catálogo / CSV parity - const det = data.detail; - const validationErrors = (errors: unknown[]) => errors as NonNullable; + const det = data.detail || (typeof data.message === 'object' ? data.message : null); if ( det && - typeof det === 'object' && + typeof det === "object" && !Array.isArray(det) && Array.isArray((det as { errors?: unknown }).errors) ) { - const d = det as { message?: string; errors: unknown[] }; + const d = det as { + message?: string; + errors: Array<{ col?: string; msg?: string; field?: string; message?: string }>; + }; + + // Mapping for catalog column names to DTO field names + const colToField: Record = { + "CLAVE TRANSPORTISTA": "transporter_key", + NOMBRE: "name", + "NOMBRE CORTO": "short_name", + RESPONSABLE: "responsible", + RFC: "rfc", + CALLES: "streets", + "CODIGO POSTAL": "postal_code", + CIUDAD: "city", + ESTADO: "state", + PAIS: "country", + "CODIGO CARGADOR": "loader_code", + "CODIGO CAAT": "caat_code", + "CODIGO TRANS": "transport_code", + "TIPO INTERFASE TRANS": "transport_interface_type", + "SERVIDOR FTP": "ftp_server", + "USUARIO FTP": "ftp_user", + "CLAVE ACCESO FTP": "ftp_password", + "DIRECTORIO FTP": "ftp_directory" + }; + + const normalizedErrors = d.errors.map((err) => ({ + field: err.field || (err.col ? colToField[err.col] || err.col : ""), + message: err.message || err.msg || "Error de validación" + })); + return { - error: d.message || 'Error de validación', - validationErrors: validationErrors(d.errors), + error: d.message || (typeof data.message === 'string' ? data.message : 'Error de validación'), + validationErrors: normalizedErrors, status: response.status }; } @@ -350,21 +380,31 @@ async function fetchApi( if (data.errors && Array.isArray(data.errors)) { return { error: data.message || 'Error de validación', - validationErrors: validationErrors(data.errors), + validationErrors: data.errors as NonNullable, status: response.status }; } // Errores de validación de FastAPI (con detail) else if (data.detail) { let errorMessage = 'Error de validación: '; + const vErrors: NonNullable = []; // FastAPI devuelve errores de validación en data.detail como array if (Array.isArray(data.detail)) { - const errors = data.detail.map((err: any) => { + data.detail.forEach((err: any) => { + const fieldPath = err.loc ? err.loc.filter((l: any) => l !== 'body').join('.') : 'campo'; + const msg = humanizeValidationMessage(err.msg || 'error de validación'); + + vErrors.push({ + field: err.loc ? String(err.loc[err.loc.length - 1]) : 'campo', + message: msg + }); + }); + + errorMessage += data.detail.map((err: any) => { const field = err.loc ? err.loc.join('.') : 'campo desconocido'; return `${field}: ${err.msg}`; }).join(', '); - errorMessage += errors; } else if (typeof data.detail === 'string') { errorMessage = data.detail; } else { @@ -373,6 +413,7 @@ async function fetchApi( return { error: errorMessage, + validationErrors: vErrors.length ? vErrors : undefined, status: response.status }; } diff --git a/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte index ca480f5e..ef3cea88 100644 --- a/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte @@ -288,7 +288,7 @@ - + e.preventDefault()}> {title} diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte index 0bbb617d..939cd3d0 100644 --- a/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte @@ -168,7 +168,7 @@ - + e.preventDefault()}> {title} diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte index 6d05d2ba..2446ea29 100644 --- a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte @@ -52,6 +52,7 @@ let loading = $state(false); let error = $state(null); + let fieldErrors = $state>({}); let countries = $state([]); let states = $state([]); let refsLoading = $state(false); @@ -109,6 +110,7 @@ $effect(() => { if (!open) { error = null; + fieldErrors = {}; loading = false; return; } @@ -120,9 +122,89 @@ void loadReferenceData(); }); + function validateForm(): boolean { + const errors: Record = {}; + + // Clave del transportista + if (!formData.transporter_key?.trim()) { + errors.transporter_key = 'La clave es obligatoria'; + } else if (/\s/.test(formData.transporter_key)) { + errors.transporter_key = 'La clave no puede contener espacios'; + } else if (!/^[A-Za-z0-9_-]+$/.test(formData.transporter_key)) { + errors.transporter_key = 'La clave solo permite letras, números, guiones y guiones bajos'; + } + + // Nombre / Razón Social + if (!formData.name?.trim()) { + errors.name = 'El nombre o razón social es obligatorio'; + } + + // RFC (Opcional, pero si se pone debe ser válido si es MX) + if (formData.rfc?.trim()) { + const rfcRegex = + /^([A-ZÑ&]{3,4}) ?(?:- ?)?(\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])) ?(?:- ?)?([A-Z\d]{2})([A-Z\d])$/i; + if (formData.country === 'MEX' && !rfcRegex.test(formData.rfc.trim())) { + errors.rfc = 'Formato de RFC inválido para México'; + } + } + + // Código Postal + if (formData.postal_code?.trim()) { + if (formData.country === 'MEX' && !/^\d{5}$/.test(formData.postal_code.trim())) { + errors.postal_code = 'El código postal en México debe ser de 5 dígitos'; + } else if (!/^\d+$/.test(formData.postal_code.trim())) { + errors.postal_code = 'El código postal debe ser numérico'; + } + } + + // Códigos de transporte + if (formData.caat_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.caat_code)) { + errors.caat_code = 'El código CAAT debe ser alfanumérico'; + } + if (formData.transport_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.transport_code)) { + errors.transport_code = 'El código de transporte debe ser alfanumérico'; + } + if (formData.loader_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.loader_code)) { + errors.loader_code = 'El código de cargador debe ser alfanumérico'; + } + if (formData.filler_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.filler_code)) { + errors.filler_code = 'El código de relleno debe ser alfanumérico'; + } + + // Configuración FTP + if (formData.ftp_server?.trim()) { + const hostRegex = + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/; + const ipRegex = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + if (!hostRegex.test(formData.ftp_server) && !ipRegex.test(formData.ftp_server)) { + errors.ftp_server = 'Servidor FTP inválido (debe ser un host o IP)'; + } + } + if (formData.ftp_user?.trim() && /\s/.test(formData.ftp_user)) { + errors.ftp_user = 'El usuario FTP no puede contener espacios'; + } + + fieldErrors = errors; + return Object.keys(errors).length === 0; + } + + function clearFieldError(field: string) { + if (fieldErrors[field]) { + fieldErrors[field] = ''; + } + } + async function handleSubmit() { if (loading) return; error = null; + fieldErrors = {}; + + if (!validateForm()) { + error = 'Por favor, corrige los errores en el formulario'; + return; + } + loading = true; try { @@ -131,11 +213,6 @@ throw new Error('No hay una compañía seleccionada'); } - // Validación básica - if (!formData.transporter_key.trim()) { - throw new Error('La clave es requerida'); - } - let response; if (isEdit && item) { response = await transportersApi.update(item.transporter_key, formData, companyId); @@ -144,9 +221,17 @@ } if (response.error) { - const ve = (response as { validationErrors?: { msg?: string }[] }).validationErrors; + const ve = response.validationErrors; if (ve?.length) { - throw new Error(ve.map((e) => e.msg).join(' · ')); + // Mapear errores de validación del backend si están disponibles + const backendErrors: Record = {}; + ve.forEach((err) => { + if (err.field) { + backendErrors[err.field] = err.message || 'Error de validación'; + } + }); + fieldErrors = backendErrors; + throw new Error('Errores de validación en el servidor'); } throw new Error(response.error); } @@ -180,7 +265,7 @@ - + e.preventDefault()}> {title} @@ -214,14 +299,28 @@ id="transporter_key" bind:value={formData.transporter_key} disabled={isEdit} + aria-invalid={!!fieldErrors.transporter_key} + oninput={() => clearFieldError('transporter_key')} required maxlength={30} /> + {#if fieldErrors.transporter_key} +

{fieldErrors.transporter_key}

+ {/if}
- - + + clearFieldError('name')} + maxlength={256} + /> + {#if fieldErrors.name} +

{fieldErrors.name}

+ {/if}
@@ -229,19 +328,42 @@ clearFieldError('short_name')} maxlength={10} placeholder="Máx. 10 car." /> + {#if fieldErrors.short_name} +

{fieldErrors.short_name}

+ {/if}
- + clearFieldError('rfc')} + maxlength={30} + /> + {#if fieldErrors.rfc} +

{fieldErrors.rfc}

+ {/if}
- + clearFieldError('responsible')} + maxlength={100} + /> + {#if fieldErrors.responsible} +

{fieldErrors.responsible}

+ {/if}
@@ -251,7 +373,16 @@
- + clearFieldError('caat_code')} + maxlength={49} + /> + {#if fieldErrors.caat_code} +

{fieldErrors.caat_code}

+ {/if}
@@ -259,9 +390,14 @@ clearFieldError('transport_code')} maxlength={8} placeholder="Máx. 8 car." /> + {#if fieldErrors.transport_code} +

{fieldErrors.transport_code}

+ {/if}
@@ -269,9 +405,14 @@ clearFieldError('loader_code')} maxlength={9} placeholder="Máx. 9 car." /> + {#if fieldErrors.loader_code} +

{fieldErrors.loader_code}

+ {/if}
@@ -279,13 +420,27 @@ clearFieldError('transport_interface_type')} maxlength={20} /> + {#if fieldErrors.transport_interface_type} +

{fieldErrors.transport_interface_type}

+ {/if}
- + clearFieldError('filler_code')} + maxlength={20} + /> + {#if fieldErrors.filler_code} +

{fieldErrors.filler_code}

+ {/if}
@@ -300,18 +455,44 @@
- + clearFieldError('streets')} + maxlength={100} + /> + {#if fieldErrors.streets} +

{fieldErrors.streets}

+ {/if}
- + clearFieldError('city')} + maxlength={30} + /> + {#if fieldErrors.city} +

{fieldErrors.city}

+ {/if}
- - + clearFieldError('country')} + > + {refsLoading ? '...' : formData.country @@ -327,14 +508,25 @@ {/each} + {#if fieldErrors.country} +

{fieldErrors.country}

+ {/if}
- - + clearFieldError('state')} + > + {refsLoading ? '...' : formData.state || @@ -349,10 +541,22 @@ {/each} + {#if fieldErrors.state} +

{fieldErrors.state}

+ {/if}
- + clearFieldError('postal_code')} + maxlength={15} + /> + {#if fieldErrors.postal_code} +

{fieldErrors.postal_code}

+ {/if}
@@ -363,23 +567,60 @@
- + clearFieldError('ftp_server')} + maxlength={200} + /> + {#if fieldErrors.ftp_server} +

{fieldErrors.ftp_server}

+ {/if}
- + clearFieldError('ftp_user')} + maxlength={200} + /> + {#if fieldErrors.ftp_user} +

{fieldErrors.ftp_user}

+ {/if}
- + clearFieldError('ftp_password')} + maxlength={100} + /> + {#if fieldErrors.ftp_password} +

{fieldErrors.ftp_password}

+ {/if}
- + clearFieldError('ftp_directory')} + maxlength={1000} + /> + {#if fieldErrors.ftp_directory} +

{fieldErrors.ftp_directory}

+ {/if}
diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte index b3b57717..c0c45e1e 100644 --- a/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte @@ -215,7 +215,7 @@ - + e.preventDefault()}> {title} From c9d8bf666a3bb9752d01a5b3caee4e21d836afca Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 11 May 2026 08:39:16 -0500 Subject: [PATCH 05/11] fix: update docker-compose network to external and enhance avatar URL resolution logic --- backend/pytest.ini | 3 +++ docker-compose.yml | 2 +- frontend/.gitignore | 1 + frontend/package.json | 3 ++- frontend/src/lib/utils.ts | 25 +++++++++++++++---------- frontend/vite.config.ts | 1 + 6 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 backend/pytest.ini diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 00000000..27eec68e --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +python_files = test_*.py diff --git a/docker-compose.yml b/docker-compose.yml index 28b35572..7b232c1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -313,5 +313,5 @@ networks: driver: bridge hub-net: - external: false + external: true name: aduanasoft-hub_default diff --git a/frontend/.gitignore b/frontend/.gitignore index 6a5f9f9d..ce9f3eb7 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -26,6 +26,7 @@ Thumbs.db !.env.test # Vite +.vite/ vite.config.js.timestamp-* vite.config.ts.timestamp-* diff --git a/frontend/package.json b/frontend/package.json index 068aeeab..2e49b7aa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,8 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", "lint": "prettier --check . && eslint .", - "test:unit": "vitest", + "test:unit": "vitest --project server", + "test:unit:full": "vitest", "test": "npm run test:unit -- --run && npm run test:e2e", "test:e2e": "playwright test" }, diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index fca43238..3e996ac5 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -43,18 +43,23 @@ function parseAbsoluteLikeUrl(value: string): URL | null { if (!trimmed) return null; try { - return new URL(trimmed); - } catch { - // Soporta formato host:puerto/ruta sin protocolo. - if (/^[a-z0-9.-]+:\d+\//i.test(trimmed)) { - try { - return new URL(`http://${trimmed}`); - } catch { - return null; - } + const parsed = new URL(trimmed); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed; } - return null; + } catch { } + + // Soporta formato host:puerto/ruta sin protocolo. + if (/^[a-z0-9.-]+:\d+\//i.test(trimmed)) { + try { + return new URL(`http://${trimmed}`); + } catch { + return null; + } + } + + return null; } function rewriteInternalHostToPublicUrl(value: string): string { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 24c69218..6ed0afa2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -37,6 +37,7 @@ const vitestClientProject = { }; export default defineConfig({ + cacheDir: '.vite', server: { port: 5173, // fija el puerto host: true, // escucha en 0.0.0.0 From 4fef7a6cc4f22f6050325fe6432ccb62ac4156fb Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 11 May 2026 09:27:19 -0500 Subject: [PATCH 06/11] se agrego la edicion y eliminacion de los registros de selectores --- .../api/dashboard/reference_data/sectors.ts | 4 ++-- .../reference_data/sectors/columns.ts | 7 ++++-- .../sectors/data-table-actions.svelte | 10 +++++--- .../reference-data/sectors-ref-permissions.ts | 13 +++++++--- .../reference_data/sectors/+page.svelte | 24 +++++++++++++++---- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/api/dashboard/reference_data/sectors.ts b/frontend/src/lib/api/dashboard/reference_data/sectors.ts index 43b9c72b..b2fe0800 100644 --- a/frontend/src/lib/api/dashboard/reference_data/sectors.ts +++ b/frontend/src/lib/api/dashboard/reference_data/sectors.ts @@ -45,7 +45,7 @@ export const sectorsApi = { }, get: (id: number, companyId: number) => - api.get(`/v1/a76/sectors/${id}/?company_id=${companyId}`), + api.get(`/v1/a76/sectors/${id}?company_id=${companyId}`), getByKey: (key: string, companyId: number) => { const params = new URLSearchParams({ @@ -64,5 +64,5 @@ export const sectorsApi = { api.put(`/v1/a76/sectors/${id}/?company_id=${companyId}`, data), delete: (id: number, companyId: number) => - api.delete(`/v1/a76/sectors/${id}/?company_id=${companyId}`) + api.delete(`/v1/a76/sectors/${id}?company_id=${companyId}`) }; diff --git a/frontend/src/lib/components/dashboard/reference_data/sectors/columns.ts b/frontend/src/lib/components/dashboard/reference_data/sectors/columns.ts index 834824bb..a270a965 100644 --- a/frontend/src/lib/components/dashboard/reference_data/sectors/columns.ts +++ b/frontend/src/lib/components/dashboard/reference_data/sectors/columns.ts @@ -14,7 +14,8 @@ export type Sector = { export function createColumns( onSuccess?: () => void, - permissions: { canEdit: boolean; canDelete: boolean } = { canEdit: true, canDelete: true } + permissions: { canEdit: boolean; canDelete: boolean } = { canEdit: true, canDelete: true }, + callbacks: { onEdit?: (item: Sector) => void; onDelete?: (item: Sector) => void } = {} ): ColumnDef[] { return [ { @@ -67,7 +68,9 @@ export function createColumns( item: row.original, onSuccess, canEdit: permissions.canEdit, - canDelete: permissions.canDelete + canDelete: permissions.canDelete, + onEdit: callbacks.onEdit, + onDelete: callbacks.onDelete }); } } diff --git a/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte b/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte index 1c399a57..70d350a8 100644 --- a/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte @@ -13,12 +13,16 @@ item, onSuccess, canEdit = true, - canDelete = true + canDelete = true, + onEdit, + onDelete }: { item: Sector; onSuccess?: () => void; canEdit?: boolean; canDelete?: boolean; + onEdit?: (item: Sector) => void; + onDelete?: (item: Sector) => void; } = $props(); let showDetailsDialog = $state(false); @@ -54,13 +58,13 @@ {#if canEdit} - (alert('Módulo de edición no disponible para Catálogos Públicos'))}> + onEdit?.(item)}> Editar {/if} {#if canDelete} - (alert('Módulo de eliminación no disponible para Catálogos Públicos'))} class="text-destructive"> + onDelete?.(item)} class="text-destructive"> Eliminar diff --git a/frontend/src/lib/permissions/reference-data/sectors-ref-permissions.ts b/frontend/src/lib/permissions/reference-data/sectors-ref-permissions.ts index 44c94ddd..8747d56b 100644 --- a/frontend/src/lib/permissions/reference-data/sectors-ref-permissions.ts +++ b/frontend/src/lib/permissions/reference-data/sectors-ref-permissions.ts @@ -1,13 +1,20 @@ /** * Datos de referencia — permisos `ref_sectors.*` (seed_v2.py `permissions_reference`). - * Solo lectura en seed actual; `action` reservado por si se amplía el CRUD. * Sectores (referencia) + * Nota: Para mutaciones (create/edit/delete) se usan los permisos de cat_sectors + * ya que es el único catálogo "fijo" que permite edición por el usuario. */ import type { User } from '$lib/auth'; import { userHasPermission } from '$lib/auth'; -export type SectorsRefAction = 'view'; +export type SectorsRefAction = 'view' | 'create' | 'edit' | 'delete'; export function userHasSectorsRefAction(user: User | null, action: SectorsRefAction): boolean { - return userHasPermission(user, `ref_sectors.${action}`); + if (action === 'view') { + return userHasPermission(user, `ref_sectors.${action}`); + } + + // Mapeo a cat_sectors para acciones de escritura + const catalogAction = action === 'edit' ? 'edit' : action; + return userHasPermission(user, `cat_sectors.${catalogAction}`); } diff --git a/frontend/src/routes/dashboard/reference_data/sectors/+page.svelte b/frontend/src/routes/dashboard/reference_data/sectors/+page.svelte index 9cb4d78a..9c2eb613 100644 --- a/frontend/src/routes/dashboard/reference_data/sectors/+page.svelte +++ b/frontend/src/routes/dashboard/reference_data/sectors/+page.svelte @@ -15,6 +15,7 @@ import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; import { createColumns } from '$lib/components/dashboard/reference_data/sectors/columns'; import CreateEditDialog from '$lib/components/dashboard/reference_data/sectors/create-edit-dialog.svelte'; + import DeleteDialog from '$lib/components/dashboard/reference_data/sectors/delete-dialog.svelte'; import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte'; // Los datos iniciales vienen del servidor @@ -31,7 +32,9 @@ // Diálogos let createDialogOpen = $state(false); + let deleteDialogOpen = $state(false); let editingItem = $state(null); + let itemToDelete = $state(null); // Filtros let searchQuery = $state($page.url.searchParams.get('search') || ''); @@ -39,9 +42,9 @@ // Permisos const canView = $derived(userHasSectorsRefAction($currentUser, 'view')); - const canCreate = $derived(false); - const canEdit = $derived(false); - const canDelete = $derived(false); + const canCreate = $derived(userHasSectorsRefAction($currentUser, 'create')); + const canEdit = $derived(userHasSectorsRefAction($currentUser, 'edit')); + const canDelete = $derived(userHasSectorsRefAction($currentUser, 'delete')); const isError = $derived(!canView || status >= 400 || error); @@ -111,10 +114,22 @@ function handleSuccess() { createDialogOpen = false; + deleteDialogOpen = false; editingItem = null; + itemToDelete = null; reloadData(); } + function handleEdit(item: Sector) { + editingItem = item; + createDialogOpen = true; + } + + function handleDelete(item: Sector) { + itemToDelete = item; + deleteDialogOpen = true; + } + useShortcuts('Sectores', [ { key: 'Alt+Shift+R', description: 'Actualizar Lista', action: reloadData }, { @@ -127,7 +142,7 @@ } ]); - const columns = $derived(createColumns(handleSuccess, { canEdit, canDelete })); + const columns = $derived(createColumns(handleSuccess, { canEdit, canDelete }, { onEdit: handleEdit, onDelete: handleDelete }));
@@ -176,3 +191,4 @@
+ From 5dbce1a523a33f77cf7b11ff6203c6053d1db4ea Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 11 May 2026 10:17:20 -0500 Subject: [PATCH 07/11] fix: remove cacheDir from Vite configuration --- backend/pytest.ini | 3 --- frontend/vite.config.ts | 3 +-- 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 backend/pytest.ini diff --git a/backend/pytest.ini b/backend/pytest.ini deleted file mode 100644 index 27eec68e..00000000 --- a/backend/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -testpaths = tests -python_files = test_*.py diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6ed0afa2..3c60fcc4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -37,9 +37,8 @@ const vitestClientProject = { }; export default defineConfig({ - cacheDir: '.vite', server: { - port: 5173, // fija el puerto + port: 5173, // fija el puerto host: true, // escucha en 0.0.0.0 // Lista explícita + peticiones internas (p. ej. chunks JSON `?import`) pueden usar // Hosts distintos y recibir 403. `true` permite cualquier Host en dev. From af415eb604c77998e00a90894f3cf2f6849f41c4 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 11 May 2026 10:36:30 -0500 Subject: [PATCH 08/11] fix: change hub-net network to internal --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7b232c1b..28b35572 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -313,5 +313,5 @@ networks: driver: bridge hub-net: - external: true + external: false name: aduanasoft-hub_default From c50d38c496e323ff300d601cee8b6d97e10579f2 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 11 May 2026 12:03:04 -0500 Subject: [PATCH 09/11] Errores corregidos y despliegie normal decatalgos en trailers --- .../public/reference_data/states/routes.py | 4 ++ .../api/dashboard/reference_data/states.ts | 5 +- .../dashboard/reference_data/trailer_types.ts | 10 ++- .../edit/items/fa/state-dialog.svelte | 4 +- .../modals/state-selector-dialog.svelte | 4 +- .../trailers/create-edit-dialog.svelte | 68 +++++++++++++++---- .../transporters/create-edit-dialog.svelte | 5 +- .../vehicles/create-edit-dialog.svelte | 33 +++++++-- frontend/src/lib/i18n/trailer-types.ts | 65 ++++++++++++++++++ 9 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 frontend/src/lib/i18n/trailer-types.ts diff --git a/backend/api/v1/modules/public/reference_data/states/routes.py b/backend/api/v1/modules/public/reference_data/states/routes.py index 79595d33..165e4d0e 100644 --- a/backend/api/v1/modules/public/reference_data/states/routes.py +++ b/backend/api/v1/modules/public/reference_data/states/routes.py @@ -22,12 +22,16 @@ async def list_states( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), search: str = Query(None, description="Término de búsqueda"), + country_id: str = Query(None, description="Filtrar por país (m3_key)"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): skip = (page - 1) * page_size query = db.query(State) + if country_id: + query = query.filter(State.m3_key == country_id) + if search: search_filter = f"%{search}%" query = query.filter( diff --git a/frontend/src/lib/api/dashboard/reference_data/states.ts b/frontend/src/lib/api/dashboard/reference_data/states.ts index e501ded0..8773460e 100644 --- a/frontend/src/lib/api/dashboard/reference_data/states.ts +++ b/frontend/src/lib/api/dashboard/reference_data/states.ts @@ -40,11 +40,14 @@ export const statesApi = { * Lista todos los estados con paginación y búsqueda * 🛡️ CORREGIDO: Ahora requiere companyId */ - list: (companyId: number, page = 1, pageSize = 50, search?: string) => { + list: (companyId: number, page = 1, pageSize = 50, search?: string, countryId?: string) => { let url = `/v1/public/reference_data/states/?company_id=${companyId}&page=${page}&page_size=${pageSize}`; if (search) { url += `&search=${encodeURIComponent(search)}`; } + if (countryId) { + url += `&country_id=${encodeURIComponent(countryId)}`; + } return api.get(url); }, diff --git a/frontend/src/lib/api/dashboard/reference_data/trailer_types.ts b/frontend/src/lib/api/dashboard/reference_data/trailer_types.ts index 116eb183..c0ec9929 100644 --- a/frontend/src/lib/api/dashboard/reference_data/trailer_types.ts +++ b/frontend/src/lib/api/dashboard/reference_data/trailer_types.ts @@ -16,8 +16,14 @@ export interface TrailerTypeListResponse { } export const trailerTypesApi = { - list: (page = 1, pageSize = 100) => + /** + * Lista los tipos de trailer con paginación + * @param companyId - ID de la empresa (requerido por RBAC) + * @param page - Número de página + * @param pageSize - Tamaño de página + */ + list: (companyId: number, page = 1, pageSize = 100) => api.get( - `/v1/public/reference_data/trailer-types/?page=${page}&page_size=${pageSize}` + `/v1/public/reference_data/trailer-types/?company_id=${companyId}&page=${page}&page_size=${pageSize}` ) }; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte index 6d9f8b47..59019343 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte @@ -4,6 +4,7 @@ import { Input } from '$lib/components/ui/input'; import { Loader2, Search } from 'lucide-svelte'; import { statesApi, type State } from '$lib/api/dashboard/reference_data/states'; + import { companyStore } from '$lib/stores/company.svelte'; import { m } from '$lib/i18n/messages'; let { @@ -28,7 +29,8 @@ loading = true; error = ''; try { - const response = await statesApi.list(1, 100); + const companyId = companyStore.activeCompany?.id || 1; + const response = await statesApi.list(companyId, 1, 100); if (response.data?.items) { items = response.data.items; filteredItems = items; diff --git a/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte b/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte index 9fe0b478..2403b9c1 100644 --- a/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte @@ -5,6 +5,7 @@ import * as Table from '$lib/components/ui/table'; import { Search, Loader2, MapPin } from 'lucide-svelte'; import { statesApi, type State } from '$lib/api/dashboard/reference_data/states'; + import { companyStore } from '$lib/stores/company.svelte'; import { toast } from 'svelte-sonner'; // --- PROPS --- @@ -102,7 +103,8 @@ // Note: statesApi.list takes page, pageSize, and searchTerm? // Wait, let me check statesApi.list signature again. // It only takes page and pageSize! I need to check if it supports search. - const response = await statesApi.list(page, pageSize); + const companyId = companyStore.activeCompany?.id || 1; + const response = await statesApi.list(companyId, page, pageSize); if (response.error) { toast.error(`Error: ${response.error}`); diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte index 0bbb617d..55154ecf 100644 --- a/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte @@ -11,6 +11,7 @@ import { companyStore } from '$lib/stores/company.svelte'; import { browser } from '$app/environment'; import { onMount, onDestroy } from 'svelte'; + import { getTrailerTypeDescription } from '$lib/i18n/trailer-types'; let { open = $bindable(false), @@ -53,7 +54,7 @@ ]; const countryM3 = $derived( - countries.find((c) => c.ame_key === formData.country)?.m3_key ?? null + countries.find((c) => c.ame_key === formData.country || c.m3_key === formData.country)?.m3_key ?? null ); const statesFiltered = $derived( @@ -74,19 +75,31 @@ }; } - async function loadReferenceData() { + async function loadReferenceData(companyId: number) { if (!browser) return; refsLoading = true; try { - const [tt, cc, ss] = await Promise.all([ - trailerTypesApi.list(1, 100), - countriesApi.list(1, 100), - statesApi.list(1, 100) + const [tt, cc] = await Promise.all([ + trailerTypesApi.list(companyId, 1, 100), + countriesApi.list(companyId, 1, 100) ]); - if (tt.data?.items) trailerTypes = tt.data.items; - if (cc.data?.items) countries = cc.data.items; - if (ss.data?.items) states = ss.data.items; - } catch { + if (tt.data?.items) trailerTypes = [...tt.data.items]; + if (cc.data?.items) countries = [...cc.data.items]; + + // Si ya tenemos país, cargar sus estados + if (formData.country) { + const cM3 = countries.find(c => c.ame_key === formData.country || c.m3_key === formData.country)?.m3_key; + if (cM3) { + const ss = await statesApi.list(companyId, 1, 100, undefined, cM3); + if (ss.data?.items) states = [...ss.data.items]; + } + } else { + // Cargar algunos estados por defecto (opcional) + const ss = await statesApi.list(companyId, 1, 100); + if (ss.data?.items) states = [...ss.data.items]; + } + } catch (e) { + console.error('Error loading reference data:', e); trailerTypes = []; countries = []; states = []; @@ -95,18 +108,43 @@ } } + // Efecto para recargar estados cuando cambia el país + $effect(() => { + const companyId = companyStore.activeCompany?.id; + if (!browser || !companyId || !formData.country) return; + + const cM3 = countries.find(c => c.ame_key === formData.country || c.m3_key === formData.country)?.m3_key; + if (cM3) { + void statesApi.list(companyId, 1, 100, undefined, cM3).then(res => { + if (res.data?.items) { + states = [...res.data.items]; + } + }); + } + }); + $effect(() => { if (!open) { error = null; loading = false; return; } + + const companyId = companyStore.activeCompany?.id; + if (!companyId) return; + if (item) { - formData = { ...item }; + formData = { + ...item, + state: item.state ?? '', + country: item.country ?? '', + trailer_type_key: item.trailer_type_key ?? '' + }; } else { formData = emptyTrailerForm(); } - void loadReferenceData(); + + void loadReferenceData(companyId); }); async function handleSubmit() { @@ -222,7 +260,7 @@ {refsLoading ? 'Cargando tipos...' : formData.trailer_type_key - ? `${formData.trailer_type_key} — ${trailerTypes.find((t) => t.trailer_type_key === formData.trailer_type_key)?.description ?? ''}` + ? `${formData.trailer_type_key} — ${getTrailerTypeDescription(formData.trailer_type_key, trailerTypes.find((t) => t.trailer_type_key === formData.trailer_type_key)?.description ?? '')}` : '— Sin tipo (opcional) —'} @@ -231,7 +269,9 @@ {t.trailer_type_key} {#if t.description} - — {t.description} + + — {getTrailerTypeDescription(t.trailer_type_key, t.description)} {/if} {/each} diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte index 6d05d2ba..3f6b7892 100644 --- a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte @@ -65,11 +65,12 @@ async function loadReferenceData() { if (!browser) return; + const companyId = companyStore.activeCompany?.id || 1; refsLoading = true; try { const [cc, ss] = await Promise.all([ - countriesApi.list(1, 100), - statesApi.list(1, 100) + countriesApi.list(companyId, 1, 100), + statesApi.list(companyId, 1, 100) ]); if (cc.data?.items) countries = cc.data.items; if (ss.data?.items) states = ss.data.items; diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte index b3b57717..73189a91 100644 --- a/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte @@ -90,16 +90,26 @@ refsLoading = true; const cid = companyStore.activeCompany.id; try { - const [tr, tt, cc, ss] = await Promise.all([ + const [tr, tt, cc] = await Promise.all([ transportersApi.list(cid, { page: 1, page_size: 100 }), transportTypesApi.list(cid, 1, 100), - countriesApi.list(1, 100), - statesApi.list(1, 100) + countriesApi.list(cid, 1, 100) ]); if (tr.data?.items) transporters = tr.data.items; if (tt.data?.items) transportTypes = tt.data.items; if (cc.data?.items) countries = cc.data.items; - if (ss.data?.items) states = ss.data.items; + + // Si ya tenemos país, cargar sus estados + if (formData.country) { + const cM3 = countries.find(c => c.ame_key === formData.country)?.m3_key; + if (cM3) { + const ss = await statesApi.list(cid, 1, 100, undefined, cM3); + if (ss.data?.items) states = ss.data.items; + } + } else { + const ss = await statesApi.list(cid, 1, 100); + if (ss.data?.items) states = ss.data.items; + } } catch { transporters = []; transportTypes = []; @@ -110,6 +120,21 @@ } } + // Efecto para recargar estados cuando cambia el país + $effect(() => { + const cid = companyStore.activeCompany?.id; + if (!browser || !cid || !formData.country) return; + + const cM3 = countries.find(c => c.ame_key === formData.country)?.m3_key; + if (cM3) { + void statesApi.list(cid, 1, 100, undefined, cM3).then(res => { + if (res.data?.items) { + states = res.data.items; + } + }); + } + }); + function emptyVehicleForm(): Vehicle { return { vehicle_key: '', diff --git a/frontend/src/lib/i18n/trailer-types.ts b/frontend/src/lib/i18n/trailer-types.ts new file mode 100644 index 00000000..28889cdc --- /dev/null +++ b/frontend/src/lib/i18n/trailer-types.ts @@ -0,0 +1,65 @@ +import { getLocale } from '$lib/paraglide/runtime'; + +/** + * Mapeo de traducciones para el catálogo de Tipos de Trailer (GTipoTrailer). + * Se usa la clave (trailer_type_key) para obtener la descripción en español. + */ +const TRAILER_TYPE_ES: Record = { + '20': 'Contenedor marítimo de 20 pies - Techo abierto', + '2B': 'Contenedor marítimo de 20 pies - Techo cerrado', + '40': 'Contenedor marítimo de 40 pies - Techo abierto', + '4B': 'Contenedor marítimo de 40 pies - Techo cerrado', + 'BI': 'Remolque para bebidas', + 'CB': 'Remolque de cuello de ganso', + 'CH': 'Chasis', + 'CL': 'Contenedor marítimo de otra longitud - Techo cerrado', + 'CU': 'Contenedor marítimo de otra longitud - Techo abierto', + 'CZ': 'Contenedor refrigerado', + 'DD': 'Remolque de doble caída', + 'DT': 'Remolque de caída trasera', + 'FR': 'Remolque flat rack', + 'FT': 'Plataforma / Cama plana', + 'HC': 'Remolque tolva (cubierto)', + 'HE': 'Remolque para caballos', + 'HO': 'Remolque tolva (abierto)', + 'HP': 'Remolque tolva (descarga neumática cubierto)', + 'L1': 'Pipa / Tanque (líquidos) no caldeado / no aislado', + 'L2': 'Pipa / Tanque (líquidos) caldeado / no aislado', + 'L3': 'Pipa / Tanque (líquidos) no caldeado / aislado', + 'L4': 'Pipa / Tanque (líquidos) caldeado / aislado', + 'LP': 'Remolque para troncos / tubería / postes', + 'LT': 'Remolque para ganado', + 'NC': 'Sin equipo', + 'OE': 'Otro', + 'RD': 'Remolque de rack fijo / doble caída', + 'RG': 'Góndola cerrada', + 'RO': 'Góndola abierta', + 'RS': 'Remolque de rack fijo / caída simple', + 'SD': 'Remolque de caída simple', + 'T1': 'Pipa / Tanque (gas) no caldeado / no aislado', + 'T2': 'Pipa / Tanque (gas) caldeado / no aislado', + 'T3': 'Pipa / Tanque (gas) no caldeado / aislado', + 'T4': 'Pipa / Tanque (gas) caldeado / aislado', + 'T5': 'Pipa / Tanque (químicos) no caldeado / no aislado', + 'T6': 'Pipa / Tanque (químicos) caldeado / no aislado', + 'T7': 'Pipa / Tanque (químicos) no caldeado / aislado', + 'T8': 'Pipa / Tanque (químicos) caldeado / aislado', + 'TC': 'Portavehículos / Nodriza', + 'TK': 'Pipa / Tanque (líquidos grado alimenticio)', + 'TL': 'Semirremolque', + 'TW': 'Remolque de temperatura controlada' +}; + +/** + * Obtiene la descripción traducida de un tipo de trailer. + * @param key Clave del tipo de trailer (ej: '20', 'FT') + * @param fallback Descripción original por si no hay traducción + * @returns La descripción en el idioma activo + */ +export function getTrailerTypeDescription(key: string, fallback: string = ''): string { + const locale = getLocale(); + if (locale.startsWith('es')) { + return TRAILER_TYPE_ES[key] || fallback; + } + return fallback; +} From 52577b9e6663bad0852f6bf4e41d25a6e22df360 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 11 May 2026 17:17:37 -0500 Subject: [PATCH 10/11] refactor: update SSO handling and remove deprecated components - Changed the hub-net network configuration to external in docker-compose. - Removed the Single Sign-On (SSO) service implementation and associated login form component. - Enhanced authentication callback logic to improve error handling and redirect management. - Updated various routes to streamline login and authentication processes, ensuring proper redirection to workspace login. - Cleaned up unused code and improved overall structure for better maintainability. --- docker-compose.yml | 2 +- frontend/src/lib/components/login-form.svelte | 383 ------------------ frontend/src/lib/server/workspace-auth.ts | 157 +++++++ frontend/src/lib/sso.ts | 139 ------- frontend/src/routes/+page.server.ts | 2 +- .../src/routes/auth/callback/+page.server.ts | 32 +- frontend/src/routes/auth/sso/+page.server.ts | 39 +- .../src/routes/dashboard/+layout.server.ts | 20 +- .../routes/dashboard/invoices/+page.server.ts | 6 +- frontend/src/routes/login/+page.server.ts | 110 ++--- frontend/src/routes/login/+page.svelte | 10 +- frontend/src/routes/logout/+server.ts | 30 +- 12 files changed, 266 insertions(+), 664 deletions(-) delete mode 100644 frontend/src/lib/components/login-form.svelte create mode 100644 frontend/src/lib/server/workspace-auth.ts delete mode 100644 frontend/src/lib/sso.ts diff --git a/docker-compose.yml b/docker-compose.yml index 28b35572..7b232c1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -313,5 +313,5 @@ networks: driver: bridge hub-net: - external: false + external: true name: aduanasoft-hub_default diff --git a/frontend/src/lib/components/login-form.svelte b/frontend/src/lib/components/login-form.svelte deleted file mode 100644 index cf51a8be..00000000 --- a/frontend/src/lib/components/login-form.svelte +++ /dev/null @@ -1,383 +0,0 @@ - - -
- - - -
{ - // Interceptar solo en el paso 1 antes de hacer el submit real - if (!readyToSubmit) { - cancel(); - loading = true; - tenants = await fetchTenants(); - loading = false; - if (tenants.length === 1) { - // 1 sola org: login directo - tenantSlug = tenants[0].slug; - readyToSubmit = true; - await tick(); // esperar a que el DOM refleje tenantSlug antes de enviar - formEl?.requestSubmit(); - } else if (tenants.length > 1) { - // Varias orgs: mostrar selector - step = 2; - } else if (discoveryError) { - // Error claro del Hub (sin licencia, credenciales inválidas, etc.) - // No hacer submit — el error ya se muestra en discoveryError - } else { - // 0 orgs sin error: enviar igual, el backend rechazará - readyToSubmit = true; - await tick(); - formEl?.requestSubmit(); - } - return; - } - loading = true; - return async ({ update, result }) => { - await update({ reset: false }); - loading = false; - readyToSubmit = false; - if (result.type === 'failure') { - clearClientCookies(); - step = 1; - } - }; - }} - > - - -
- Anexo 76 -
-

Anexo 76

-

- Sistema de Cumplimiento Fiscal y Aduanal -

-
-
- - {#if error} -
- - - - {error} -
- {/if} - - - - {#if step === 2} - - - {/if} - - {#if step === 1} - - - Usuario - - - - - - - - - - - - -
-
- o continúa con -
-
- - - - - - -

- ¿No tienes cuenta?{' '} - - Regístrate - -

- {:else} - -
-

Selecciona tu organización

-

Tu cuenta tiene acceso a varias organizaciones

-
- -
- {#each tenants as t} - - {/each} -
- - - - - - - {/if} -
-
- - - -
-
-

- Al continuar, aceptas nuestros - Términos de Servicio - y - Política de Privacidad. -

-
diff --git a/frontend/src/lib/server/workspace-auth.ts b/frontend/src/lib/server/workspace-auth.ts new file mode 100644 index 00000000..317b045a --- /dev/null +++ b/frontend/src/lib/server/workspace-auth.ts @@ -0,0 +1,157 @@ +import { env } from '$env/dynamic/private'; +import { redirect, type Cookies } from '@sveltejs/kit'; + +const DEFAULT_WORKSPACE_BASE_URL = 'https://workspace.aduanasoft.com'; +const RETURN_PATH_COOKIE = 'workspace_return_path'; + +function stripTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ''); +} + +function isInternalOnlyHost(rawUrl: string): boolean { + try { + const parsed = new URL(rawUrl); + const host = parsed.hostname.toLowerCase(); + return host === 'host.docker.internal' || host === 'backend' || host === 'hub-keycloak'; + } catch { + return false; + } +} + +export function getWorkspaceBaseUrl(): string { + const candidates = [ + (env.VITE_HUB_URL || '').trim(), + (env.HUB_URL || '').trim(), + DEFAULT_WORKSPACE_BASE_URL + ].filter(Boolean); + + for (const candidate of candidates) { + if (!isInternalOnlyHost(candidate)) { + return stripTrailingSlashes(candidate); + } + } + + return DEFAULT_WORKSPACE_BASE_URL; +} + +export type WorkspaceLoginUrlOptions = { + /** + * URL del login del Hub sin `return_to`. Usar en `post_logout_redirect_uri` para que, + * tras logout en KC, el Hub aplique myApps() (launcher si el usuario tiene varias apps). + * Con `return_to` a Anexo76, el re-login siempre rebotaba a esa app aunque hubiera más. + */ + forPostLogout?: boolean; +}; + +export function getWorkspaceLoginUrl( + systemBaseUrl: string, + options?: WorkspaceLoginUrlOptions +): string { + const workspaceBaseUrl = getWorkspaceBaseUrl(); + if (options?.forPostLogout) { + return `${workspaceBaseUrl}/login`; + } + // return_to points to /login so that after Workspace auth the browser lands on + // /login, which immediately attempts a prompt=none KC auth. + const loginUrl = `${systemBaseUrl}/login`; + return `${workspaceBaseUrl}/login?return_to=${encodeURIComponent(loginUrl)}`; +} + +export function storeReturnPath(cookies: Cookies, path: string): void { + if (!path || !path.startsWith('/')) return; + cookies.set(RETURN_PATH_COOKIE, path, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: env.NODE_ENV === 'production', + maxAge: 60 * 10 + }); +} + +export function getPublicKeycloakBaseUrl(): string { + const configuredKeycloakUrl = (env.VITE_KEYCLOAK_URL || '').trim(); + if (configuredKeycloakUrl) { + return stripTrailingSlashes(configuredKeycloakUrl); + } + + return `${getWorkspaceBaseUrl()}/kcauth`; +} + +export function getKeycloakRealm(): string { + return (env.KEYCLOAK_REALM || env.VITE_KEYCLOAK_REALM || 'master').trim(); +} + +export function getKeycloakClientId(): string { + return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'anexo76-frontend').trim(); +} + +export function getCleanReturnPath(url: URL): string { + const cleanParams = new URLSearchParams(url.searchParams); + cleanParams.delete('sso_verified'); + + const queryString = cleanParams.toString(); + return queryString ? `${url.pathname}?${queryString}` : url.pathname; +} + +export function storeWorkspaceReturnPath(cookies: Cookies, url: URL): string { + const returnPath = getCleanReturnPath(url); + + cookies.set(RETURN_PATH_COOKIE, returnPath, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: env.NODE_ENV === 'production', + maxAge: 60 * 10 + }); + + return returnPath; +} + +export function readWorkspaceReturnPath(cookies: Cookies, fallbackPath: string): string { + const storedReturnPath = cookies.get(RETURN_PATH_COOKIE); + if (storedReturnPath && storedReturnPath.startsWith('/')) { + return storedReturnPath; + } + + return fallbackPath; +} + +export function clearWorkspaceReturnPath(cookies: Cookies): void { + cookies.delete(RETURN_PATH_COOKIE, { path: '/' }); +} + +export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPath: string): string { + const keycloakBaseUrl = getPublicKeycloakBaseUrl(); + const redirectUri = `${systemBaseUrl}/auth/callback`; + const state = JSON.stringify({ redirect_url: redirectPath }); + const params = new URLSearchParams({ + client_id: getKeycloakClientId(), + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid', + prompt: 'none', + state + }); + + return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`; +} + +export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never { + storeWorkspaceReturnPath(cookies, url); + throw redirect(303, getWorkspaceLoginUrl(url.origin)); +} + +export function redirectToKeycloakAuthorization(systemBaseUrl: string, redirectPath: string): never { + throw redirect(303, buildKeycloakAuthorizationUrl(systemBaseUrl, redirectPath)); +} + +export function buildKeycloakLogoutUrl(systemBaseUrl: string): string { + const keycloakBaseUrl = getPublicKeycloakBaseUrl(); + const workspaceLoginUrl = getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }); + const params = new URLSearchParams({ + client_id: getKeycloakClientId(), + post_logout_redirect_uri: workspaceLoginUrl + }); + + return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/logout?${params.toString()}`; +} \ No newline at end of file diff --git a/frontend/src/lib/sso.ts b/frontend/src/lib/sso.ts deleted file mode 100644 index cb67ee25..00000000 --- a/frontend/src/lib/sso.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Servicio de Single Sign-On (SSO) con proveedores externos - */ -import { browser } from '$app/environment'; - -// Tipos de proveedores SSO soportados -export type SSOProvider = 'microsoft' | 'google' | 'github'; - -/** - * Inicia el flujo de autenticación con un proveedor SSO - * @param provider - El proveedor SSO a utilizar - */ -export const loginWithProvider = async (provider: SSOProvider): Promise => { - if (!browser) { - return; - } - - try { - // Construir la URL de redirección al proveedor SSO - const keycloakUrl = import.meta.env.VITE_KEYCLOAK_URL; - const realm = import.meta.env.VITE_KEYCLOAK_REALM; - const clientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID; - - // Validar que las variables de entorno estén configuradas - if (!keycloakUrl || !realm || !clientId) { - const missing = []; - if (!keycloakUrl) missing.push('VITE_KEYCLOAK_URL'); - if (!realm) missing.push('VITE_KEYCLOAK_REALM'); - if (!clientId) missing.push('VITE_KEYCLOAK_CLIENT_ID'); - - const errorMsg = `Configuración de Keycloak incompleta. Faltan las siguientes variables de entorno: ${missing.join(', ')}. Por favor, verifica tu archivo .env y reinicia el servidor de desarrollo.`; - console.error(errorMsg); - alert(errorMsg); - throw new Error(errorMsg); - } - - const redirectUri = encodeURIComponent(window.location.origin + '/auth/callback'); - - // URL de login de Keycloak con el provider específico - const loginUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid&kc_idp_hint=${provider}`; - - // Redirigir al usuario al proveedor SSO - window.location.href = loginUrl; - } catch (error) { - console.error(`Error al iniciar login con ${provider}:`, error); - throw error; - } -}; - -/** - * Obtiene la lista de proveedores SSO disponibles - * Esta función podría consultar a Keycloak para obtener los providers configurados - */ -export const getAvailableProviders = async (): Promise => { - // Por ahora retornamos una lista estática - // En producción, esto debería consultarse desde Keycloak - return ['microsoft', 'google', 'github']; -}; - -/** - * Obtiene la configuración de visualización para un proveedor - */ -export const getProviderConfig = (provider: SSOProvider) => { - const configs = { - microsoft: { - name: 'Microsoft', - icon: '🪟', - color: 'bg-blue-600 hover:bg-blue-700' - }, - google: { - name: 'Google', - icon: '🔍', - color: 'bg-red-600 hover:bg-red-700' - }, - github: { - name: 'GitHub', - icon: '🐙', - color: 'bg-gray-800 hover:bg-gray-900' - } - }; - - return configs[provider]; -}; - -/** - * Intercambia el código de autorización por tokens - */ -export const exchangeCodeForTokens = async ( - code: string, - redirectUri: string -): Promise<{ access_token: string; refresh_token: string; id_token?: string }> => { - try { - const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/'; - const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL : `${API_BASE_URL}/`; - - const response = await fetch(`${baseUrl}v1/auth/exchange-code`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - code, - redirect_uri: redirectUri - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || 'Error intercambiando código por tokens'); - } - - return await response.json(); - } catch (error) { - console.error('Error en exchangeCodeForTokens:', error); - throw error; - } -}; - -/** - * Decodifica un JWT (sin verificar la firma) - * NOTA: Esta es una decodificación simple para obtener los claims. - * La verificación de la firma debe hacerse en el backend. - */ -export const decodeJWT = (token: string): any => { - try { - const parts = token.split('.'); - if (parts.length !== 3) { - throw new Error('Token JWT inválido'); - } - - // Decodificar la parte del payload (segunda parte) - const payload = parts[1]; - const decodedPayload = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); - return JSON.parse(decodedPayload); - } catch (error) { - console.error('Error decodificando JWT:', error); - return null; - } -}; diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 36adcfa4..ba19381a 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -32,7 +32,7 @@ export const load: PageServerLoad = async ({ cookies, fetch }) => { clearAuthTokens(cookies); } } - + // Si no está autenticado, mostrar la página principal pública return { isAuthenticated: false diff --git a/frontend/src/routes/auth/callback/+page.server.ts b/frontend/src/routes/auth/callback/+page.server.ts index 13e5e20a..d6d3e80b 100644 --- a/frontend/src/routes/auth/callback/+page.server.ts +++ b/frontend/src/routes/auth/callback/+page.server.ts @@ -1,6 +1,12 @@ -import { redirect } from '@sveltejs/kit'; +import { redirect, isRedirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; import { setAccessTokenCookies } from '$lib/server/access-token-cookie'; +import { + clearWorkspaceReturnPath, + getWorkspaceLoginUrl, + readWorkspaceReturnPath, + storeReturnPath, +} from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ url, cookies, fetch }) => { // Obtener el código y state de los query params @@ -10,13 +16,24 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => { const errorDescription = url.searchParams.get('error_description'); if (errorParam) { - console.error('❌ [Callback Server] Error en autenticación:', errorParam, errorDescription); - throw redirect(303, `/login?error=${encodeURIComponent(errorDescription || errorParam)}`); + console.error('❌ [Callback Server] KC auth error:', errorParam, errorDescription); + // login_required means no KC session exists yet → send to Workspace login. + // Preserve the intended destination through the detour so /login can pick it up. + if (state) { + try { + const stateObj = JSON.parse(state); + const returnPath = stateObj.redirect_url; + if (returnPath && returnPath.startsWith('/') && returnPath !== '/login') { + storeReturnPath(cookies, returnPath); + } + } catch { /* ignore malformed state */ } + } + throw redirect(303, getWorkspaceLoginUrl(url.origin)); } if (!code) { console.error('❌ [Callback Server] No se recibió código de autorización'); - throw redirect(303, '/login?error=No se recibió código de autorización'); + throw redirect(303, getWorkspaceLoginUrl(url.origin)); } try { @@ -78,7 +95,7 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => { } // Obtener la URL de redirección del state o ir al dashboard - let redirectTo = '/dashboard'; + let redirectTo = readWorkspaceReturnPath(cookies, '/dashboard'); if (state) { try { const stateObj = JSON.parse(state); @@ -87,12 +104,15 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => { console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state'); } } + + clearWorkspaceReturnPath(cookies); // Redirigir a la página de destino throw redirect(303, redirectTo); } catch (err: any) { + if (isRedirect(err)) throw err; console.error('❌ [Callback Server] Error procesando autenticación:', err); - throw redirect(303, `/login?error=${encodeURIComponent(err.message || 'Error procesando autenticación')}`); + throw redirect(303, getWorkspaceLoginUrl(url.origin)); } }; diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index 4c045a9a..9cced10e 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -7,6 +7,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'; // Disable client-side rendering to prevent SvelteKit from making a second // __data.json request that would consume the one-time relay token twice. @@ -17,7 +18,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => { console.log('[SSO] relay token presente:', !!relayToken); if (!relayToken) { - throw redirect(303, '/login?error=sso_missing_token'); + redirectToWorkspaceLogin(cookies, url); } // Limpiar sesión anterior para que el nuevo usuario reciba sus propias cookies. @@ -51,7 +52,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => { body: JSON.stringify({ relay_token: relayToken }), }); } catch (err) { - throw redirect(303, '/login?error=sso_hub_unreachable'); + redirectToWorkspaceLogin(cookies, url); } if (!response.ok) { @@ -72,10 +73,10 @@ export const load: PageServerLoad = async ({ url, cookies }) => { throw redirect(303, '/dashboard'); } - throw redirect(303, `/login?error=${encodeURIComponent(detail)}`); + redirectToWorkspaceLogin(cookies, url); } - const tokens = await response.json(); + let tokens = await response.json(); console.log('[SSO] exchange exitoso, tokens recibidos:', { hasAccessToken: !!tokens.access_token, accessTokenLen: tokens.access_token?.length, @@ -84,6 +85,36 @@ export const load: PageServerLoad = async ({ url, cookies }) => { tenant_slug: tokens.tenant_slug, }); + // ── Refresh proactivo ──────────────────────────────────────────────────── + // Los tokens del relay fueron emitidos por KC via el browser (iss=IP:8085). + // El Hub backend valida contra KC interno (hub-keycloak:8080) → issuer mismatch → 401. + // Refrescando aquí: Anexo76 backend → Hub → KC interno → iss=hub-keycloak:8080 → válido. + if (tokens.refresh_token) { + try { + const internalApiUrl = ( + process.env.INTERNAL_API_URL || + process.env.VITE_API_URL || + 'http://backend:8000/api/' + ).replace(/\/+$/, ''); + const refreshRes = await fetch(`${internalApiUrl}/v1/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: tokens.refresh_token }), + }); + if (refreshRes.ok) { + const refreshed = await refreshRes.json(); + if (refreshed.access_token && refreshed.refresh_token) { + tokens = { ...tokens, ...refreshed }; + console.log('[SSO] tokens refrescados exitosamente (iss normalizado)'); + } + } else { + console.warn('[SSO] refresh proactivo falló (status', refreshRes.status, ') — usando tokens originales del relay'); + } + } catch (err) { + console.warn('[SSO] refresh proactivo error (non-blocking):', err); + } + } + const isProduction = process.env.NODE_ENV === 'production'; console.log('[SSO] NODE_ENV:', process.env.NODE_ENV, '→ isProduction:', isProduction); diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index c0756033..68bdd38c 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -7,21 +7,22 @@ import { getUserCompanies, clearAuthTokens } from '$lib/server/api'; +import { + redirectToWorkspaceLogin +} from '$lib/server/workspace-auth'; export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // Verificar si existe el token en las cookies const { accessToken } = getAuthTokens(cookies); console.log('[dashboard layout] access_token presente:', !!accessToken, '| url:', url.pathname); - // Si no hay token, redirigir al login, pero excluir la ruta /login para evitar bucle - if (!accessToken && url.pathname !== '/login') { - const redirectUrl = `/login?redirect=${encodeURIComponent(url.pathname)}`; - throw redirect(303, redirectUrl); + if (!accessToken) { + redirectToWorkspaceLogin(cookies, url); } // Validar el token con el backend y obtener datos del usuario // La función validateAuth maneja automáticamente el refresh de tokens - const redirectOnFail = `/login?redirect=${encodeURIComponent(url.pathname)}`; + const redirectOnFail = undefined; try { // Primero my-companies: ejecuta get_current_user y puede crear tenant/empresa/usuario @@ -75,15 +76,8 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { throw error; } - // Si estamos ya en la página de login, no intentar redirigir de nuevo - if (url.pathname === '/login') { - console.error('🔐 [Dashboard] Error validando token en login page, limpiando cookies.'); - clearAuthTokens(cookies); - return { authenticated: false, error: error }; - } - // Para cualquier otro error (conexión, etc), limpiar token y redirigir clearAuthTokens(cookies); - throw redirect(303, redirectOnFail); + redirectToWorkspaceLogin(cookies, url); } }; diff --git a/frontend/src/routes/dashboard/invoices/+page.server.ts b/frontend/src/routes/dashboard/invoices/+page.server.ts index 12ce133f..aabc158e 100644 --- a/frontend/src/routes/dashboard/invoices/+page.server.ts +++ b/frontend/src/routes/dashboard/invoices/+page.server.ts @@ -21,7 +21,7 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => { const accessToken = tokens.accessToken; if (!accessToken) { - throw redirect(302, '/auth/login'); + throw redirect(302, '/login'); } try { @@ -75,14 +75,14 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => { {}, cookies, fetch, - '/auth/login' + '/login' ), authenticatedFetch( 'v1/public/reference_data/invoice-types?page=1&page_size=100', {}, cookies, fetch, - '/auth/login' + '/login' ) ]); diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index a96b687a..3b159fa0 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -1,85 +1,37 @@ -import { redirect, fail } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import { clearAuthTokens, setAuthTokens, getServerApiUrl } from '$lib/server/api'; - - +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { clearAuthTokens } from '$lib/server/api'; +import { + getWorkspaceLoginUrl, + readWorkspaceReturnPath, + storeReturnPath, + redirectToKeycloakAuthorization +} from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ cookies, url }) => { - // Si hay un parámetro 'logout' en la URL, limpiar las cookies - if (url.searchParams.has('logout')) { - clearAuthTokens(cookies); - return {}; - } - - // Limpiar siempre las cookies de sesión anterior al cargar login - // Esto evita que se queden datos del tenant anterior clearAuthTokens(cookies); - // Permitir acceso al login sin redirigir automáticamente - // Esto evita bucles de redirección cuando el token existe pero puede estar expirado - return {}; -}; + // Workspace redirige de vuelta aquí con ?sso_verified=1 después de que el usuario + // se autenticó en Workspace (que usa el mismo Keycloak central). + // En ese momento la sesión KC ya existe en el browser → prompt=none funciona sin + // mostrar ninguna pantalla de login. + if (url.searchParams.get('sso_verified') === '1') { + const existingReturnPath = readWorkspaceReturnPath(cookies, ''); + const intendedPath = + existingReturnPath && existingReturnPath !== '/login' + ? existingReturnPath + : (url.searchParams.get('redirect') || '/dashboard'); -export const actions = { - default: async ({ request, cookies, url, fetch }) => { - const data = await request.formData(); - const username = data.get('username')?.toString(); - const password = data.get('password')?.toString(); - const tenant_slug = data.get('tenant_slug')?.toString(); - - if (!username || !password || !tenant_slug) { - return fail(400, { error: 'Credenciales incorrectas' }); - } - - try { - const apiUrl = getServerApiUrl(); - const loginUrl = `${apiUrl}v1/auth/login`; - - const requestBody = { - username, - password, - tenant_slug - }; - - const response = await fetch(loginUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(requestBody) - }); - - const result = await response.json(); - - if (!response.ok) { - return fail(response.status, { - error: result.detail || 'Error de autenticación', - username, - tenant_slug - }); - } - - if (result.access_token) { - // Establecer tokens usando la función centralizada - setAuthTokens(cookies, result.access_token, result.refresh_token); - - // Redirigir al dashboard o a la URL original - const redirectUrl = url.searchParams.get('redirect') || '/dashboard'; - throw redirect(303, redirectUrl); - } - - return fail(500, { error: 'No se recibió token de autenticación' }); - } catch (error) { - // Si es un redirect de SvelteKit, re-lanzarlo - if (error && typeof error === 'object' && 'status' in error && 'location' in error) { - throw error; - } - - return fail(500, { - error: 'Error de conexión con el servidor: ' + (error instanceof Error ? error.message : String(error)), - username, - tenant_slug - }); - } + storeReturnPath(cookies, intendedPath); + redirectToKeycloakAuthorization(url.origin, intendedPath); } -} satisfies Actions; + + // Sin sso_verified → primera visita o sesión expirada. + // Guardar la ruta deseada y mandar al Workspace a autenticar. + const intendedPath = url.searchParams.get('redirect') || '/dashboard'; + if (intendedPath !== '/dashboard') { + storeReturnPath(cookies, intendedPath); + } + + throw redirect(303, getWorkspaceLoginUrl(url.origin)); +}; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index eabd843a..bb1b2ec5 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -1,9 +1 @@ - - -
-
- -
-
+ diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index fbf58b2f..0b0f6474 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -1,16 +1,10 @@ import { redirect } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; import type { RequestHandler } from './$types'; import { clearAccessTokenCookies } from '$lib/server/access-token-cookie'; +import { buildKeycloakLogoutUrl, clearWorkspaceReturnPath } from '$lib/server/workspace-auth'; export const POST: RequestHandler = async ({ cookies, request }) => { - const refreshToken = cookies.get('refresh_token'); - - // Redirigir al workspace (Hub) — es el sistema central de autenticación. - const hubPublicUrl = (env.HUB_URL || '').replace(/\/+$/, ''); - const postLogoutUrl = hubPublicUrl - ? `${hubPublicUrl}/login` - : `${new URL(request.url).origin}/login`; + const systemBaseUrl = new URL(request.url).origin; // Eliminar todas las cookies de autenticación (access_token puede estar fragmentado) clearAccessTokenCookies(cookies); @@ -18,23 +12,7 @@ export const POST: RequestHandler = async ({ cookies, request }) => { cookies.delete('active_company_id', { path: '/' }); cookies.delete('sso_tenant_id', { path: '/' }); cookies.delete('sso_tenant_pub', { path: '/' }); + clearWorkspaceReturnPath(cookies); - // Llamar al Hub para revocar el refresh token (best-effort). - if (refreshToken) { - try { - const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); - await fetch(`${hubUrl}/api/v1/auth/logout`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - refresh_token: refreshToken, - post_logout_redirect_uri: postLogoutUrl, - }), - }); - } catch { - // Si falla la llamada al Hub, continuar de todos modos - } - } - - throw redirect(303, postLogoutUrl); + throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl)); }; From 42d7b876d1c5dbf13e9ef118a70254cfded4388f Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 12 May 2026 09:19:39 -0500 Subject: [PATCH 11/11] feat: add environment variables for SMTP configuration and update hub-net to internal --- docker-compose.prod.yml | 10 ++++++++++ docker-compose.yml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 45064db3..916ce963 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -180,6 +180,16 @@ services: - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - SPOKE_URLS=${SPOKE_URLS:-""} + - HUB_URL=${HUB_URL:-https://workspace.aduanasoft.com} + - HUB_ADMIN_EMAIL=${HUB_ADMIN_EMAIL:-} + - HUB_ADMIN_PASSWORD=${HUB_ADMIN_PASSWORD:-} + - APP_PUBLIC_URL=${APP_PUBLIC_URL:-https://anexo76-dev.aduanasoft.com} + - SMTP_HOST=${SMTP_HOST:-smtp.gmail.com} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-Sistema Anexo76} + - SMTP_USE_TLS=${SMTP_USE_TLS:-true} - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} diff --git a/docker-compose.yml b/docker-compose.yml index 7b232c1b..28b35572 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -313,5 +313,5 @@ networks: driver: bridge hub-net: - external: true + external: false name: aduanasoft-hub_default