From 2ce8a9047c9b132683d8f2ee72e2e9378bf10a2f Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Fri, 1 May 2026 23:32:48 -0500 Subject: [PATCH] feat: enhance user statistics and tenant management - Updated user statistics endpoint to include access token and hub tenant ID for improved data retrieval. - Refactored user listing functionality to support access token and hub tenant ID, ensuring accurate user data from the Hub. - Introduced new methods in UserService for fetching users with additional information from the Hub. - Enhanced security functions to ensure user-tenant relationships are maintained and synchronized with the Hub. - Improved frontend logic to handle company initialization and user authentication more effectively. --- backend/api/v1/modules/core/users/routes.py | 44 ++- backend/api/v1/modules/core/users/service.py | 266 ++++++++++++++---- backend/core/security.py | 199 +++++++++++-- .../src/routes/dashboard/+layout.server.ts | 7 +- frontend/src/routes/dashboard/+layout.svelte | 7 +- frontend/src/routes/dashboard/+layout.ts | 3 +- 6 files changed, 436 insertions(+), 90 deletions(-) diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index d6f5e667..adc0e862 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -13,8 +13,12 @@ from core.config import settings from core.database import get_core_db from core.s3_keys import public_user_avatar_api_path, user_avatar_key from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes -from core.security import get_current_user, validate_access_to_resource -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from core.security import ( + get_current_user, + resolve_hub_tenant_id_for_api, + validate_access_to_resource, +) +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile from fastapi.responses import Response from sqlalchemy.orm import Session @@ -38,6 +42,7 @@ _AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"} @router.get("/stats", response_model=UserStatsDTO) async def get_user_statistics( + request: Request, company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), @@ -47,12 +52,25 @@ async def get_user_statistics( """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) service = UserService(db, tenant_id, company_id) - return service.get_user_stats() # Este no es async en service.py - + auth_header = request.headers.get("Authorization") or "" + token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() + ) + hub_tid = resolve_hub_tenant_id_for_api( + tenant_id, request.headers.get("X-Tenant-Override") + ) + return service.get_user_stats( + access_token=token or None, + hub_tenant_id=hub_tid, + x_tenant_override=request.headers.get("X-Tenant-Override"), + ) @router.get("/", response_model=UserListResponseDTO) async def list_users( + request: Request, company_id: int = Query(..., description="Company ID"), page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"), @@ -65,7 +83,23 @@ async def list_users( """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) service = UserService(db, tenant_id, company_id) - result = await service.get_tenant_users(page=page, page_size=page_size, search=search) + auth_header = request.headers.get("Authorization") or "" + token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() + ) + hub_tid = resolve_hub_tenant_id_for_api( + tenant_id, request.headers.get("X-Tenant-Override") + ) + result = await service.get_tenant_users( + page=page, + page_size=page_size, + search=search, + access_token=token, + hub_tenant_id=hub_tid, + x_tenant_override=request.headers.get("X-Tenant-Override"), + ) return result diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index e843dd10..3d39839c 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -180,85 +180,170 @@ class UserService: raise e raise HTTPException(status_code=500, detail=str(e)) + async def _fetch_hub_tenant_users_with_info( + self, + access_token: str, + hub_tenant_id: int, + x_tenant_override: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Lista usuarios del tenant desde Aduanasoft Hub (Keycloak + user_tenants).""" + base = (settings.HUB_URL or "").rstrip("/") + url = f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info" + headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"} + if x_tenant_override and str(x_tenant_override).strip(): + headers["X-Tenant-Override"] = str(x_tenant_override).strip() + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers) + + if response.status_code == 401: + raise HTTPException(status_code=401, detail="No autorizado en el Hub") + if response.status_code == 403: + raise HTTPException( + status_code=403, detail="Sin permiso para listar usuarios del tenant en el Hub" + ) + if response.status_code >= 400: + logger.error( + "Hub users-with-info error status=%s body=%s", + response.status_code, + response.text[:500], + ) + raise HTTPException( + status_code=502, + detail="No se pudo obtener el catálogo de usuarios desde el Hub", + ) + + data = response.json() + if not isinstance(data, list): + raise HTTPException( + status_code=502, detail="Respuesta inválida del Hub al listar usuarios" + ) + return data + async def get_tenant_users( - self, page: int = 1, page_size: int = 20, search: Optional[str] = None + self, + page: int = 1, + page_size: int = 20, + search: Optional[str] = None, + *, + access_token: str, + hub_tenant_id: int, + x_tenant_override: Optional[str] = None, ) -> Dict[str, Any]: """ - Obtiene todos los usuarios del tenant con paginación - - Args: - page: Número de página (1-indexed) - page_size: Tamaño de página - search: Término de búsqueda (busca en username, email, nombre, teléfono y bio) - - Returns: - Dict con usuarios y metadatos de paginación + Usuarios del tenant: fuente de verdad Aduanasoft Hub; roles de compañía y + perfil extendido desde BD local (user_tenants / user_company_roles). """ try: - from ..permissions.models import UserCompanyRole, CompanyRole + from ..permissions.models import UserCompanyRole from sqlalchemy.orm import joinedload - - # Obtener relaciones usuario-tenant - query = self.db.query(UserTenant).filter( - and_( - UserTenant.tenant_id == self.tenant_id, - UserTenant.is_active == True, + + if not access_token or not hub_tenant_id: + raise HTTPException( + status_code=400, + detail="Token o tenant Hub requerido para listar usuarios", ) + + hub_rows = await self._fetch_hub_tenant_users_with_info( + access_token, hub_tenant_id, x_tenant_override ) - total = query.count() - - # Calcular offset - offset = (page - 1) * page_size - user_tenants = query.offset(offset).limit(page_size).all() - - # Obtener roles de usuarios en la compañía actual - user_roles_query = self.db.query(UserCompanyRole).options( - joinedload(UserCompanyRole.company_role) - ).filter( - and_( - UserCompanyRole.company_id == self.company_id, - UserCompanyRole.tenant_id == self.tenant_id, - UserCompanyRole.is_active == True + user_roles_query = ( + self.db.query(UserCompanyRole) + .options(joinedload(UserCompanyRole.company_role)) + .filter( + and_( + UserCompanyRole.company_id == self.company_id, + UserCompanyRole.tenant_id == self.tenant_id, + UserCompanyRole.is_active == True, + ) ) ) - - # Crear un mapa de user_id -> lista de roles - user_roles_map = {} + user_roles_map: Dict[str, List[str]] = {} for user_role in user_roles_query.all(): - if user_role.user_id not in user_roles_map: - user_roles_map[user_role.user_id] = [] - user_roles_map[user_role.user_id].append(user_role.company_role.name) + uid = user_role.user_id + if uid not in user_roles_map: + user_roles_map[uid] = [] + user_roles_map[uid].append(user_role.company_role.name) - try: - # En lugar de consultar Keycloak uno a uno (lento y sin API directa ahora), - # devolvemos la info local mínima o consultamos un endpoint de "buscar varios" en el Hub si existiera. - # Por ahora, minimizamos el impacto devolviendo lo que tenemos local. - normalized_user = _normalize_user({ - "id": ut.keycloak_user_id, - "username": "User", # Placeholder si no tenemos el dato local - }, role_str, ut) + local_by_kc = { + ut.keycloak_user_id: ut + for ut in self.db.query(UserTenant) + .filter( + and_( + UserTenant.tenant_id == self.tenant_id, + UserTenant.company_id == self.company_id, + ) + ) + .all() + } - users.append(normalized_user) - except Exception as e: - logger.warning(f"Error processing user {ut.keycloak_user_id}: {e}") + needle = (search or "").strip().lower() + filtered: List[Dict[str, Any]] = [] + for u in hub_rows: + if not u.get("is_active", True): continue + kc = u.get("keycloak_user_id") + if not kc: + continue + if needle: + blob = " ".join( + [ + str(u.get("email") or ""), + str(u.get("username") or ""), + str(u.get("first_name") or ""), + str(u.get("last_name") or ""), + ] + ).lower() + ut_loc = local_by_kc.get(kc) + if ut_loc: + blob += f" {ut_loc.phone or ''} {ut_loc.bio or ''}".lower() + if needle not in blob: + continue + filtered.append(u) - total_pages = (total + page_size - 1) // page_size + total = len(filtered) + offset = (page - 1) * page_size + page_rows = filtered[offset : offset + page_size] + + users: List[Dict[str, Any]] = [] + for u in page_rows: + kc = u["keycloak_user_id"] + role_names = user_roles_map.get(kc, []) + role_str = ", ".join(role_names) if role_names else u.get("role") + local_ut = local_by_kc.get(kc) + normalized_user = _normalize_user( + { + "id": kc, + "username": u.get("username") or "", + "email": u.get("email") or "", + "firstName": u.get("first_name") or "", + "lastName": u.get("last_name") or "", + "enabled": u.get("is_active", True), + "emailVerified": False, + }, + role_str, + local_ut, + ) + users.append(normalized_user) + + total_pages = max(1, (total + page_size - 1) // page_size) if total else 1 return { "users": users, - "total": len(users) if search else total, + "total": total, "page": page, "page_size": page_size, "total_pages": total_pages, } + except HTTPException: + raise except Exception as e: logger.error(f"Error getting tenant users: {str(e)}") raise HTTPException( status_code=500, detail=f"Error getting users: {str(e)}" - ) + ) from e async def get_user(self, user_id: str) -> Dict[str, Any]: """Obtiene un usuario específico""" @@ -338,12 +423,8 @@ class UserService: logger.error(f"Error changing password: {e}") raise HTTPException(status_code=500, detail="Error changing password") - def get_user_stats(self) -> Dict[str, Any]: - """Obtiene estadísticas de usuarios del tenant""" - license = self._get_license() - - # Contar usuarios activos e inactivos - active_users = ( + def _count_active_user_tenants_local(self) -> int: + return ( self.db.query(func.count(UserTenant.id)) .filter( and_( @@ -352,8 +433,72 @@ class UserService: ) ) .scalar() + or 0 ) + def get_user_stats( + self, + access_token: Optional[str] = None, + hub_tenant_id: Optional[int] = None, + x_tenant_override: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Estadísticas de usuarios: cupo según licencia efectiva del Hub (verify-license) + con ``X-Tenant-Override``; activos desde users-with-info del Hub si hay token; + inactivos y fallback de conteos en BD local. + """ + max_users_allowed = 0 + hub_max_ok = False + active_users = 0 + active_from_hub = False + + if access_token and hub_tenant_id: + base = (settings.HUB_URL or "").rstrip("/") + headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"} + if x_tenant_override and str(x_tenant_override).strip(): + headers["X-Tenant-Override"] = str(x_tenant_override).strip() + try: + with httpx.Client(timeout=30.0) as client: + lic_resp = client.get( + f"{base}/api/v1/auth/verify-license", + headers=headers, + ) + if lic_resp.status_code == 200: + lic_body = lic_resp.json() + if lic_body.get("valid") and lic_body.get("max_users") is not None: + max_users_allowed = int(lic_body["max_users"]) + hub_max_ok = True + + users_resp = client.get( + f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info", + headers=headers, + ) + if users_resp.status_code == 200: + payload = users_resp.json() + if isinstance(payload, list): + active_users = sum( + 1 for row in payload if row.get("is_active", True) + ) + active_from_hub = True + else: + logger.warning( + "Hub users-with-info stats: respuesta no lista" + ) + else: + logger.warning( + "Hub users-with-info stats status=%s", + users_resp.status_code, + ) + except Exception as e: + logger.warning("Hub stats (verify-license / users-with-info): %s", e) + + if not hub_max_ok: + license = self._get_license() + max_users_allowed = license.max_users + + if not active_from_hub: + active_users = self._count_active_user_tenants_local() + inactive_users = ( self.db.query(func.count(UserTenant.id)) .filter( @@ -363,19 +508,20 @@ class UserService: ) ) .scalar() + or 0 ) total_users = active_users + inactive_users - users_available = max(0, license.max_users - active_users) + users_available = max(0, max_users_allowed - active_users) usage_percentage = ( - (active_users / license.max_users * 100) if license.max_users > 0 else 0 + (active_users / max_users_allowed * 100) if max_users_allowed > 0 else 0 ) return { "total_users": total_users, "active_users": active_users, "inactive_users": inactive_users, - "max_users_allowed": license.max_users, + "max_users_allowed": max_users_allowed, "users_available": users_available, "usage_percentage": round(usage_percentage, 2), } diff --git a/backend/core/security.py b/backend/core/security.py index 164486ab..328ebdce 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -27,6 +27,8 @@ _synced_tenant_ids: Set[int] = set() # Alias Hub tenant_id -> tenant_id local cuando existe drift histórico de IDs # (mismo slug, diferente id). _tenant_id_aliases: Dict[int, int] = {} +# Inverso: id local core.tenants -> id tenant en Hub (JWT / client_tenants) para llamadas al Hub. +_tenant_id_hub_by_local: Dict[int, int] = {} # Security scheme security = HTTPBearer() @@ -68,36 +70,173 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, raise HTTPException(status_code=401, detail="Authentication error") -def _ensure_company_exists(db: Session, tenant_id: int, tenant_name: str) -> None: +def _ensure_user_tenant_for_company( + db: Session, keycloak_user_id: str, tenant_id: int, company_id: int +) -> None: + """Garantiza fila core.user_tenants (usuario ↔ compañía ↔ tenant).""" + from api.v1.modules.core.user_tenant.models import UserTenant + + existing = ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + UserTenant.company_id == company_id, + ) + .first() + ) + if existing: + if not existing.is_active: + existing.is_active = True + db.commit() + return + db.add( + UserTenant( + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + company_id=company_id, + is_active=True, + ) + ) + db.commit() + + +def _ensure_company_exists( + db: Session, + tenant_id: int, + tenant_name: str, + hub_user: Optional[Dict[str, Any]] = None, +) -> None: """ - Garantiza que exista al menos una empresa en a76.company para el tenant. - El tenant IS la empresa — se crea automáticamente al primer login. + Garantiza al menos una empresa en a76.company para el tenant. + + Primera vez: CompanyService.create_company_manually (seed catálogos por compañía), + relación user_tenants y bootstrap_super_admin para el usuario del token. """ + from api.v1.modules.a76.general_catalogs.company.models import Company + from api.v1.modules.a76.general_catalogs.company.dto import CompanyCreateDTO + from api.v1.modules.a76.general_catalogs.company.service import CompanyService + from api.v1.modules.core.permissions.service import PermissionService + from api.v1.modules.core.user_tenant.models import UserTenant + + kc = hub_user.get("sub") if hub_user else None + try: - from api.v1.modules.a76.general_catalogs.company.models import Company - exists = db.query(Company).filter(Company.tenant_id == tenant_id).first() - if not exists: - company = Company(tenant_id=tenant_id, name=tenant_name) - db.add(company) + company = db.query(Company).filter(Company.tenant_id == tenant_id).first() + + if not company: + svc = CompanyService(db) + username = "System" + if hub_user: + username = ( + hub_user.get("preferred_username") + or hub_user.get("email") + or hub_user.get("name") + or "System" + ) + company = svc.create_company_manually( + CompanyCreateDTO(name=tenant_name), + tenant_id=tenant_id, + username=username, + ) + logger.info( + "Empresa creada vía CompanyService para tenant id=%s name=%r company_id=%s", + tenant_id, + tenant_name, + company.id, + ) + + if not company: + logger.warning( + "No hay empresa para tenant %s tras intento de creación automática", tenant_id + ) + return + + if company.name != tenant_name: + company.name = tenant_name db.commit() - logger.info(f"Empresa creada automáticamente para tenant id={tenant_id}: '{tenant_name}'") - elif exists.name != tenant_name: - exists.name = tenant_name - db.commit() - logger.info(f"Empresa actualizada para tenant id={tenant_id}: '{tenant_name}'") + logger.info("Empresa actualizada para tenant id=%s: '%s'", tenant_id, tenant_name) + + if kc: + ut = ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == kc, + UserTenant.tenant_id == tenant_id, + UserTenant.company_id == company.id, + ) + .first() + ) + if not ut: + _ensure_user_tenant_for_company(db, kc, tenant_id, company.id) + PermissionService(db).bootstrap_super_admin(kc, company.id) + + except HTTPException: + raise except Exception as e: db.rollback() - logger.warning(f"No se pudo crear empresa automática para tenant {tenant_id}: {e}") + logger.warning( + "No se pudo asegurar empresa/usuario para tenant %s: %s", tenant_id, e + ) -def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int: +def _repair_user_company_link_if_needed( + db: Session, + tenant_id_effective: int, + hub_user: Optional[Dict[str, Any]], +) -> None: + """Si ya hay empresa pero el usuario no tiene user_tenants, enlaza y hace bootstrap.""" + if not hub_user or not hub_user.get("sub"): + return + from api.v1.modules.a76.general_catalogs.company.models import Company + from api.v1.modules.core.permissions.service import PermissionService + from api.v1.modules.core.user_tenant.models import UserTenant + + kc = hub_user["sub"] + company = ( + db.query(Company) + .filter(Company.tenant_id == tenant_id_effective) + .first() + ) + if not company: + return + ut = ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == kc, + UserTenant.tenant_id == tenant_id_effective, + UserTenant.company_id == company.id, + ) + .first() + ) + if ut: + return + try: + _ensure_user_tenant_for_company(db, kc, tenant_id_effective, company.id) + PermissionService(db).bootstrap_super_admin(kc, company.id) + except Exception as e: + logger.warning( + "No se pudo reparar enlace usuario-compañía tenant=%s: %s", + tenant_id_effective, + e, + ) + + +def _ensure_tenant_synced( + db: Session, + tenant_id: int, + tenant_slug: str, + hub_user: Optional[Dict[str, Any]] = None, +) -> int: """ Garantiza que el tenant del Hub exista en core.tenants local. Se ejecuta una sola vez por tenant_id por ciclo de vida del proceso. El Hub es la fuente de verdad — este método solo sincroniza en una dirección. """ if tenant_id in _synced_tenant_ids: - return _tenant_id_aliases.get(tenant_id, tenant_id) + effective = int(_tenant_id_aliases.get(tenant_id, tenant_id)) + _repair_user_company_link_if_needed(db, effective, hub_user) + return effective try: # Importación local para evitar imports circulares @@ -116,7 +255,7 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int: logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'") _synced_tenant_ids.add(tenant_id) # Garantizar empresa aunque el tenant ya existiera - _ensure_company_exists(db, tenant_id, name) + _ensure_company_exists(db, tenant_id, name, hub_user) return tenant_id # Crear el tenant local con los datos disponibles del token. @@ -134,7 +273,7 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int: _synced_tenant_ids.add(tenant_id) logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants") # Crear la empresa correspondiente al tenant recién sincronizado - _ensure_company_exists(db, tenant_id, name) + _ensure_company_exists(db, tenant_id, name, hub_user) return tenant_id except IntegrityError: @@ -154,8 +293,9 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int: # Auto-heal en runtime: mapear temporalmente al tenant local existente por slug # para evitar dejar al usuario sin compañías y evitar este conflicto en cada request. _tenant_id_aliases[tenant_id] = int(stale.id) + _tenant_id_hub_by_local[int(stale.id)] = int(tenant_id) _synced_tenant_ids.add(tenant_id) - _ensure_company_exists(db, int(stale.id), stale.name or tenant_slug) + _ensure_company_exists(db, int(stale.id), stale.name or tenant_slug, hub_user) return int(stale.id) else: _synced_tenant_ids.add(tenant_id) @@ -195,7 +335,9 @@ async def get_current_user( tenant_id = user_info.get("tenant_id") tenant_slug = user_info.get("tenant_slug") if tenant_id and tenant_slug: - effective_tenant_id = _ensure_tenant_synced(db, int(tenant_id), str(tenant_slug)) + effective_tenant_id = _ensure_tenant_synced( + db, int(tenant_id), str(tenant_slug), hub_user=user_info + ) if effective_tenant_id != int(tenant_id): logger.warning( f"[get_current_user] tenant_id ajustado por alias: hub={tenant_id} local={effective_tenant_id} slug={tenant_slug}" @@ -268,6 +410,23 @@ def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]: return None +def resolve_hub_tenant_id_for_api( + local_tenant_id: Optional[int], x_tenant_override: Optional[str] +) -> int: + """ + ID de tenant en Hub (client_tenants) para llamadas a la API del Hub. + + Prioriza X-Tenant-Override (cookie SSO). Si hubo drift id Hub↔local, + usa el mapeo inverso registrado en _ensure_tenant_synced. + """ + if x_tenant_override and str(x_tenant_override).strip().isdigit(): + return int(str(x_tenant_override).strip()) + if local_tenant_id is None: + return 0 + lid = int(local_tenant_id) + return int(_tenant_id_hub_by_local.get(lid, lid)) + + def collect_user_role_names(current_user: Dict[str, Any]) -> Set[str]: """ Roles del usuario: primero la lista ``roles`` del Hub (GET /api/v1/auth/me diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index 155659c9..c0756033 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -24,11 +24,12 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { const redirectOnFail = `/login?redirect=${encodeURIComponent(url.pathname)}`; try { - const userData = await validateAuth(cookies, fetch, redirectOnFail); - - // Cargar las compañías del usuario en el servidor (SSR) + // Primero my-companies: ejecuta get_current_user y puede crear tenant/empresa/usuario + // antes de /me/profile en validateAuth (evita SSR sin la empresa recién provisionada). const companies = await getUserCompanies(cookies, fetch); + const userData = await validateAuth(cookies, fetch, redirectOnFail); + // Si la cookie active_company_id apunta a una compañía que ya no existe, limpiarla const cookieCompanyId = cookies.get('active_company_id'); if (cookieCompanyId) { diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 18bc8527..2794be5e 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -110,7 +110,12 @@ // ── Inicializar compañías ───────────────────────────────────────────── if (data.companies) { - companyStore.initialize(data.companies, data.activeCompanyId); + const activeCompanyId = (data as { activeCompanyId?: number }).activeCompanyId; + void (async () => { + await companyStore.initialize(data.companies, activeCompanyId); + // Releer en cliente: el SSR puede haber quedado desfasado respecto al provisionamiento. + await companyStore.loadCompanies(undefined, activeCompanyId); + })(); } // ── Escuchar cambios de compañía y recargar datos ───────────────────── diff --git a/frontend/src/routes/dashboard/+layout.ts b/frontend/src/routes/dashboard/+layout.ts index 690f2b0e..4c49913d 100644 --- a/frontend/src/routes/dashboard/+layout.ts +++ b/frontend/src/routes/dashboard/+layout.ts @@ -6,6 +6,7 @@ export const load: LayoutLoad = async ({ data }) => { user: data.user, companies: data.companies, authenticated: data.authenticated, - userTenants: data.userTenants ?? [] + userTenants: data.userTenants ?? [], + activeCompanyId: data.activeCompanyId }; };