Merge branch 'development' into feature/alembic-daf

This commit is contained in:
2026-05-12 09:23:19 -06:00
57 changed files with 2063 additions and 944 deletions

View File

@@ -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] = "ca7d3c4e8b2a"
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")

View File

@@ -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",
)

View File

@@ -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)

View File

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

View File

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

View File

@@ -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),

View File

@@ -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] = []

View File

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

View File

@@ -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(

View File

@@ -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()

View File

@@ -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()

View File

@@ -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(

View File

@@ -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):
@@ -119,6 +122,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()

View File

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

View File

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

View File

@@ -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,
},
)

View File

@@ -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()

View File

@@ -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}}

1
frontend/.gitignore vendored
View File

@@ -26,6 +26,7 @@ Thumbs.db
!.env.test
# Vite
.vite/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View File

@@ -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"
},

View File

@@ -331,18 +331,48 @@ async function fetchApi<T = any>(
// 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<ApiResponse['validationErrors']>;
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<string, string> = {
"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<T = any>(
if (data.errors && Array.isArray(data.errors)) {
return {
error: data.message || 'Error de validación',
validationErrors: validationErrors(data.errors),
validationErrors: data.errors as NonNullable<ApiResponse['validationErrors']>,
status: response.status
};
}
// Errores de validación de FastAPI (con detail)
else if (data.detail) {
let errorMessage = 'Error de validación: ';
const vErrors: NonNullable<ApiResponse['validationErrors']> = [];
// 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<T = any>(
return {
error: errorMessage,
validationErrors: vErrors.length ? vErrors : undefined,
status: response.status
};
}

View File

@@ -45,7 +45,7 @@ export const sectorsApi = {
},
get: (id: number, companyId: number) =>
api.get<Sector>(`/v1/a76/sectors/${id}/?company_id=${companyId}`),
api.get<Sector>(`/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<Sector>(`/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}`)
};

View File

@@ -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<StateListResponse>(url);
},

View File

@@ -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<TrailerTypeListResponse>(
`/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}`
)
};

View File

@@ -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<unknown>): string | null {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return null;
}
function pickText(...candidates: Array<unknown>): 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
});
}
};
};
@@ -116,6 +175,11 @@ export const initAuth = async (): Promise<boolean> => {
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) {
@@ -168,6 +232,7 @@ let previousTenantId: number | undefined = undefined;
const updateAuthState = async () => {
if (!keycloakInstance?.authenticated) {
authStore.reset();
persistUserInSession(null);
return;
}
@@ -191,14 +256,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);
@@ -372,15 +466,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) {
@@ -388,6 +541,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
// ─────────────────────────────────────────────────────────
@@ -410,6 +642,7 @@ export const logout = async () => {
// Limpiar estado en memoria
authStore.reset();
persistUserInSession(null);
clearAccessTokenOnDocument();
// La cookie HttpOnly del refresh_token la limpia el servidor

View File

@@ -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;

View File

@@ -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<Sector>[] {
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
});
}
}

View File

@@ -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 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
{#if canEdit}
<DropdownMenu.Item onclick={() => (alert('Módulo de edición no disponible para Catálogos Públicos'))}>
<DropdownMenu.Item onclick={() => onEdit?.(item)}>
<Pencil class="mr-2 size-4" />
Editar
</DropdownMenu.Item>
{/if}
{#if canDelete}
<DropdownMenu.Item onclick={() => (alert('Módulo de eliminación no disponible para Catálogos Públicos'))} class="text-destructive">
<DropdownMenu.Item onclick={() => onDelete?.(item)} class="text-destructive">
<Trash2 class="mr-2 size-4" />
Eliminar
</DropdownMenu.Item>

View File

@@ -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}`);

View File

@@ -288,7 +288,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl" onInteractOutside={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>

View File

