Merge branch 'development' into feature/alembic-daf
This commit is contained in:
@@ -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")
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
80
backend/core/workspace_profile_client.py
Normal file
80
backend/core/workspace_profile_client.py
Normal 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")
|
||||
114
backend/core/workspace_profile_sync.py
Normal file
114
backend/core/workspace_profile_sync.py
Normal 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,
|
||||
},
|
||||
)
|
||||
2
backend/tests/fixtures/builders.py
vendored
2
backend/tests/fixtures/builders.py
vendored
@@ -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()
|
||||
|
||||
@@ -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
1
frontend/.gitignore
vendored
@@ -26,6 +26,7 @@ Thumbs.db
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 || "";
|
||||
|
||||
@@ -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>
|
||||
|
||||
65
frontend/src/lib/i18n/trailer-types.ts
Normal file
65
frontend/src/lib/i18n/trailer-types.ts
Normal 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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
157
frontend/src/lib/server/workspace-auth.ts
Normal file
157
frontend/src/lib/server/workspace-auth.ts
Normal 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()}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
24
frontend/src/lib/utils.avatar.test.ts
Normal file
24
frontend/src/lib/utils.avatar.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
]);
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user