chore: snapshot before development sync
This commit is contained in:
@@ -12,7 +12,7 @@ class UserContextMiddleware(BaseHTTPMiddleware):
|
|||||||
try:
|
try:
|
||||||
# verify_token might raise exception if invalid, we catch it to not block request
|
# verify_token might raise exception if invalid, we catch it to not block request
|
||||||
# but we won't have user context
|
# but we won't have user context
|
||||||
user_info = verify_token(token)
|
user_info = await verify_token(token)
|
||||||
set_user_context(user_info)
|
set_user_context(user_info)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Log error or ignore
|
# Log error or ignore
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ class TokenResponseDTO(BaseModel):
|
|||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
expires_in: int
|
expires_in: int
|
||||||
tenant: Optional["TenantInfoDTO"] = None
|
tenant: Optional["TenantInfoDTO"] = None
|
||||||
|
tenant_id: Optional[int] = None
|
||||||
|
tenant_slug: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
json_schema_extra = {
|
json_schema_extra = {
|
||||||
@@ -196,3 +198,9 @@ class LoginChoiceResponseDTO(BaseModel):
|
|||||||
|
|
||||||
status: str = "choose_tenant"
|
status: str = "choose_tenant"
|
||||||
tenants: list[TenantInfoDTO]
|
tenants: list[TenantInfoDTO]
|
||||||
|
|
||||||
|
|
||||||
|
class SSOExchangeRequestDTO(BaseModel):
|
||||||
|
"""DTO para canjear el relay token por KC tokens."""
|
||||||
|
|
||||||
|
relay_token: str = Field(..., description="Relay token recibido en la URL")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .dto import (
|
|||||||
RegisterRequestDTO,
|
RegisterRequestDTO,
|
||||||
RegisterResponseDTO,
|
RegisterResponseDTO,
|
||||||
SetCookieRequestDTO,
|
SetCookieRequestDTO,
|
||||||
|
SSOExchangeRequestDTO,
|
||||||
SwitchTenantRequestDTO,
|
SwitchTenantRequestDTO,
|
||||||
TokenResponseDTO,
|
TokenResponseDTO,
|
||||||
UserInfoResponseDTO,
|
UserInfoResponseDTO,
|
||||||
@@ -231,3 +232,39 @@ async def set_cookie(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sso-exchange", response_model=TokenResponseDTO)
|
||||||
|
async def sso_exchange(
|
||||||
|
body: SSOExchangeRequestDTO,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Canjea un relay token de un solo uso (generado por el Hub) por KC tokens.
|
||||||
|
Llamado server-side desde la página /auth/sso del frontend de Anexo76.
|
||||||
|
Establece cookies HttpOnly con los tokens y devuelve el resultado.
|
||||||
|
"""
|
||||||
|
service = AuthService(db)
|
||||||
|
tokens = await service.sso_exchange(body.relay_token)
|
||||||
|
|
||||||
|
_is_prod = False # TODO: leer de settings.ENVIRONMENT == "production"
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=tokens.access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=_is_prod,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=3600,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=tokens.refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=_is_prod,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return tokens
|
||||||
|
|||||||
@@ -64,13 +64,17 @@ class AuthService:
|
|||||||
|
|
||||||
return TokenResponseDTO(**data)
|
return TokenResponseDTO(**data)
|
||||||
|
|
||||||
# Si el Hub falló con error de credenciales
|
# Pasar el mensaje de error real del Hub al cliente
|
||||||
|
try:
|
||||||
|
hub_detail = response.json().get("detail", None)
|
||||||
|
except Exception:
|
||||||
|
hub_detail = None
|
||||||
|
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
raise HTTPException(status_code=401, detail=hub_detail or "Credenciales inválidas")
|
||||||
|
|
||||||
# Otros errores del Hub
|
|
||||||
logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
|
logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
|
||||||
raise HTTPException(status_code=response.status_code, detail="Authentication server error")
|
raise HTTPException(status_code=response.status_code, detail=hub_detail or "Error en el servidor de autenticación")
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.error(f"Hub unreachable during login: {str(e)}")
|
logger.error(f"Hub unreachable during login: {str(e)}")
|
||||||
@@ -175,3 +179,34 @@ class AuthService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Switch tenant error: {str(e)}")
|
logger.error(f"Switch tenant error: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail="Switch tenant error")
|
raise HTTPException(status_code=500, detail="Switch tenant error")
|
||||||
|
|
||||||
|
async def sso_exchange(self, relay_token: str) -> TokenResponseDTO:
|
||||||
|
"""
|
||||||
|
Canjea un relay token de un solo uso por KC tokens.
|
||||||
|
Llama al Hub backend (server-to-server), sin Bearer requerido en el Hub.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/sso-exchange",
|
||||||
|
json={"relay_token": relay_token},
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return TokenResponseDTO(
|
||||||
|
access_token=data["access_token"],
|
||||||
|
refresh_token=data["refresh_token"],
|
||||||
|
token_type=data.get("token_type", "bearer"),
|
||||||
|
expires_in=data.get("expires_in", 3600),
|
||||||
|
tenant_id=data.get("tenant_id"),
|
||||||
|
tenant_slug=data.get("tenant_slug"),
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=response.status_code,
|
||||||
|
detail=response.json().get("detail", "SSO exchange failed"),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSO exchange error: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="SSO exchange error")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import httpx
|
import httpx
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -75,7 +76,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
|||||||
exempt_paths = [
|
exempt_paths = [
|
||||||
"/api/docs", "/api/redoc", "/openapi.json",
|
"/api/docs", "/api/redoc", "/openapi.json",
|
||||||
"/api/v1/auth", "/api/v1/status", "/api/health",
|
"/api/v1/auth", "/api/v1/status", "/api/health",
|
||||||
"/api/", "/api/v1/core/help-center",
|
"/api/v1/core/help-center",
|
||||||
]
|
]
|
||||||
|
|
||||||
is_exempt = any(
|
is_exempt = any(
|
||||||
@@ -97,23 +98,54 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
|||||||
# Validación contra el Hub Central
|
# Validación contra el Hub Central
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"{settings.HUB_URL}/api/v1/auth/verify-license",
|
f"{settings.HUB_URL}api/v1/auth/verify-license",
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"🔑 verify-license → status={response.status_code} body={response.text[:300]}")
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
# Endpoint no existe en este Hub — dejar pasar
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
# Escenario 1: sin licencia asignada o licencia inactiva
|
||||||
if not data.get("valid", False):
|
if not data.get("valid", False):
|
||||||
|
message = data.get("message", "Sin licencia asignada para este tenant")
|
||||||
|
logger.warning(f"🚫 License invalid for tenant: {data.get('tenant_slug')} — {message}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=402,
|
status_code=402,
|
||||||
content={
|
content={
|
||||||
"error": "LICENSE_ERROR",
|
"error": "LICENSE_ERROR",
|
||||||
"message": f"Licencia inválida: {data.get('message', 'Sin suscripción activa')}",
|
"message": message,
|
||||||
"status_code": 402,
|
"status_code": 402,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Escenario 2: licencia vencida (verificación local de expires_at)
|
||||||
|
expires_at_str = data.get("expires_at")
|
||||||
|
if expires_at_str:
|
||||||
|
try:
|
||||||
|
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if expires_at < datetime.now(timezone.utc):
|
||||||
|
logger.warning(f"🚫 License expired for tenant: {data.get('tenant_slug')} — expired at {expires_at_str}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=402,
|
||||||
|
content={
|
||||||
|
"error": "LICENSE_EXPIRED",
|
||||||
|
"message": f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción.",
|
||||||
|
"status_code": 402,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad
|
||||||
|
|
||||||
request.state.license_info = data
|
request.state.license_info = data
|
||||||
return await call_next(request) # <--- Único camino al éxito
|
return await call_next(request) # <--- Único camino al éxito
|
||||||
|
|
||||||
elif response.status_code == 403:
|
elif response.status_code == 403:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Utilidades de seguridad y autenticación con Keycloak
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Optional, Set
|
from typing import Any, Dict, Optional, Set
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Security
|
from fastapi import Depends, HTTPException, Request, Security
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
import httpx
|
import httpx
|
||||||
@@ -24,28 +24,37 @@ token_cache = TTLCache(maxsize=1000, ttl=60)
|
|||||||
# IDs de tenants ya sincronizados en este proceso (evita consultas repetidas)
|
# IDs de tenants ya sincronizados en este proceso (evita consultas repetidas)
|
||||||
_synced_tenant_ids: Set[int] = set()
|
_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] = {}
|
||||||
|
|
||||||
# Security scheme
|
# Security scheme
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
async def verify_token(token: str) -> Dict[str, Any]:
|
async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Verifica un token JWT llamando al Hub central.
|
Verifica un token JWT llamando al Hub central.
|
||||||
"""
|
"""
|
||||||
# Check cache first
|
# Cache key incluye el override para que distintos tenants no se mezclen
|
||||||
if token in token_cache:
|
cache_key = (token, tenant_id_override)
|
||||||
return token_cache[token]
|
if cache_key in token_cache:
|
||||||
|
return token_cache[cache_key]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
|
||||||
|
if tenant_id_override:
|
||||||
|
headers["X-Tenant-Override"] = tenant_id_override
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"{settings.HUB_URL}api/v1/auth/me",
|
f"{settings.HUB_URL}api/v1/auth/me",
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
headers=headers
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
user_info = response.json()
|
user_info = response.json()
|
||||||
token_cache[token] = user_info
|
token_cache[cache_key] = user_info
|
||||||
return user_info
|
return user_info
|
||||||
|
|
||||||
logger.error(f"Hub token verification failed with status {response.status_code}")
|
logger.error(f"Hub token verification failed with status {response.status_code}")
|
||||||
@@ -72,19 +81,23 @@ def _ensure_company_exists(db: Session, tenant_id: int, tenant_name: str) -> Non
|
|||||||
db.add(company)
|
db.add(company)
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Empresa creada automáticamente para tenant id={tenant_id}: '{tenant_name}'")
|
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}'")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.warning(f"No se pudo crear empresa automática para tenant {tenant_id}: {e}")
|
logger.warning(f"No se pudo crear empresa automática para tenant {tenant_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> None:
|
def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
|
||||||
"""
|
"""
|
||||||
Garantiza que el tenant del Hub exista en core.tenants local.
|
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.
|
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.
|
El Hub es la fuente de verdad — este método solo sincroniza en una dirección.
|
||||||
"""
|
"""
|
||||||
if tenant_id in _synced_tenant_ids:
|
if tenant_id in _synced_tenant_ids:
|
||||||
return
|
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Importación local para evitar imports circulares
|
# Importación local para evitar imports circulares
|
||||||
@@ -94,16 +107,17 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> None
|
|||||||
|
|
||||||
existing = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
existing = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||||
if existing:
|
if existing:
|
||||||
# Update name/slug if they differ (Hub is source of truth)
|
# Update name/slug/keycloak_realm if they differ (Hub is source of truth)
|
||||||
if existing.slug != tenant_slug or existing.name != name:
|
if existing.slug != tenant_slug or existing.name != name or existing.keycloak_realm != tenant_slug:
|
||||||
existing.slug = tenant_slug
|
existing.slug = tenant_slug
|
||||||
existing.name = name
|
existing.name = name
|
||||||
|
existing.keycloak_realm = tenant_slug
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'")
|
logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'")
|
||||||
_synced_tenant_ids.add(tenant_id)
|
_synced_tenant_ids.add(tenant_id)
|
||||||
# Garantizar empresa aunque el tenant ya existiera
|
# Garantizar empresa aunque el tenant ya existiera
|
||||||
_ensure_company_exists(db, tenant_id, name)
|
_ensure_company_exists(db, tenant_id, name)
|
||||||
return
|
return tenant_id
|
||||||
|
|
||||||
# Crear el tenant local con los datos disponibles del token.
|
# Crear el tenant local con los datos disponibles del token.
|
||||||
# El Hub siempre crea el realm de Keycloak con el mismo nombre que el slug.
|
# El Hub siempre crea el realm de Keycloak con el mismo nombre que el slug.
|
||||||
@@ -121,6 +135,7 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> None
|
|||||||
logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants")
|
logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants")
|
||||||
# Crear la empresa correspondiente al tenant recién sincronizado
|
# Crear la empresa correspondiente al tenant recién sincronizado
|
||||||
_ensure_company_exists(db, tenant_id, name)
|
_ensure_company_exists(db, tenant_id, name)
|
||||||
|
return tenant_id
|
||||||
|
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# Puede ser concurrencia o colisión de slug (id diferente, mismo slug)
|
# Puede ser concurrencia o colisión de slug (id diferente, mismo slug)
|
||||||
@@ -136,16 +151,25 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> None
|
|||||||
f"Elimine el registro obsoleto con: "
|
f"Elimine el registro obsoleto con: "
|
||||||
f"DELETE FROM core.tenants WHERE id={stale.id};"
|
f"DELETE FROM core.tenants WHERE id={stale.id};"
|
||||||
)
|
)
|
||||||
|
# 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)
|
||||||
|
_synced_tenant_ids.add(tenant_id)
|
||||||
|
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug)
|
||||||
|
return int(stale.id)
|
||||||
else:
|
else:
|
||||||
_synced_tenant_ids.add(tenant_id)
|
_synced_tenant_ids.add(tenant_id)
|
||||||
|
return tenant_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.warning(f"No se pudo sincronizar tenant {tenant_id} ({tenant_slug}): {e}")
|
logger.warning(f"No se pudo sincronizar tenant {tenant_id} ({tenant_slug}): {e}")
|
||||||
|
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||||
db: Session = Depends(get_core_db),
|
db: Session = Depends(get_core_db),
|
||||||
|
request: Request = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Dependency para obtener el usuario actual desde el token JWT.
|
Dependency para obtener el usuario actual desde el token JWT.
|
||||||
@@ -156,13 +180,27 @@ async def get_current_user(
|
|||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
"""
|
"""
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
user_info = await verify_token(token)
|
|
||||||
|
# Leer tenant override del header X-Tenant-Override (pasado por el SvelteKit server
|
||||||
|
# desde la cookie sso_tenant_id, flujo SSO relay multi-tenant)
|
||||||
|
tenant_override = request.headers.get('X-Tenant-Override') if request else None
|
||||||
|
|
||||||
|
logger.info(f"[get_current_user] X-Tenant-Override={tenant_override!r}")
|
||||||
|
|
||||||
|
user_info = await verify_token(token, tenant_id_override=tenant_override)
|
||||||
|
# Copia local para poder normalizar tenant_id sin mutar el objeto cacheado
|
||||||
|
user_info = dict(user_info)
|
||||||
|
|
||||||
# Sincronizar tenant desde Hub a BD local (solo la primera vez por tenant)
|
# Sincronizar tenant desde Hub a BD local (solo la primera vez por tenant)
|
||||||
tenant_id = user_info.get("tenant_id")
|
tenant_id = user_info.get("tenant_id")
|
||||||
tenant_slug = user_info.get("tenant_slug")
|
tenant_slug = user_info.get("tenant_slug")
|
||||||
if tenant_id and tenant_slug:
|
if tenant_id and tenant_slug:
|
||||||
_ensure_tenant_synced(db, int(tenant_id), str(tenant_slug))
|
effective_tenant_id = _ensure_tenant_synced(db, int(tenant_id), str(tenant_slug))
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
user_info["tenant_id"] = effective_tenant_id
|
||||||
|
|
||||||
return user_info
|
return user_info
|
||||||
|
|
||||||
|
|||||||
@@ -58,15 +58,6 @@ async def on_startup():
|
|||||||
logger.info("Base de datos inicializada correctamente.")
|
logger.info("Base de datos inicializada correctamente.")
|
||||||
|
|
||||||
|
|
||||||
# Configurar CORS
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=settings.cors_origins_list,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Agregar middlewares personalizados
|
# Agregar middlewares personalizados
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
app.add_middleware(RequestLoggingMiddleware)
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
@@ -75,6 +66,16 @@ app.add_middleware(LicenseValidationMiddleware)
|
|||||||
app.add_middleware(TenantMiddleware)
|
app.add_middleware(TenantMiddleware)
|
||||||
app.add_middleware(UserContextMiddleware)
|
app.add_middleware(UserContextMiddleware)
|
||||||
|
|
||||||
|
# CORS debe ser el último en añadirse para que sea el más externo
|
||||||
|
# y cubra todas las respuestas, incluyendo las de los middlewares internos
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Centraliza startup para evitar on_event() (deprecated en FastAPI)
|
# Centraliza startup para evitar on_event() (deprecated en FastAPI)
|
||||||
|
|||||||
8
frontend/src/app.d.ts
vendored
8
frontend/src/app.d.ts
vendored
@@ -7,7 +7,13 @@ declare global {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
}
|
}
|
||||||
// interface PageData {}
|
interface PageData {
|
||||||
|
licenseError?: {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ async function fetchApi<T = any>(
|
|||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Incluir tenant override para flujo SSO multi-tenant.
|
||||||
|
// sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id.
|
||||||
|
if (browser) {
|
||||||
|
const tenantPub = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((c) => c.startsWith('sso_tenant_pub='))
|
||||||
|
?.split('=')[1];
|
||||||
|
if (tenantPub) {
|
||||||
|
headers['X-Tenant-Override'] = tenantPub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
100
frontend/src/lib/components/license-error-screen.svelte
Normal file
100
frontend/src/lib/components/license-error-screen.svelte
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { logout } from '$lib/auth';
|
||||||
|
|
||||||
|
interface LicenseError {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { error }: { error: LicenseError } = $props();
|
||||||
|
|
||||||
|
const isHubOffline = error.type === 'HUB_OFFLINE' || error.type === 'HUB_ERROR';
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
void logout();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center p-8">
|
||||||
|
<div class="flex max-w-md flex-col items-center gap-6 text-center">
|
||||||
|
<!-- Icon -->
|
||||||
|
{#if isHubOffline}
|
||||||
|
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-10 w-10 text-yellow-600 dark:text-yellow-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-10 w-10 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Heading -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-foreground">
|
||||||
|
{#if isHubOffline}
|
||||||
|
Servicio de licencias no disponible
|
||||||
|
{:else}
|
||||||
|
Acceso suspendido
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help text -->
|
||||||
|
<div class="rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{#if isHubOffline}
|
||||||
|
El servidor de licencias no está disponible en este momento. Por favor, inténtalo de nuevo
|
||||||
|
en unos minutos o contacta a soporte si el problema persiste.
|
||||||
|
{:else if error.type === 'LICENSE_ERROR'}
|
||||||
|
Tu organización no cuenta con una licencia activa para acceder al sistema. Contacta a tu
|
||||||
|
administrador o al equipo de soporte para regularizar tu suscripción.
|
||||||
|
{:else}
|
||||||
|
No tienes permisos para acceder al sistema. Contacta a tu administrador.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
{#if isHubOffline}
|
||||||
|
<Button variant="outline" onclick={() => window.location.reload()}>
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button variant="destructive" onclick={handleLogout}>
|
||||||
|
Cerrar sesión
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
import { cn } from "$lib/utils.ts";
|
import { cn } from "$lib/utils.ts";
|
||||||
import faviconUrl from '$lib/assets/favicon.svg';
|
import faviconUrl from '$lib/assets/favicon.svg';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { loginWithProvider } from '$lib/sso.ts';
|
import { loginWithProvider } from '$lib/sso.ts';
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
@@ -32,8 +32,9 @@
|
|||||||
// Descubrimiento de tenants
|
// Descubrimiento de tenants
|
||||||
type TenantInfo = { id: number; name: string; slug: string };
|
type TenantInfo = { id: number; name: string; slug: string };
|
||||||
let tenants = $state<TenantInfo[]>([]);
|
let tenants = $state<TenantInfo[]>([]);
|
||||||
|
let discoveryError = $state('');
|
||||||
|
|
||||||
const error = $derived(page.form?.error || '');
|
const error = $derived(discoveryError || page.form?.error || '');
|
||||||
|
|
||||||
// Limpiar todo el localStorage y cookies al montar el componente de login
|
// Limpiar todo el localStorage y cookies al montar el componente de login
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTenants(): Promise<TenantInfo[]> {
|
async function fetchTenants(): Promise<TenantInfo[]> {
|
||||||
|
discoveryError = '';
|
||||||
try {
|
try {
|
||||||
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
|
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
|
||||||
const res = await fetch(`${apiBase}/v1/auth/login`, {
|
const res = await fetch(`${apiBase}/v1/auth/login`, {
|
||||||
@@ -73,15 +75,17 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
|
||||||
// Múltiples tenants: { status: "choose_tenant", tenants: [...] }
|
// Múltiples tenants: { status: "choose_tenant", tenants: [...] }
|
||||||
if (data.tenants) return data.tenants;
|
if (data.tenants) return data.tenants;
|
||||||
// Un solo tenant: Hub devuelve token directo con data.tenant
|
// Un solo tenant: Hub devuelve token directo con data.tenant
|
||||||
if (data.access_token && data.tenant) return [data.tenant];
|
if (data.access_token && data.tenant) return [data.tenant];
|
||||||
|
} else {
|
||||||
|
discoveryError = data.detail || 'Error de autenticación';
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — el server action mostrará el error de autenticación
|
discoveryError = 'Error de conexión con el servidor';
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -137,8 +141,11 @@
|
|||||||
} else if (tenants.length > 1) {
|
} else if (tenants.length > 1) {
|
||||||
// Varias orgs: mostrar selector
|
// Varias orgs: mostrar selector
|
||||||
step = 2;
|
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 {
|
} else {
|
||||||
// 0 orgs: enviar igual, el backend rechazará
|
// 0 orgs sin error: enviar igual, el backend rechazará
|
||||||
readyToSubmit = true;
|
readyToSubmit = true;
|
||||||
await tick();
|
await tick();
|
||||||
formEl?.requestSubmit();
|
formEl?.requestSubmit();
|
||||||
@@ -147,11 +154,12 @@
|
|||||||
}
|
}
|
||||||
loading = true;
|
loading = true;
|
||||||
return async ({ update, result }) => {
|
return async ({ update, result }) => {
|
||||||
await update();
|
await update({ reset: false });
|
||||||
loading = false;
|
loading = false;
|
||||||
readyToSubmit = false;
|
readyToSubmit = false;
|
||||||
if (result.type === 'failure') {
|
if (result.type === 'failure') {
|
||||||
clearClientCookies();
|
clearClientCookies();
|
||||||
|
step = 1;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
<Sidebar.Root {collapsible} {...restProps}>
|
<Sidebar.Root {collapsible} {...restProps}>
|
||||||
<Sidebar.Header>
|
<Sidebar.Header>
|
||||||
<TeamSwitcher />
|
<TeamSwitcher {userTenants} />
|
||||||
</Sidebar.Header>
|
</Sidebar.Header>
|
||||||
<Sidebar.Content>
|
<Sidebar.Content>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={data.navMain} />
|
||||||
|
|||||||
@@ -7,9 +7,20 @@
|
|||||||
import CheckIcon from '@lucide/svelte/icons/check';
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
import { companyStore } from '$lib/stores/company.svelte';
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
import { getBackendAssetUrl } from '$lib/utils';
|
import { getBackendAssetUrl } from '$lib/utils';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { userTenants = [] }: { userTenants: Tenant[] } = $props();
|
||||||
|
|
||||||
const sidebar = useSidebar();
|
const sidebar = useSidebar();
|
||||||
|
|
||||||
|
let switchingTenant = $state(false);
|
||||||
|
|
||||||
// Derivar la URL del logo usando el endpoint específico
|
// Derivar la URL del logo usando el endpoint específico
|
||||||
let activeCompanyLogoUrl = $derived(
|
let activeCompanyLogoUrl = $derived(
|
||||||
companyStore.activeCompany?.logo
|
companyStore.activeCompany?.logo
|
||||||
@@ -24,11 +35,70 @@
|
|||||||
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
|
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fallback en degradé cuando no hay logo cargado
|
function readCookie(name: string): string | null {
|
||||||
const fallbackBg =
|
if (typeof document === 'undefined') return null;
|
||||||
'radial-gradient(circle at 30% 30%, rgba(0,0,0,0.08), rgba(0,0,0,0.12)), linear-gradient(135deg, rgba(99,102,241,0.12), rgba(14,165,233,0.18))';
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const logoBg = $derived(activeCompanyLogoUrl ? `url(${activeCompanyLogoUrl})` : fallbackBg);
|
let activeTenantPubId = $derived.by<number | null>(() => {
|
||||||
|
const fromCookie = readCookie('sso_tenant_pub');
|
||||||
|
if (fromCookie && !Number.isNaN(Number(fromCookie))) return Number(fromCookie);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function switchTenant(tenant: Tenant) {
|
||||||
|
if (switchingTenant) return;
|
||||||
|
switchingTenant = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api-sveltekit/auth/switch-tenant', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tenant_id: tenant.id }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
companyStore.clear();
|
||||||
|
await invalidateAll();
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
console.error('[team-switcher] switch-tenant error:', err);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[team-switcher] fetch error:', e);
|
||||||
|
} finally {
|
||||||
|
switchingTenant = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIdentity(value: string | undefined | null): string {
|
||||||
|
return (value ?? '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
let tenantIdentitySet = $derived.by(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const tenant of userTenants) {
|
||||||
|
set.add(normalizeIdentity(tenant.name));
|
||||||
|
set.add(normalizeIdentity(tenant.slug));
|
||||||
|
}
|
||||||
|
set.delete('');
|
||||||
|
return set;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Excluir del listado de companias cualquier registro que realmente represente al tenant.
|
||||||
|
let myCompanies = $derived(
|
||||||
|
companyStore.companies.filter((company) => !tenantIdentitySet.has(normalizeIdentity(company.name)))
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const active = companyStore.activeCompany;
|
||||||
|
if (!active) return;
|
||||||
|
if (!tenantIdentitySet.has(normalizeIdentity(active.name))) return;
|
||||||
|
if (myCompanies.length === 0) return;
|
||||||
|
void companyStore.setActiveCompany(myCompanies[0], true);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
@@ -99,18 +169,42 @@
|
|||||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis Compañías</DropdownMenu.Label>
|
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
|
||||||
|
{#if userTenants.length === 0}
|
||||||
|
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||||
|
<span class="text-muted-foreground">Sin tenant asignado</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{:else}
|
||||||
|
{#each userTenants as tenant (tenant.id)}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => switchTenant(tenant)}
|
||||||
|
class="cursor-pointer gap-2 p-2"
|
||||||
|
disabled={switchingTenant}
|
||||||
|
>
|
||||||
|
<div class="flex size-6 items-center justify-center rounded-md border bg-muted">
|
||||||
|
<BuildingIcon class="size-3.5" />
|
||||||
|
</div>
|
||||||
|
<span class="truncate font-medium">{tenant.name}</span>
|
||||||
|
{#if activeTenantPubId === tenant.id}
|
||||||
|
<CheckIcon class="ml-auto size-4 text-primary" />
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis compañías</DropdownMenu.Label>
|
||||||
|
|
||||||
{#if companyStore.loading}
|
{#if companyStore.loading}
|
||||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||||
<span class="text-muted-foreground">Cargando...</span>
|
<span class="text-muted-foreground">Cargando...</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{:else if companyStore.companies.length === 0}
|
{:else if myCompanies.length === 0}
|
||||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||||
<span class="text-muted-foreground">No hay compañías disponibles</span>
|
<span class="text-muted-foreground">No tienes compañías disponibles</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{:else}
|
{:else}
|
||||||
{#each companyStore.companies as company, index (company.id)}
|
{#each myCompanies as company, index (company.id)}
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => companyStore.setActiveCompany(company)}
|
onSelect={() => companyStore.setActiveCompany(company)}
|
||||||
class="cursor-pointer gap-2 p-2"
|
class="cursor-pointer gap-2 p-2"
|
||||||
|
|||||||
@@ -82,10 +82,11 @@ export function clearAuthTokens(cookies: Cookies) {
|
|||||||
/**
|
/**
|
||||||
* Crea headers de autorización con el token Bearer
|
* Crea headers de autorización con el token Bearer
|
||||||
*/
|
*/
|
||||||
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>) {
|
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, tenantOverride?: string) {
|
||||||
return {
|
return {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
|
||||||
...additionalHeaders
|
...additionalHeaders
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -162,6 +163,9 @@ export async function authenticatedFetch(
|
|||||||
// Construir URL completa
|
// Construir URL completa
|
||||||
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
|
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
|
||||||
|
|
||||||
|
// Leer tenant override de cookie SSO (flujo multi-tenant relay)
|
||||||
|
const tenantOverride = cookies.get('sso_tenant_id');
|
||||||
|
|
||||||
// Crear AbortController para timeout
|
// Crear AbortController para timeout
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
@@ -174,7 +178,7 @@ export async function authenticatedFetch(
|
|||||||
const isFormData = options.body instanceof FormData;
|
const isFormData = options.body instanceof FormData;
|
||||||
const headers = isFormData
|
const headers = isFormData
|
||||||
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
|
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
|
||||||
: createAuthHeaders(accessToken, options.headers as Record<string, string>);
|
: createAuthHeaders(accessToken, options.headers as Record<string, string>, tenantOverride);
|
||||||
|
|
||||||
let response = await fetch(url, {
|
let response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
@@ -205,7 +209,7 @@ export async function authenticatedFetch(
|
|||||||
// Si el body es FormData, no incluir Content-Type
|
// Si el body es FormData, no incluir Content-Type
|
||||||
const newHeaders = isFormData
|
const newHeaders = isFormData
|
||||||
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
|
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
|
||||||
: createAuthHeaders(newToken, options.headers as Record<string, string>);
|
: createAuthHeaders(newToken, options.headers as Record<string, string>, tenantOverride);
|
||||||
|
|
||||||
response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -103,8 +103,14 @@ class CompanyStore {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Error loading companies:', response.error);
|
console.error('Error loading companies:', response.error);
|
||||||
// Si falla la carga (ej: 401), limpiar el store
|
if (response.status === 402) {
|
||||||
if (response.status === 401) {
|
const { toast } = await import('svelte-sonner');
|
||||||
|
toast.error('Licencia inactiva', {
|
||||||
|
duration: 8000,
|
||||||
|
description: response.error || 'Tu licencia no está activa para este tenant. Contacta al administrador.'
|
||||||
|
});
|
||||||
|
this.clear();
|
||||||
|
} else if (response.status === 401) {
|
||||||
this.clear();
|
this.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,82 @@
|
|||||||
/**
|
/**
|
||||||
* Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente.
|
* Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente.
|
||||||
*
|
*
|
||||||
* Flujo:
|
* Dos modos:
|
||||||
* 1. Cliente llama POST /api-sveltekit/auth/switch-tenant con { tenant_slug }
|
* - { tenant_id } → flujo SSO relay: solo actualiza cookie sso_tenant_id (override de tenant)
|
||||||
* 2. Este servidor lee access_token y refresh_token de las cookies (HttpOnly).
|
* - { tenant_slug } → flujo login clásico: re-emite tokens KC para el nuevo tenant
|
||||||
* 3. Llama al backend /v1/auth/switch-tenant con ambos tokens.
|
|
||||||
* 4. Si es exitoso, actualiza las cookies con los nuevos tokens.
|
|
||||||
* 5. Retorna ok al cliente para que recargue la página.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
|
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
|
||||||
|
|
||||||
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
||||||
const { tenant_slug } = await request.json();
|
const body = await request.json();
|
||||||
|
const { tenant_id, tenant_slug } = body as { tenant_id?: number; tenant_slug?: string };
|
||||||
|
|
||||||
if (!tenant_slug) {
|
if (!tenant_id && !tenant_slug) {
|
||||||
return json({ error: 'tenant_slug is required' }, { status: 400 });
|
return json({ error: 'tenant_id or tenant_slug is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken } = getAuthTokens(cookies);
|
const { accessToken, refreshToken } = getAuthTokens(cookies);
|
||||||
|
|
||||||
if (!accessToken || !refreshToken) {
|
if (!accessToken) {
|
||||||
|
return json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
|
||||||
|
if (tenant_id) {
|
||||||
|
try {
|
||||||
|
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||||
|
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!tenantsRes.ok) {
|
||||||
|
return json({ error: 'Could not validate tenant access' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const tenants: { id: number }[] = await tenantsRes.json();
|
||||||
|
const hasAccess = tenants.some((t) => t.id === tenant_id);
|
||||||
|
if (!hasAccess) {
|
||||||
|
return json({ error: 'Access denied to tenant' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
cookies.set('sso_tenant_id', String(tenant_id), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
});
|
||||||
|
cookies.set('sso_tenant_pub', String(tenant_id), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: false,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
});
|
||||||
|
cookies.delete('active_company_id', { path: '/' });
|
||||||
|
return json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[switch-tenant] SSO mode error:', error);
|
||||||
|
return json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo login clásico: re-emitir tokens KC para el nuevo tenant
|
||||||
|
if (!refreshToken) {
|
||||||
return json({ error: 'Not authenticated' }, { status: 401 });
|
return json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = getServerApiUrl();
|
const baseUrl = getServerApiUrl();
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, {
|
const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${accessToken}`
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken })
|
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -44,15 +85,13 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Actualizar cookies con los nuevos tokens del nuevo tenant
|
|
||||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||||
// Limpiar la compañía activa para que el dashboard recargue con el nuevo tenant
|
|
||||||
cookies.delete('active_company_id', { path: '/' });
|
cookies.delete('active_company_id', { path: '/' });
|
||||||
|
cookies.delete('sso_tenant_id', { path: '/' });
|
||||||
|
cookies.delete('sso_tenant_pub', { path: '/' });
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[switch-tenant] Error:', error);
|
console.error('[switch-tenant] Classic mode error:', error);
|
||||||
return json({ error: 'Internal server error' }, { status: 500 });
|
return json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
91
frontend/src/routes/auth/sso/+page.server.ts
Normal file
91
frontend/src/routes/auth/sso/+page.server.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* SSO auto-login page for Anexo76.
|
||||||
|
* The Hub App Launcher redirects here with ?relay=<token> after generating a relay token.
|
||||||
|
* This server-side load function exchanges the relay token for KC tokens via the
|
||||||
|
* Hub backend, sets HttpOnly cookies, and redirects to /dashboard.
|
||||||
|
*/
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||||
|
const relayToken = url.searchParams.get('relay');
|
||||||
|
|
||||||
|
if (!relayToken) {
|
||||||
|
throw redirect(303, '/login?error=sso_missing_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSO exchange must call Hub backend, not Anexo76 backend.
|
||||||
|
// Use INTERNAL_HUB_URL for server-to-server communication.
|
||||||
|
let hubUrl = process.env.INTERNAL_HUB_URL;
|
||||||
|
if (!hubUrl) {
|
||||||
|
hubUrl = process.env.VITE_HUB_URL;
|
||||||
|
// Fallback: replace localhost with hub-backend for Docker
|
||||||
|
hubUrl = hubUrl?.replace('localhost', 'host.docker.internal').replace('127.0.0.1', 'host.docker.internal');
|
||||||
|
}
|
||||||
|
const baseUrl = hubUrl?.endsWith('/') ? hubUrl : `${hubUrl}/`;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${baseUrl}api/v1/auth/sso-exchange`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ relay_token: relayToken }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw redirect(303, '/login?error=sso_hub_unreachable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
const detail = body?.detail || 'sso_exchange_failed';
|
||||||
|
throw redirect(303, `/login?error=${encodeURIComponent(detail)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await response.json();
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
// access_token — NO HttpOnly (client JS reads it for Bearer headers)
|
||||||
|
cookies.set('access_token', tokens.access_token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: false,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
// refresh_token — HttpOnly (never exposed to JS)
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
cookies.set('refresh_token', tokens.refresh_token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// sso_tenant_id — HttpOnly cookie con el tenant seleccionado.
|
||||||
|
// El backend lo pasa como X-Tenant-Override en Hub /auth/me para que
|
||||||
|
// devuelva el tenant correcto aunque el KC token tenga otro tenant baked in.
|
||||||
|
if (tokens.tenant_id) {
|
||||||
|
cookies.set('sso_tenant_id', String(tokens.tenant_id), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
});
|
||||||
|
// sso_tenant_pub — companion no-HttpOnly para que el cliente JS pueda
|
||||||
|
// leer el tenant override e incluirlo como X-Tenant-Override en fetch directo al backend.
|
||||||
|
// No es un secreto (solo un ID numérico; Hub valida UserTenant en cada request).
|
||||||
|
cookies.set('sso_tenant_pub', String(tokens.tenant_id), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: false,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw redirect(303, '/dashboard');
|
||||||
|
};
|
||||||
11
frontend/src/routes/auth/sso/+page.svelte
Normal file
11
frontend/src/routes/auth/sso/+page.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// This page is never rendered — the server-side load always redirects.
|
||||||
|
// It exists only to satisfy SvelteKit's file-based routing requirement.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div class="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
|
||||||
|
<p class="text-sm text-slate-500">Iniciando sesión automáticamente…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
import {
|
import {
|
||||||
validateAuth,
|
validateAuth,
|
||||||
getUserCompanies,
|
|
||||||
getAuthTokens,
|
getAuthTokens,
|
||||||
clearAuthTokens,
|
getUserCompanies,
|
||||||
authenticatedFetch
|
clearAuthTokens
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||||
@@ -38,18 +38,19 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargar los tenants del usuario para el selector de organización
|
// Cargar los tenants del usuario desde Hub (fuente de verdad multi-tenant)
|
||||||
let userTenants: { id: number; name: string; slug: string; is_active: boolean }[] = [];
|
let userTenants: { id: number; name: string; slug: string }[] = [];
|
||||||
try {
|
try {
|
||||||
const tenantsRes = await authenticatedFetch(
|
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||||
`v1/core/user-tenants/user/${userData.sub}`,
|
const tenantOverride = cookies.get('sso_tenant_id');
|
||||||
{},
|
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
|
||||||
cookies,
|
headers: {
|
||||||
fetch
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
);
|
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
if (tenantsRes.ok) {
|
if (tenantsRes.ok) {
|
||||||
const tenantsData = await tenantsRes.json();
|
userTenants = await tenantsRes.json();
|
||||||
userTenants = tenantsData.tenants ?? [];
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No bloquear el dashboard si falla la carga de tenants
|
// No bloquear el dashboard si falla la carga de tenants
|
||||||
@@ -69,7 +70,6 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Para cualquier otro error (conexión, etc), limpiar token y redirigir
|
// Para cualquier otro error (conexión, etc), limpiar token y redirigir
|
||||||
console.error('🔐 [Dashboard] Error validando token:', error);
|
|
||||||
clearAuthTokens(cookies);
|
clearAuthTokens(cookies);
|
||||||
throw redirect(303, redirectOnFail);
|
throw redirect(303, redirectOnFail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@
|
|||||||
import type { SessionExpiredDetail } from '$lib/session-manager';
|
import type { SessionExpiredDetail } from '$lib/session-manager';
|
||||||
import { authStore } from '$lib/auth';
|
import { authStore } from '$lib/auth';
|
||||||
import { logout, getKeycloakInstance } from '$lib/auth';
|
import { logout, getKeycloakInstance } from '$lib/auth';
|
||||||
|
import LicenseErrorScreen from '$lib/components/license-error-screen.svelte';
|
||||||
|
|
||||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
type LicenseError = { type: string; message: string; status: number };
|
||||||
|
let { data, children }: { data: LayoutData & { licenseError?: LicenseError }; children: any } = $props();
|
||||||
|
|
||||||
let csvImportBanner = $state(false);
|
let csvImportBanner = $state(false);
|
||||||
let csvImportBannerLabel = $state<string | null>(null);
|
let csvImportBannerLabel = $state<string | null>(null);
|
||||||
@@ -117,6 +119,9 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if data.licenseError}
|
||||||
|
<LicenseErrorScreen error={data.licenseError} />
|
||||||
|
{:else}
|
||||||
<Sidebar.Provider>
|
<Sidebar.Provider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<Sidebar.Inset class="overflow-x-hidden">
|
<Sidebar.Inset class="overflow-x-hidden">
|
||||||
@@ -169,3 +174,4 @@
|
|||||||
|
|
||||||
<!-- Diálogo de advertencia de sesión por inactividad -->
|
<!-- Diálogo de advertencia de sesión por inactividad -->
|
||||||
<SessionTimeoutWarning />
|
<SessionTimeoutWarning />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,14 +1,49 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ cookies }) => {
|
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||||
|
const refreshToken = cookies.get('refresh_token');
|
||||||
|
|
||||||
// Eliminar todas las cookies de autenticación
|
// Eliminar todas las cookies de autenticación
|
||||||
cookies.delete('access_token', { path: '/' });
|
cookies.delete('access_token', { path: '/' });
|
||||||
cookies.delete('refresh_token', { path: '/' });
|
cookies.delete('refresh_token', { path: '/' });
|
||||||
|
|
||||||
// Eliminar la cookie de la compañía activa
|
|
||||||
cookies.delete('active_company_id', { path: '/' });
|
cookies.delete('active_company_id', { path: '/' });
|
||||||
|
cookies.delete('sso_tenant_id', { path: '/' });
|
||||||
|
cookies.delete('sso_tenant_pub', { path: '/' });
|
||||||
|
|
||||||
|
// Llamar al Hub para revocar el refresh token y obtener la URL de logout KC.
|
||||||
|
// Si lo logramos, redirigimos al browser a través del endpoint KC logout para
|
||||||
|
// que Keycloak elimine su cookie de sesión SSO (evita auto-login silencioso).
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||||
|
// Detectar el origen del request para usar como post_logout_redirect_uri
|
||||||
|
const origin = request.headers.get('origin') || request.headers.get('referer')?.replace(/\/$/, '') || '';
|
||||||
|
const postLogoutUri = origin ? `${origin}/login` : '/login';
|
||||||
|
|
||||||
|
const res = await fetch(`${hubUrl}/api/v1/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
post_logout_redirect_uri: postLogoutUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (data.kc_logout_url) {
|
||||||
|
// Redirigir el browser al endpoint KC logout para limpiar la sesión SSO
|
||||||
|
throw redirect(303, data.kc_logout_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// Si es un redirect de SvelteKit, relanzar
|
||||||
|
if (err?.status && err?.location) throw err;
|
||||||
|
// Si falla, caer al login local
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redirigir al login
|
|
||||||
throw redirect(303, '/login');
|
throw redirect(303, '/login');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -138,43 +138,10 @@ hub_mode_init() {
|
|||||||
done
|
done
|
||||||
echo -e "${GREEN}✓ Hub disponible${NC}"
|
echo -e "${GREEN}✓ Hub disponible${NC}"
|
||||||
|
|
||||||
# ── 2. Registrar usuario via Hub ───────────────────────────────────────────
|
# ── 2. Verificar que el usuario exista (creado desde el Hub) ─────────────
|
||||||
echo -e "\n${YELLOW}[2/4] Registrando usuario '${USER_USERNAME}' en tenant '${TENANT_SLUG}'...${NC}"
|
echo -e "\n${YELLOW}[2/4] El usuario debe estar creado previamente desde el Hub.${NC}"
|
||||||
echo -e "${YELLOW} (Si el tenant no existe en el Hub, el registro fallará aquí)${NC}"
|
echo -e "${YELLOW} El registro requiere un token de invitación gestionado por el Hub.${NC}"
|
||||||
|
echo -e "${CYAN} Usuario esperado: ${USER_USERNAME} / tenant: ${TENANT_SLUG}${NC}"
|
||||||
local register_response http_code
|
|
||||||
register_response=$(curl -s -w "\n%{http_code}" -X POST \
|
|
||||||
"${HUB_URL}/api/v1/auth/register" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{
|
|
||||||
\"username\": \"${USER_USERNAME}\",
|
|
||||||
\"email\": \"${USER_EMAIL}\",
|
|
||||||
\"password\": \"${USER_PASSWORD}\",
|
|
||||||
\"first_name\": \"${USER_FIRSTNAME}\",
|
|
||||||
\"last_name\": \"${USER_LASTNAME}\",
|
|
||||||
\"tenant_slug\":\"${TENANT_SLUG}\"
|
|
||||||
}")
|
|
||||||
|
|
||||||
http_code=$(echo "${register_response}" | tail -n1)
|
|
||||||
local register_body
|
|
||||||
register_body=$(echo "${register_response}" | head -n -1)
|
|
||||||
|
|
||||||
if [[ "${http_code}" == "201" ]]; then
|
|
||||||
echo -e "${GREEN}✓ Usuario creado exitosamente${NC}"
|
|
||||||
elif [[ "${http_code}" == "409" ]] || (echo "${register_body}" | grep -qi "already\|existe\|conflict" 2>/dev/null); then
|
|
||||||
echo -e "${YELLOW}⚠ Usuario ya existe, continuando con login...${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ Error registrando usuario (HTTP ${http_code}):${NC}"
|
|
||||||
echo "${register_body}" | python3 -m json.tool 2>/dev/null || echo "${register_body}"
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW} Asegúrate de que el tenant '${TENANT_SLUG}' esté provisionado en el Hub.${NC}"
|
|
||||||
echo -e " Un administrador del Hub debe ejecutar:${NC}"
|
|
||||||
echo -e " curl -X POST ${HUB_URL}/api/v1/hub/provisioning/ \\"
|
|
||||||
echo -e " -H 'Authorization: Bearer <hub-admin-token>' \\"
|
|
||||||
echo -e " -H 'Content-Type: application/json' \\"
|
|
||||||
echo -e " -d '{\"name\":\"${COMPANY_NAME}\",\"slug\":\"${TENANT_SLUG}\",\"contact_email\":\"${USER_EMAIL}\",\"plan\":\"ENTERPRISE\",\"license_months\":12}'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 3. Login para obtener token y crear empresa ────────────────────────────
|
# ── 3. Login para obtener token y crear empresa ────────────────────────────
|
||||||
echo -e "\n${YELLOW}[3/4] Login y creación de empresa en Anexo76...${NC}"
|
echo -e "\n${YELLOW}[3/4] Login y creación de empresa en Anexo76...${NC}"
|
||||||
|
|||||||
Reference in New Issue
Block a user