@@ -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() {
@@ -168,7 +206,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl" onInteractOutside={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
@@ -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) —'}
</Select.Trigger>
<Select.Content>
@@ -231,7 +269,9 @@
<Select.Item value={t.trailer_type_key} label={t.trailer_type_key}>
{t.trailer_type_key}
{#if t.description}
<span class="text-muted-foreground"> — {t.description}</span>
<span class="text-muted-foreground">
— {getTrailerTypeDescription(t.trailer_type_key, t.description)}</span
>
{/if}
</Select.Item>
{/each}

View File

@@ -52,6 +52,7 @@
let loading = $state(false);
let error = $state<string | null>(null);
let fieldErrors = $state<Record<string, string>>({});
let countries = $state<Country[]>([]);
let states = $state<State[]>([]);
let refsLoading = $state(false);
@@ -65,11 +66,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;
@@ -109,6 +111,7 @@
$effect(() => {
if (!open) {
error = null;
fieldErrors = {};
loading = false;
return;
}
@@ -120,9 +123,89 @@
void loadReferenceData();
});
function validateForm(): boolean {
const errors: Record<string, string> = {};
// 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 +214,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 +222,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<string, string> = {};
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 +266,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl" onInteractOutside={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
@@ -214,14 +300,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}
<p class="text-xs text-destructive">{fieldErrors.transporter_key}</p>
{/if}
</div>
<div class="grid gap-2">
<Label for="name">Nombre / Razón Social</Label>
<Input id="name" bind:value={formData.name} maxlength={256} />
<Label for="name">Nombre / Razón Social <span class="text-destructive">*</span></Label>
<Input
id="name"
bind:value={formData.name}
aria-invalid={!!fieldErrors.name}
oninput={() => clearFieldError('name')}
maxlength={256}
/>
{#if fieldErrors.name}
<p class="text-xs text-destructive">{fieldErrors.name}</p>
{/if}
</div>
<div class="grid gap-2">
@@ -229,19 +329,42 @@
<Input
id="short_name"
bind:value={formData.short_name}
aria-invalid={!!fieldErrors.short_name}
oninput={() => clearFieldError('short_name')}
maxlength={10}
placeholder="Máx. 10 car."
/>
{#if fieldErrors.short_name}
<p class="text-xs text-destructive">{fieldErrors.short_name}</p>
{/if}
</div>
<div class="grid gap-2">
<Label for="rfc">RFC</Label>
<Input id="rfc" bind:value={formData.rfc} maxlength={30} />
<Input
id="rfc"
bind:value={formData.rfc}
aria-invalid={!!fieldErrors.rfc}
oninput={() => clearFieldError('rfc')}
maxlength={30}
/>
{#if fieldErrors.rfc}
<p class="text-xs text-destructive">{fieldErrors.rfc}</p>
{/if}
</div>
<div class="grid gap-2">
<Label for="responsible">Responsable</Label>
<Input id="responsible" bind:value={formData.responsible} maxlength={100} />
<Input
id="responsible"
bind:value={formData.responsible}
aria-invalid={!!fieldErrors.responsible}
oninput={() => clearFieldError('responsible')}
maxlength={100}
/>
{#if fieldErrors.responsible}
<p class="text-xs text-destructive">{fieldErrors.responsible}</p>
{/if}
</div>
</section>
@@ -251,7 +374,16 @@
<div class="grid gap-2">
<Label for="caat_code">Código CAAT</Label>
<Input id="caat_code" bind:value={formData.caat_code} maxlength={49} />
<Input
id="caat_code"
bind:value={formData.caat_code}
aria-invalid={!!fieldErrors.caat_code}
oninput={() => clearFieldError('caat_code')}
maxlength={49}
/>
{#if fieldErrors.caat_code}
<p class="text-xs text-destructive">{fieldErrors.caat_code}</p>
{/if}
</div>
<div class="grid gap-2">
@@ -259,9 +391,14 @@
<Input
id="transport_code"
bind:value={formData.transport_code}
aria-invalid={!!fieldErrors.transport_code}
oninput={() => clearFieldError('transport_code')}
maxlength={8}
placeholder="Máx. 8 car."
/>
{#if fieldErrors.transport_code}
<p class="text-xs text-destructive">{fieldErrors.transport_code}</p>
{/if}
</div>
<div class="grid gap-2">
@@ -269,9 +406,14 @@
<Input
id="loader_code"
bind:value={formData.loader_code}
aria-invalid={!!fieldErrors.loader_code}
oninput={() => clearFieldError('loader_code')}
maxlength={9}
placeholder="Máx. 9 car."
/>
{#if fieldErrors.loader_code}
<p class="text-xs text-destructive">{fieldErrors.loader_code}</p>
{/if}
</div>
<div class="grid gap-2">
@@ -279,13 +421,27 @@
<Input
id="transport_interface_type"
bind:value={formData.transport_interface_type}
aria-invalid={!!fieldErrors.transport_interface_type}
oninput={() => clearFieldError('transport_interface_type')}
maxlength={20}
/>
{#if fieldErrors.transport_interface_type}
<p class="text-xs text-destructive">{fieldErrors.transport_interface_type}</p>
{/if}
</div>
<div class="grid gap-2">
<Label for="filler_code">Código Relleno</Label>
<Input id="filler_code" bind:value={formData.filler_code} maxlength={20} />
<Input
id="filler_code"
bind:value={formData.filler_code}
aria-invalid={!!fieldErrors.filler_code}
oninput={() => clearFieldError('filler_code')}
maxlength={20}
/>
{#if fieldErrors.filler_code}
<p class="text-xs text-destructive">{fieldErrors.filler_code}</p>
{/if}
</div>
<div class="flex items-center justify-between gap-3 rounded-lg border p-4">
@@ -300,18 +456,44 @@
<div class="grid gap-2">
<Label for="streets">Calle y Número</Label>
<Input id="streets" bind:value={formData.streets} maxlength={100} />
<Input
id="streets"
bind:value={formData.streets}
aria-invalid={!!fieldErrors.streets}
oninput={() => clearFieldError('streets')}
maxlength={100}
/>
{#if fieldErrors.streets}
<p class="text-xs text-destructive">{fieldErrors.streets}</p>
{/if}
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="city">Ciudad</Label>
<Input id="city" bind:value={formData.city} maxlength={30} />
<Input
id="city"
bind:value={formData.city}
aria-invalid={!!fieldErrors.city}
oninput={() => clearFieldError('city')}
maxlength={30}
/>
{#if fieldErrors.city}
<p class="text-xs text-destructive">{fieldErrors.city}</p>
{/if}
</div>
<div class="grid gap-2">
<Label for="country">País (clave americana)</Label>
<Select.Root type="single" bind:value={formData.country} disabled={refsLoading}>
<Select.Trigger class="w-full" id="country">
<Select.Root
type="single"
bind:value={formData.country}
disabled={refsLoading}
onValueChange={() => clearFieldError('country')}
>
<Select.Trigger
class={fieldErrors.country ? 'border-destructive' : ''}
id="country"
>
{refsLoading
? '...'
: formData.country
@@ -327,14 +509,25 @@
{/each}
</Select.Content>
</Select.Root>
{#if fieldErrors.country}
<p class="text-xs text-destructive">{fieldErrors.country}</p>
{/if}
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="state">Estado / provincia</Label>
<Select.Root type="single" bind:value={formData.state} disabled={refsLoading}>
<Select.Trigger class="w-full" id="state">
<Select.Root
type="single"
bind:value={formData.state}
disabled={refsLoading}
onValueChange={() => clearFieldError('state')}
>
<Select.Trigger
class={fieldErrors.state ? 'border-destructive' : ''}
id="state"
>
{refsLoading
? '...'
: formData.state ||
@@ -349,10 +542,22 @@
{/each}
</Select.Content>
</Select.Root>
{#if fieldErrors.state}
<p class="text-xs text-destructive">{fieldErrors.state}</p>
{/if}
</div>
<div class="grid gap-2">
<Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} maxlength={15} />
<Input
id="postal_code"
bind:value={formData.postal_code}
aria-invalid={!!fieldErrors.postal_code}
oninput={() => clearFieldError('postal_code')}
maxlength={15}
/>
{#if fieldErrors.postal_code}
<p class="text-xs text-destructive">{fieldErrors.postal_code}</p>
{/if}
</div>
</div>
</section>
@@ -363,23 +568,60 @@
<div class="grid gap-2">
<Label for="ftp_server">Servidor FTP</Label>
<Input id="ftp_server" bind:value={formData.ftp_server} maxlength={200} />
<Input
id="ftp_server"
bind:value={formData.ftp_server}
aria-invalid={!!fieldErrors.ftp_server}
oninput={() => clearFieldError('ftp_server')}
maxlength={200}
/>
{#if fieldErrors.ftp_server}
<p class="text-xs text-destructive">{fieldErrors.ftp_server}</p>
{/if}
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="ftp_user">Usuario</Label>
<Input id="ftp_user" bind:value={formData.ftp_user} maxlength={200} />
<Input
id="ftp_user"
bind:value={formData.ftp_user}
aria-invalid={!!fieldErrors.ftp_user}
oninput={() => clearFieldError('ftp_user')}
maxlength={200}
/>
{#if fieldErrors.ftp_user}
<p class="text-xs text-destructive">{fieldErrors.ftp_user}</p>
{/if}
</div>
<div class="grid gap-2">
<Label for="ftp_password">Contraseña</Label>
<Input id="ftp_password" type="password" bind:value={formData.ftp_password} maxlength={100} />
<Input
id="ftp_password"
type="password"
bind:value={formData.ftp_password}
aria-invalid={!!fieldErrors.ftp_password}
oninput={() => clearFieldError('ftp_password')}
maxlength={100}
/>
{#if fieldErrors.ftp_password}
<p class="text-xs text-destructive">{fieldErrors.ftp_password}</p>
{/if}
</div>
</div>
<div class="grid gap-2">
<Label for="ftp_directory">Directorio</Label>
<Input id="ftp_directory" bind:value={formData.ftp_directory} maxlength={1000} />
<Input
id="ftp_directory"
bind:value={formData.ftp_directory}
aria-invalid={!!fieldErrors.ftp_directory}
oninput={() => clearFieldError('ftp_directory')}
maxlength={1000}
/>
{#if fieldErrors.ftp_directory}
<p class="text-xs text-destructive">{fieldErrors.ftp_directory}</p>
{/if}
</div>
</section>
</div>

View File

@@ -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: '',
@@ -215,7 +240,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[95vh] max-w-3xl overflow-y-auto">
<Dialog.Content class="max-h-[95vh] max-w-3xl overflow-y-auto" onInteractOutside={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>

View File

@@ -1,383 +0,0 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card/index";
import {
FieldGroup,
Field,
FieldLabel,
FieldDescription,
} from "$lib/components/ui/field/index";
import { Input } from "$lib/components/ui/input/index";
import { Button } from "$lib/components/ui/button/index";
import { cn } from "$lib/utils";
import faviconUrl from '$lib/assets/favicon.svg';
import type { HTMLAttributes } from "svelte/elements";
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { loginWithProvider } from '$lib/sso';
import { onMount, tick } from 'svelte';
import { clearAccessTokenOnDocument } from '$lib/access-token-cookie-browser';
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
const id = $props.id();
let username = $state('');
let password = $state('');
let tenantSlug = $state('');
let loading = $state(false);
// step 1 = credenciales, step 2 = selección de organización
let step = $state<1 | 2>(1);
let readyToSubmit = $state(false);
let formEl: HTMLFormElement | undefined = $state();
// Descubrimiento de tenants
type TenantInfo = { id: number; name: string; slug: string };
let tenants = $state<TenantInfo[]>([]);
let discoveryError = $state('');
const error = $derived(discoveryError || page.form?.error || '');
// Limpiar todo el localStorage y cookies al montar el componente de login
onMount(() => {
clearAllData();
// Si viene ?tenant= en la URL (ej: después del registro), pre-seleccionar
const urlTenant = new URL(window.location.href).searchParams.get('tenant');
if (urlTenant) {
tenantSlug = urlTenant;
}
});
// Función para limpiar cookies del cliente
function clearClientCookies() {
if (typeof document !== 'undefined') {
const isSecure = window.location.protocol === 'https:';
const secureFlag = isSecure ? '; Secure' : '';
clearAccessTokenOnDocument();
document.cookie = `refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
document.cookie = `active_company_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
}
}
function clearAllData() {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('activeCompanyId');
}
clearClientCookies();
}
async function fetchTenants(): Promise<TenantInfo[]> {
discoveryError = '';
try {
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
const res = await fetch(`${apiBase}/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
// Múltiples tenants: { status: "choose_tenant", tenants: [...] }
if (data.tenants) return data.tenants;
// Un solo tenant: Hub devuelve token directo con data.tenant
if (data.access_token && data.tenant) return [data.tenant];
} else {
discoveryError = data.detail || 'Error de autenticación';
}
} catch {
discoveryError = 'Error de conexión con el servidor';
}
return [];
}
// Llama al backend real desde el paso 2
function confirmTenant() {
if (!tenantSlug) return;
readyToSubmit = true;
formEl?.requestSubmit();
}
function goBack() {
step = 1;
tenantSlug = '';
tenants = [];
readyToSubmit = false;
}
function handleMicrosoftLogin() {
clearAllData();
if (tenantSlug) localStorage.setItem('pending_tenant_slug', tenantSlug);
loginWithProvider('microsoft');
}
function handleGoogleLogin() {
clearAllData();
if (tenantSlug) localStorage.setItem('pending_tenant_slug', tenantSlug);
loginWithProvider('google');
}
</script>
<div class={cn("flex flex-col gap-6", className)} {...restProps}>
<Card.Root class="overflow-hidden p-0 shadow-2xl border-0">
<Card.Content class="grid p-0 md:grid-cols-2">
<!-- Formulario -->
<form
bind:this={formEl}
class="p-8 md:p-10 flex flex-col justify-center"
method="POST"
use:enhance={async ({ cancel }) => {
// 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;
}
};
}}
>
<FieldGroup>
<!-- Logo / Branding -->
<div class="flex flex-col items-center gap-3 text-center mb-2">
<img src={faviconUrl} alt="Anexo 76" class="w-14 h-14 rounded-2xl shadow-lg shadow-blue-200 dark:shadow-blue-900/50" />
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100">Anexo 76</h1>
<p class="text-muted-foreground text-sm mt-0.5">
Sistema de Cumplimiento Fiscal y Aduanal
</p>
</div>
</div>
{#if error}
<div class="flex items-start gap-3 rounded-xl bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 p-4 text-sm text-red-700 dark:text-red-400">
<svg class="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{error}
</div>
{/if}
<!-- Campos ocultos siempre presentes para el submit en paso 2 -->
<input type="hidden" name="tenant_slug" value={tenantSlug} />
{#if step === 2}
<input type="hidden" name="username" value={username} />
<input type="hidden" name="password" value={password} />
{/if}
{#if step === 1}
<!-- PASO 1: Credenciales -->
<Field>
<FieldLabel for="username-{id}" class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Usuario</FieldLabel>
<Input
id="username-{id}"
name="username"
type="text"
placeholder="usuario@empresa.com"
bind:value={username}
required
disabled={loading}
class="h-11"
/>
</Field>
<Field>
<div class="flex items-center justify-between">
<FieldLabel for="password-{id}" class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Contraseña</FieldLabel>
<a href="##" class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 font-medium hover:underline underline-offset-2">
¿Olvidaste tu contraseña?
</a>
</div>
<Input
id="password-{id}"
name="password"
type="password"
bind:value={password}
required
disabled={loading}
class="h-11"
/>
</Field>
<Field>
<Button
type="submit"
disabled={loading}
class="h-11 w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 font-semibold shadow-md shadow-blue-200 dark:shadow-blue-900/40 transition-all duration-200"
>
{#if loading}
<svg class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
Verificando...
{:else}
Iniciar sesión
{/if}
</Button>
</Field>
<div class="relative flex items-center gap-3 my-1">
<div class="flex-1 h-px bg-border"></div>
<span class="text-xs text-muted-foreground font-medium px-1">o continúa con</span>
<div class="flex-1 h-px bg-border"></div>
</div>
<Field class="grid grid-cols-2 gap-3">
<Button
variant="outline"
type="button"
onclick={handleGoogleLogin}
disabled={loading}
class="h-11 border hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
>
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" fill="currentColor"/>
</svg>
<span class="sr-only">Google</span>
</Button>
<Button
variant="outline"
type="button"
onclick={handleMicrosoftLogin}
disabled={loading}
class="h-11 border hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
>
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zm12.6 0H12.6V0H24v11.4z" fill="currentColor"/>
</svg>
<span class="sr-only">Microsoft</span>
</Button>
</Field>
<p class="text-center text-sm text-muted-foreground">
¿No tienes cuenta?{' '}
<a href="/register" class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 hover:underline underline-offset-2">
Regístrate
</a>
</p>
{:else}
<!-- PASO 2: Selección de organización -->
<div class="flex flex-col gap-1 text-center">
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Selecciona tu organización</p>
<p class="text-xs text-muted-foreground">Tu cuenta tiene acceso a varias organizaciones</p>
</div>
<div class="flex flex-col gap-2">
{#each tenants as t}
<button
type="button"
onclick={() => (tenantSlug = t.slug)}
class="flex items-center gap-3 rounded-xl border-2 px-4 py-3 text-left transition-all duration-150 hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-950/40
{tenantSlug === t.slug
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/40'
: 'border-input bg-background'}"
>
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 font-bold text-sm">
{t.name.charAt(0).toUpperCase()}
</span>
<span class="flex-1 text-sm font-medium text-slate-800 dark:text-slate-200">{t.name}</span>
{#if tenantSlug === t.slug}
<svg class="w-4 h-4 text-blue-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{/if}
</button>
{/each}
</div>
<Field>
<Button
type="button"
onclick={confirmTenant}
disabled={!tenantSlug || loading}
class="h-11 w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 font-semibold shadow-md shadow-blue-200 dark:shadow-blue-900/40 transition-all duration-200"
>
{#if loading}
<svg class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
Iniciando sesión...
{:else}
Continuar
{/if}
</Button>
</Field>
<button
type="button"
onclick={goBack}
class="flex items-center justify-center gap-1.5 text-xs text-muted-foreground hover:text-slate-700 dark:hover:text-slate-300 transition-colors mx-auto"
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
Volver al inicio de sesión
</button>
{/if}
</FieldGroup>
</form>
<!-- Panel imagen -->
<div class="relative hidden md:block overflow-hidden">
<!-- Fotografía de fondo -->
<img
src="/login-bg.jpg"
alt=""
class="absolute inset-0 w-full h-full object-cover object-center"
/>
<!-- Overlay degradado -->
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/90 via-slate-900/40 to-slate-900/10"></div>
<!-- Contenido sobre la imagen -->
<div class="relative z-10 h-full flex flex-col justify-end p-10">
<div class="flex items-center gap-3 mb-4">
<img src={faviconUrl} alt="" class="w-9 h-9 rounded-xl" />
<span class="text-white font-bold text-lg tracking-tight">Anexo 76</span>
</div>
<blockquote class="space-y-2">
<p class="text-white text-xl font-semibold leading-snug">
"Cumplimiento fiscal simplificado para empresas que operan con el SAT."
</p>
<footer class="text-slate-300 text-sm">Sistema de Cumplimiento Fiscal y Aduanal</footer>
</blockquote>
</div>
</div>
</Card.Content>
</Card.Root>
<p class="px-6 text-center text-xs text-muted-foreground">
Al continuar, aceptas nuestros
<a href="##" class="underline hover:text-blue-600 underline-offset-2">Términos de Servicio</a>
y
<a href="##" class="underline hover:text-blue-600 underline-offset-2">Política de Privacidad</a>.
</p>
</div>

View File

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

View File

@@ -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}
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={avatarUrl} alt={user.name} />
<Avatar.Image src={avatarUrl} alt={displayName} onerror={handleAvatarError} />
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<span class="truncate font-medium">{displayName}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon class="ml-auto size-4" />
@@ -162,11 +198,11 @@
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={avatarUrl} alt={user.name} />
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<Avatar.Image src={avatarUrl} alt={displayName} onerror={handleAvatarError} />
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{displayName}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
</div>

View File

@@ -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<string, string> = {
'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;
}

View File

@@ -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}`);
}

View File

@@ -255,6 +255,15 @@ export async function validateAuth(
fetch: typeof globalThis.fetch,
redirectOnFail?: string
): Promise<any> {
const pickAvatar = (...candidates: Array<unknown>): 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

View File

@@ -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()}`;
}

View File

@@ -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<void> => {
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<SSOProvider[]> => {
// 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;
}
};

View File

@@ -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('');
});
});

View File

@@ -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)', () => {

View File

@@ -5,6 +5,75 @@ 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 {
const parsed = new URL(trimmed);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed;
}
} 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 +84,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 +108,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.
*/

View File

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

View File

@@ -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));
}
};

View File

@@ -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.
@@ -32,10 +33,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'
@@ -50,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) {
@@ -71,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,
@@ -83,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);

View File

@@ -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);
}
};

View File

@@ -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 ?? []

View File

@@ -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<string, any> = {};
for (const [key, value] of formData.entries()) {
if (key === 'avatar') continue; // Skip avatar file
if (key === 'avatar') continue;
if (value && value !== '') {
updateData[key] = value;
}

View File

@@ -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<File | null>(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)
);
</script>
@@ -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 @@
<Card class="transition-shadow hover:shadow-md">
<CardHeader>
<CardTitle class="text-xl">Foto de Perfil</CardTitle>
<CardDescription>Actualiza tu imagen de perfil</CardDescription>
<CardDescription>Esta imagen se administra desde Workspace</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="flex flex-col sm:flex-row items-center gap-8">
<div class="relative group">
<label for="avatar" class="cursor-pointer">
<Avatar class="h-28 w-28 ring-4 ring-background shadow-lg transition-all group-hover:scale-105 group-hover:ring-primary/50">
<AvatarImage src={currentAvatarUrl} alt={profile.username} />
<AvatarImage src={currentAvatarUrl} alt={profile.username} onerror={handleAvatarError} />
<AvatarFallback class="text-3xl font-semibold">{getInitials(profile)}</AvatarFallback>
</Avatar>
<div class="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</label>
</div>
<div class="flex-1 w-full">
<div class="flex flex-col gap-3">
<Input
id="avatar"
type="file"
accept="image/*"
onchange={handleAvatarChange}
class="cursor-pointer transition-colors"
/>
<p class="text-sm text-muted-foreground">
Haz clic en la imagen o selecciona un archivo. JPG, PNG o GIF. Máximo 2MB.
</p>
</div>
<p class="text-sm text-muted-foreground">
La foto de perfil se sincroniza desde Workspace en login, refresh de sesión y carga de perfil.
</p>
<p class="text-sm text-muted-foreground">
Para cambiarla, actualízala en Workspace y vuelve a iniciar sesión.
</p>
</div>
</div>
</CardContent>

View File

@@ -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'
)
]);

View File

@@ -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<Sector | null>(null);
let itemToDelete = $state<Sector | null>(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 }));
</script>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
@@ -176,3 +191,4 @@
</div>
<CreateEditDialog bind:open={createDialogOpen} item={editingItem} onSuccess={handleSuccess} />
<DeleteDialog bind:open={deleteDialogOpen} item={itemToDelete} onSuccess={handleSuccess} />

View File

@@ -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));
};

View File

@@ -1,9 +1 @@
<script lang="ts">
import LoginForm from "$lib/components/login-form.svelte";
</script>
<div class="bg-gradient-to-br from-slate-100 via-blue-50 to-slate-200 dark:from-slate-950 dark:via-blue-950/30 dark:to-slate-900 flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div class="w-full max-w-sm md:max-w-3xl">
<LoginForm />
</div>
</div>
<!-- Esta página nunca se renderiza: el load SSR siempre redirige al workspace. -->

View File

@@ -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));
};

View File

@@ -38,7 +38,7 @@ const vitestClientProject = {
export default defineConfig({
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.