Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -4,31 +4,19 @@ APP_VERSION=1.0.0
|
||||
DEBUG=True
|
||||
ENVIRONMENT=development
|
||||
|
||||
# Database - Core (Shared)
|
||||
# Database - Core
|
||||
CORE_DB_HOST=localhost
|
||||
CORE_DB_PORT=5432
|
||||
CORE_DB_NAME=anexo76_core
|
||||
CORE_DB_USER=postgres
|
||||
CORE_DB_PASSWORD=postgres
|
||||
|
||||
# Keycloak
|
||||
KEYCLOAK_SERVER_URL=http://localhost:8080/kcauth
|
||||
KEYCLOAK_REALM=master
|
||||
KEYCLOAK_CLIENT_ID=anexo76-backend
|
||||
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# Security
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# License Service
|
||||
LICENSE_CHECK_ENABLED=True
|
||||
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
|
||||
HUB_URL=https://hub.aduanasoft.com
|
||||
|
||||
# Synchronization (Hub & Spoke)
|
||||
# Sincronización (Hub & Spoke)
|
||||
SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||
# Only for spokes/clients. Leave empty if this is the Hub.
|
||||
CENTRAL_SERVER_URL=http://localhost:8000/api/v1/core/help-center/sync/
|
||||
CENTRAL_SERVER_URL=
|
||||
|
||||
@@ -33,6 +33,7 @@ class TokenResponseDTO(BaseModel):
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
tenant: Optional["TenantInfoDTO"] = None
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
|
||||
@@ -48,7 +48,7 @@ async def register(
|
||||
- Atributos de tenant
|
||||
"""
|
||||
service = AuthService(db)
|
||||
return service.register(register_data)
|
||||
return await service.register(register_data)
|
||||
|
||||
|
||||
@router.post("/login", response_model=None)
|
||||
@@ -68,7 +68,7 @@ async def login(
|
||||
service = AuthService(db)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
return service.login(
|
||||
return await service.login(
|
||||
login_data=login_data,
|
||||
ip_address=request.client.host,
|
||||
user_agent=request.headers.get("user-agent")
|
||||
@@ -90,7 +90,7 @@ async def switch_tenant(
|
||||
"""
|
||||
service = AuthService(db)
|
||||
# Obtener info del usuario desde el access token actual
|
||||
user_info = service.get_user_info(credentials.credentials)
|
||||
user_info = await service.get_user_info(credentials.credentials)
|
||||
|
||||
keycloak_user_id = user_info.sub
|
||||
# El realm se puede inferir del token; usamos el campo tenant_id para buscar el realm actual,
|
||||
@@ -103,7 +103,7 @@ async def switch_tenant(
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return service.switch_tenant(
|
||||
return await service.switch_tenant(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
keycloak_realm=tenant.keycloak_realm,
|
||||
tenant_slug=data.tenant_slug,
|
||||
@@ -119,7 +119,7 @@ async def refresh_token(
|
||||
Refresca el access token usando el refresh token
|
||||
"""
|
||||
service = AuthService(db)
|
||||
return service.refresh_token(refresh_data)
|
||||
return await service.refresh_token(refresh_data)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserInfoResponseDTO)
|
||||
@@ -131,7 +131,7 @@ async def get_current_user_info(
|
||||
Obtiene información del usuario actual desde el token
|
||||
"""
|
||||
service = AuthService(db)
|
||||
return service.get_user_info(credentials.credentials)
|
||||
return await service.get_user_info(credentials.credentials)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@@ -155,7 +155,7 @@ async def logout(
|
||||
# I'll keep it simple.
|
||||
|
||||
service = AuthService(db)
|
||||
return service.logout(logout_data)
|
||||
return await service.logout(logout_data)
|
||||
|
||||
|
||||
@router.post("/exchange-code", response_model=TokenResponseDTO)
|
||||
@@ -172,7 +172,7 @@ async def exchange_code(
|
||||
externo y Keycloak lo redirige al frontend con el código en los query params.
|
||||
"""
|
||||
service = AuthService(db)
|
||||
return service.exchange_code(exchange_data)
|
||||
return await service.exchange_code(exchange_data)
|
||||
|
||||
|
||||
@router.post("/set-cookie")
|
||||
@@ -198,7 +198,7 @@ async def set_cookie(
|
||||
service = AuthService(db)
|
||||
try:
|
||||
# Validar el access token
|
||||
user_info = service.get_user_info(cookie_data.access_token)
|
||||
user_info = await service.get_user_info(cookie_data.access_token)
|
||||
|
||||
# Establecer las cookies
|
||||
# Access token cookie
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
"""
|
||||
Servicio de autenticación con Keycloak
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
from typing import Any, Dict
|
||||
|
||||
from api.v1.modules.core.tenants.service import TenantService
|
||||
from api.v1.modules.core.user_tenant.service import UserTenantService
|
||||
from core.config import settings
|
||||
from fastapi import HTTPException
|
||||
from keycloak import KeycloakAdmin, KeycloakOpenID
|
||||
from keycloak.exceptions import KeycloakError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
LoginRequestDTO,
|
||||
LogoutRequestDTO,
|
||||
RefreshTokenRequestDTO,
|
||||
RegisterRequestDTO,
|
||||
RegisterResponseDTO,
|
||||
TokenResponseDTO,
|
||||
UserInfoResponseDTO,
|
||||
)
|
||||
@@ -27,701 +18,160 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Servicio de autenticación"""
|
||||
"""Servicio de autenticación centralizado vía Hub"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.keycloak_openid = KeycloakOpenID(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=settings.KEYCLOAK_REALM,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
|
||||
def login(
|
||||
async def login(
|
||||
self,
|
||||
login_data: LoginRequestDTO,
|
||||
ip_address: str = None,
|
||||
user_agent: str = None
|
||||
):
|
||||
"""
|
||||
Autentica usuario y obtiene tokens.
|
||||
|
||||
Si se omite tenant_slug, verifica credenciales primero y devuelve
|
||||
la lista de tenants disponibles (LoginChoiceResponseDTO) en lugar de tokens.
|
||||
|
||||
Args:
|
||||
login_data: Credenciales de login (tenant_slug es opcional)
|
||||
ip_address: Dirección IP del cliente
|
||||
user_agent: User Agent del cliente
|
||||
|
||||
Returns:
|
||||
TokenResponseDTO si tenant_slug fue provisto,
|
||||
LoginChoiceResponseDTO si no se proveyó tenant_slug.
|
||||
|
||||
Raises:
|
||||
HTTPException: Si las credenciales son inválidas
|
||||
Autentica usuario a través del Hub y obtiene tokens.
|
||||
"""
|
||||
# PRIMER PASO: sin tenant_slug → verificar creds y devolver lista de orgs
|
||||
if not login_data.tenant_slug:
|
||||
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
|
||||
tenants = self._verify_credentials_and_list_tenants(
|
||||
login_data.username, login_data.password
|
||||
)
|
||||
# Siempre devolver LoginChoiceResponseDTO; el frontend decide si
|
||||
# auto-seleccionar (1 tenant) o mostrar selector (>1 tenants).
|
||||
return LoginChoiceResponseDTO(
|
||||
tenants=[TenantInfoDTO(**t) for t in tenants]
|
||||
)
|
||||
|
||||
try:
|
||||
# Verificar que el tenant existe
|
||||
tenant_service = TenantService(self.db)
|
||||
user_tenant_service = UserTenantService(self.db)
|
||||
tenant = tenant_service.get_tenant_by_slug(login_data.tenant_slug)
|
||||
|
||||
if not tenant or not tenant.is_active:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# Crear nueva instancia de KeycloakOpenID con el realm del tenant
|
||||
keycloak_client = KeycloakOpenID(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=tenant.keycloak_realm,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
|
||||
# PASO 1: Primero actualizamos los atributos del usuario ANTES de autenticar
|
||||
# Esto es necesario para que los Protocol Mappers incluyan los valores correctos
|
||||
# en el token que se generará a continuación
|
||||
|
||||
# Para obtener el user_id, necesitamos hacer una autenticación temporal
|
||||
# o buscar el usuario por username
|
||||
try:
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=tenant.keycloak_realm,
|
||||
user_realm_name="master",
|
||||
verify=True,
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/login",
|
||||
json=login_data.model_dump()
|
||||
)
|
||||
|
||||
# Buscar usuario por username
|
||||
users = keycloak_admin.get_users({"username": login_data.username})
|
||||
|
||||
if users and len(users) > 0:
|
||||
user_id = users[0]["id"]
|
||||
|
||||
# Verificar si el usuario tiene acceso a este tenant
|
||||
has_access = user_tenant_service.user_has_access_to_tenant(
|
||||
user_id, tenant.id
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Si el Hub devolvió una lista de tenants (hubo login exitoso pero falta seleccionar tenant)
|
||||
if "tenants" in data:
|
||||
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
|
||||
return LoginChoiceResponseDTO(
|
||||
tenants=[TenantInfoDTO(**t) for t in data["tenants"]]
|
||||
)
|
||||
|
||||
if not has_access:
|
||||
logger.warning(
|
||||
f"User {user_id} tried to access tenant {tenant.id} without permission"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
# Si devolvió tokens
|
||||
# AUDIT LOG: Login Success
|
||||
try:
|
||||
from api.v1.modules.a76.audit_log.services.service import AuditService
|
||||
AuditService.log_login(
|
||||
db=self.db,
|
||||
username=login_data.username,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to audit login: {e}")
|
||||
|
||||
# Obtener los datos actuales del usuario
|
||||
current_user = keycloak_admin.get_user(user_id)
|
||||
current_attributes = current_user.get("attributes", {})
|
||||
|
||||
# Actualizar los atributos de tenant
|
||||
current_attributes["tenant_id"] = [str(tenant.id)]
|
||||
current_attributes["tenant_slug"] = [tenant.slug]
|
||||
|
||||
# Actualizar el usuario con los nuevos atributos
|
||||
update_payload = {
|
||||
"email": current_user.get("email"),
|
||||
"firstName": current_user.get("firstName"),
|
||||
"lastName": current_user.get("lastName"),
|
||||
"enabled": current_user.get("enabled", True),
|
||||
"emailVerified": current_user.get("emailVerified", False),
|
||||
"attributes": current_attributes,
|
||||
}
|
||||
|
||||
keycloak_admin.update_user(user_id=user_id, payload=update_payload)
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"Could not pre-update user attributes: {str(e)}")
|
||||
# Continuamos con el login aunque falle la actualización
|
||||
except HTTPException:
|
||||
raise # Re-lanzamos las excepciones HTTP (como acceso denegado)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error pre-updating user attributes: {str(e)}")
|
||||
|
||||
# PASO 2: Ahora autenticamos al usuario
|
||||
token_response = keycloak_client.token(
|
||||
username=login_data.username,
|
||||
password=login_data.password,
|
||||
grant_type=["password"],
|
||||
)
|
||||
return TokenResponseDTO(**data)
|
||||
|
||||
# Si el Hub falló con error de credenciales
|
||||
if response.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# AUDIT LOG: Login Success
|
||||
try:
|
||||
from api.v1.modules.a76.audit_log.services.service import AuditService
|
||||
|
||||
AuditService.log_login(
|
||||
db=self.db,
|
||||
username=login_data.username,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to audit login: {e}")
|
||||
# Otros errores del Hub
|
||||
logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
|
||||
raise HTTPException(status_code=response.status_code, detail="Authentication server error")
|
||||
|
||||
return TokenResponseDTO(
|
||||
access_token=token_response["access_token"],
|
||||
refresh_token=token_response["refresh_token"],
|
||||
token_type="bearer",
|
||||
expires_in=token_response["expires_in"],
|
||||
)
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"Keycloak authentication failed: {str(e)}")
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Hub unreachable during login: {str(e)}")
|
||||
raise HTTPException(status_code=503, detail="Authentication service unavailable")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Login error: {str(e)}")
|
||||
logger.error(f"Unexpected login error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Authentication error")
|
||||
|
||||
def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
|
||||
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
|
||||
"""
|
||||
Refresca el access token usando refresh token
|
||||
|
||||
Args:
|
||||
refresh_data: Refresh token
|
||||
|
||||
Returns:
|
||||
TokenResponseDTO con nuevos tokens
|
||||
Refresca el access token usando el Hub
|
||||
"""
|
||||
try:
|
||||
token_response = self.keycloak_openid.refresh_token(
|
||||
refresh_data.refresh_token
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||
json=refresh_data.model_dump()
|
||||
)
|
||||
|
||||
return TokenResponseDTO(
|
||||
access_token=token_response["access_token"],
|
||||
refresh_token=token_response["refresh_token"],
|
||||
token_type="bearer",
|
||||
expires_in=token_response["expires_in"],
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return TokenResponseDTO(**response.json())
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"Token refresh failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid or expired refresh token"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Token refresh error")
|
||||
|
||||
def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
|
||||
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
|
||||
"""
|
||||
Obtiene información del usuario desde el token
|
||||
Obtiene información del usuario desde el Hub
|
||||
"""
|
||||
from core.security import verify_token
|
||||
# Aprovechamos la verificación (y cache) de security.py
|
||||
user_info = await verify_token(access_token)
|
||||
return UserInfoResponseDTO(**user_info)
|
||||
|
||||
Args:
|
||||
access_token: Access token JWT
|
||||
|
||||
Returns:
|
||||
UserInfoResponseDTO con información del usuario
|
||||
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
|
||||
"""
|
||||
Cierra sesión a través del Hub
|
||||
"""
|
||||
try:
|
||||
user_info = self.keycloak_openid.userinfo(access_token)
|
||||
|
||||
# Extraer roles
|
||||
roles = []
|
||||
if "realm_access" in user_info:
|
||||
roles = user_info["realm_access"].get("roles", [])
|
||||
|
||||
# Extraer tenant_id y tenant_slug si están presentes
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
if not tenant_id and "attributes" in user_info:
|
||||
tenant_id = user_info["attributes"].get("tenant_id")
|
||||
|
||||
tenant_slug = user_info.get("tenant_slug")
|
||||
if not tenant_slug and "attributes" in user_info:
|
||||
tenant_slug = user_info["attributes"].get("tenant_slug")
|
||||
# Puede venir como lista de Keycloak attributes
|
||||
if isinstance(tenant_slug, list):
|
||||
tenant_slug = tenant_slug[0] if tenant_slug else None
|
||||
|
||||
return UserInfoResponseDTO(
|
||||
sub=user_info.get("sub"),
|
||||
email=user_info.get("email"),
|
||||
name=user_info.get("name"),
|
||||
preferred_username=user_info.get("preferred_username"),
|
||||
tenant_id=int(tenant_id) if tenant_id else None,
|
||||
tenant_slug=tenant_slug,
|
||||
roles=roles,
|
||||
)
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"Get user info failed: {str(e)}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
except Exception as e:
|
||||
logger.error(f"Get user info error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error retrieving user info")
|
||||
|
||||
def logout(self, logout_data: LogoutRequestDTO) -> dict:
|
||||
"""
|
||||
Cierra sesión invalidando el refresh token
|
||||
|
||||
Args:
|
||||
logout_data: Refresh token a invalidar
|
||||
|
||||
Returns:
|
||||
Dict con mensaje de éxito
|
||||
"""
|
||||
try:
|
||||
self.keycloak_openid.logout(logout_data.refresh_token)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/logout",
|
||||
json=logout_data.model_dump()
|
||||
)
|
||||
return {"message": "Logged out successfully"}
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"Logout failed: {str(e)}")
|
||||
# No lanzamos error aquí, el logout puede fallar si el token ya expiró
|
||||
return {"message": "Logged out"}
|
||||
except Exception as e:
|
||||
logger.error(f"Logout error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Logout error")
|
||||
return {"message": "Logged out"}
|
||||
|
||||
def register(self, register_data: RegisterRequestDTO) -> RegisterResponseDTO:
|
||||
async def register(self, register_data: Any) -> Any:
|
||||
"""
|
||||
Registra un nuevo usuario en Keycloak
|
||||
|
||||
Args:
|
||||
register_data: Datos del usuario a registrar
|
||||
|
||||
Returns:
|
||||
RegisterResponseDTO con información del usuario creado
|
||||
|
||||
Raises:
|
||||
HTTPException: Si el registro falla
|
||||
Registra un usuario a través del Hub
|
||||
"""
|
||||
try:
|
||||
# Verificar que el tenant existe
|
||||
from api.v1.modules.core.tenants.service import TenantService
|
||||
|
||||
tenant_service = TenantService(self.db)
|
||||
tenant = tenant_service.get_tenant_by_slug(register_data.tenant_slug)
|
||||
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
if not tenant.is_active:
|
||||
raise HTTPException(status_code=403, detail="Tenant is not active")
|
||||
|
||||
# Crear instancia de KeycloakAdmin para gestión de usuarios
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=tenant.keycloak_realm,
|
||||
user_realm_name="master", # El admin suele estar en master realm
|
||||
verify=True,
|
||||
)
|
||||
|
||||
# Preparar datos del usuario para Keycloak
|
||||
user_data = {
|
||||
"username": register_data.username,
|
||||
"email": register_data.email,
|
||||
"firstName": register_data.first_name,
|
||||
"lastName": register_data.last_name,
|
||||
"enabled": True,
|
||||
"emailVerified": False,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": register_data.password,
|
||||
"temporary": False,
|
||||
}
|
||||
],
|
||||
"attributes": {"tenant_id": str(tenant.id), "tenant_slug": tenant.slug},
|
||||
}
|
||||
|
||||
# Crear usuario en Keycloak
|
||||
user_id = keycloak_admin.create_user(user_data)
|
||||
|
||||
# Asignar rol por defecto (user) - opcional, solo si existe
|
||||
try:
|
||||
user_role = keycloak_admin.get_realm_role("user")
|
||||
if user_role:
|
||||
keycloak_admin.assign_realm_roles(user_id, [user_role])
|
||||
except KeycloakError as e:
|
||||
# El rol 'user' no existe, no es un error crítico
|
||||
logger.warning(f"Could not assign 'user' role: {str(e)}")
|
||||
|
||||
# Agregar el usuario al tenant en la base de datos
|
||||
try:
|
||||
from api.v1.modules.core.user_tenant.service import UserTenantService
|
||||
|
||||
user_tenant_service = UserTenantService(self.db)
|
||||
user_tenant_service.add_user_to_tenant(
|
||||
keycloak_user_id=user_id,
|
||||
tenant_id=tenant.id,
|
||||
role="user", # Rol por defecto
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/register",
|
||||
json=register_data.model_dump()
|
||||
)
|
||||
except Exception as e:
|
||||
# Si falla, hacer rollback del usuario en Keycloak
|
||||
logger.error(f"Failed to add user to tenant in database: {str(e)}")
|
||||
try:
|
||||
keycloak_admin.delete_user(user_id)
|
||||
except Exception as e:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to register user in database"
|
||||
)
|
||||
|
||||
return RegisterResponseDTO(
|
||||
user_id=user_id,
|
||||
username=register_data.username,
|
||||
email=register_data.email,
|
||||
message="User registered successfully",
|
||||
)
|
||||
|
||||
except KeycloakError as e:
|
||||
error_message = str(e)
|
||||
logger.warning(f"Keycloak registration failed: {error_message}")
|
||||
|
||||
# Mensajes de error más específicos
|
||||
if "User exists" in error_message or "409" in error_message:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Username or email already exists"
|
||||
)
|
||||
elif "Invalid" in error_message:
|
||||
raise HTTPException(status_code=400, detail="Invalid user data")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Registration error")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
if response.status_code == 201:
|
||||
return response.json()
|
||||
raise HTTPException(status_code=response.status_code, detail=response.text)
|
||||
except Exception as e:
|
||||
logger.error(f"Registration error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Registration error")
|
||||
|
||||
def exchange_code(self, exchange_data) -> TokenResponseDTO:
|
||||
async def exchange_code(self, exchange_data: Any) -> TokenResponseDTO:
|
||||
"""
|
||||
Intercambia un authorization code por tokens
|
||||
|
||||
Este método se usa cuando el frontend recibe un código de autorización
|
||||
después de un login con proveedor externo (Microsoft, Google, etc.)
|
||||
a través de Keycloak.
|
||||
|
||||
Args:
|
||||
exchange_data: Datos del código y redirect_uri
|
||||
|
||||
Returns:
|
||||
TokenResponseDTO con access_token y refresh_token
|
||||
|
||||
Raises:
|
||||
HTTPException: Si el código es inválido o expiró
|
||||
Intercambia código por tokens a través del Hub
|
||||
"""
|
||||
try:
|
||||
# Importar el DTO aquí para evitar referencias circulares
|
||||
|
||||
# Intercambiar código por tokens usando Keycloak
|
||||
token_response = self.keycloak_openid.token(
|
||||
grant_type="authorization_code",
|
||||
code=exchange_data.code,
|
||||
redirect_uri=exchange_data.redirect_uri,
|
||||
)
|
||||
|
||||
# Si se proporciona tenant_slug, podríamos validar que el usuario pertenece a ese tenant
|
||||
# Por ahora simplemente retornamos los tokens
|
||||
if exchange_data.tenant_slug:
|
||||
|
||||
# Validar que el tenant existe y está activo
|
||||
tenant_service = TenantService(self.db)
|
||||
tenant = tenant_service.get_tenant_by_slug(exchange_data.tenant_slug)
|
||||
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
if not tenant.is_active:
|
||||
raise HTTPException(status_code=403, detail="Tenant is not active")
|
||||
|
||||
# Opcional: Verificar que el usuario pertenece al tenant
|
||||
# Esto depende de cómo manejes los tenants en tu aplicación
|
||||
|
||||
return TokenResponseDTO(
|
||||
access_token=token_response["access_token"],
|
||||
refresh_token=token_response["refresh_token"],
|
||||
token_type=token_response.get("token_type", "bearer"),
|
||||
expires_in=token_response.get("expires_in", 3600),
|
||||
)
|
||||
|
||||
except KeycloakError as e:
|
||||
error_message = str(e)
|
||||
logger.warning(f"Code exchange failed: {error_message}")
|
||||
|
||||
if "invalid_grant" in error_message.lower():
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid or expired authorization code"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/exchange-code",
|
||||
json=exchange_data.model_dump()
|
||||
)
|
||||
elif "invalid_client" in error_message.lower():
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid client credentials"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Token exchange error")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
if response.status_code == 200:
|
||||
return TokenResponseDTO(**response.json())
|
||||
raise HTTPException(status_code=response.status_code, detail="Code exchange failed")
|
||||
except Exception as e:
|
||||
logger.error(f"Code exchange error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Code exchange error")
|
||||
logger.error(f"Exchange code error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Exchange code error")
|
||||
|
||||
def switch_tenant(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
keycloak_realm: str,
|
||||
tenant_slug: str,
|
||||
refresh_token: str,
|
||||
) -> TokenResponseDTO:
|
||||
async def switch_tenant(self, **kwargs) -> TokenResponseDTO:
|
||||
"""
|
||||
Cambia el tenant activo de un usuario autenticado sin requerir su contraseña.
|
||||
|
||||
Pasos:
|
||||
1. Verifica que el tenant existe y está activo.
|
||||
2. Verifica que el usuario tiene acceso a ese tenant.
|
||||
3. Actualiza los atributos tenant_id/tenant_slug del usuario en Keycloak.
|
||||
4. Usa el refresh_token para emitir nuevos tokens que ya contienen los atributos actualizados.
|
||||
Cambia de tenant a través del Hub
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
|
||||
tenant_service = TenantService(self.db)
|
||||
user_tenant_service = UserTenantService(self.db)
|
||||
|
||||
tenant = tenant_service.get_tenant_by_slug(tenant_slug)
|
||||
if not tenant or not tenant.is_active:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Verificar acceso
|
||||
has_access = user_tenant_service.user_has_access_to_tenant(keycloak_user_id, tenant.id)
|
||||
if not has_access:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Actualizar atributos en Keycloak antes de emitir el nuevo token
|
||||
try:
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=keycloak_realm,
|
||||
user_realm_name="master",
|
||||
verify=True,
|
||||
)
|
||||
current_user = keycloak_admin.get_user(keycloak_user_id)
|
||||
attrs = current_user.get("attributes", {})
|
||||
attrs["tenant_id"] = [str(tenant.id)]
|
||||
attrs["tenant_slug"] = [tenant.slug]
|
||||
keycloak_admin.update_user(
|
||||
user_id=keycloak_user_id,
|
||||
payload={
|
||||
"email": current_user.get("email"),
|
||||
"firstName": current_user.get("firstName"),
|
||||
"lastName": current_user.get("lastName"),
|
||||
"enabled": current_user.get("enabled", True),
|
||||
"emailVerified": current_user.get("emailVerified", False),
|
||||
"attributes": attrs,
|
||||
},
|
||||
)
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"switch_tenant: could not update user attributes: {e}")
|
||||
raise HTTPException(status_code=500, detail="Could not update tenant attributes")
|
||||
|
||||
# Emitir nuevos tokens usando el refresh_token existente
|
||||
keycloak_client = KeycloakOpenID(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=keycloak_realm,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
try:
|
||||
token_response = keycloak_client.refresh_token(refresh_token)
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"switch_tenant: token refresh failed: {e}")
|
||||
raise HTTPException(status_code=401, detail="Token refresh failed; please log in again")
|
||||
|
||||
return TokenResponseDTO(
|
||||
access_token=token_response["access_token"],
|
||||
refresh_token=token_response["refresh_token"],
|
||||
token_type="bearer",
|
||||
expires_in=token_response["expires_in"],
|
||||
)
|
||||
|
||||
def _verify_credentials_and_list_tenants(self, username: str, password: str) -> list:
|
||||
"""
|
||||
Verifica las credenciales del usuario contra Keycloak y, solo si son válidas,
|
||||
devuelve la lista de tenants a los que tiene acceso.
|
||||
|
||||
Esto evita el oráculo de enumeración de usuarios del antiguo endpoint
|
||||
/discover-tenants que no requería contraseña.
|
||||
|
||||
Args:
|
||||
username: Nombre de usuario o email
|
||||
password: Contraseña en texto plano
|
||||
|
||||
Returns:
|
||||
Lista de dicts {id, name, slug} con los tenants del usuario
|
||||
|
||||
Raises:
|
||||
HTTPException 401: Si las credenciales son inválidas
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
from sqlalchemy import and_
|
||||
|
||||
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
|
||||
if not tenants:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
realms: dict[str, list] = {}
|
||||
for tenant in tenants:
|
||||
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
|
||||
|
||||
credentials_verified = False
|
||||
matched_tenants = []
|
||||
|
||||
for realm_name, realm_tenants in realms.items():
|
||||
try:
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=realm_name,
|
||||
user_realm_name="master",
|
||||
verify=True,
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/switch-tenant",
|
||||
json=kwargs
|
||||
)
|
||||
|
||||
users = keycloak_admin.get_users({"username": username, "exact": True})
|
||||
if not users:
|
||||
users = keycloak_admin.get_users({"email": username, "exact": True})
|
||||
if not users:
|
||||
continue
|
||||
|
||||
keycloak_user_id = users[0]["id"]
|
||||
|
||||
# Verificar la contraseña contra este realm (una sola vez)
|
||||
if not credentials_verified:
|
||||
keycloak_client = KeycloakOpenID(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=realm_name,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
try:
|
||||
keycloak_client.token(
|
||||
username=username,
|
||||
password=password,
|
||||
grant_type=["password"],
|
||||
)
|
||||
credentials_verified = True
|
||||
except KeycloakError:
|
||||
# Contraseña incorrecta — no revelar que el usuario existe
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# Recopilar tenants con acceso confirmado
|
||||
for tenant in realm_tenants:
|
||||
has_access = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant.id,
|
||||
UserTenant.is_active,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if has_access:
|
||||
matched_tenants.append(
|
||||
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query realm '{realm_name}' during credential check: {e}")
|
||||
continue
|
||||
|
||||
if not credentials_verified:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
return matched_tenants
|
||||
|
||||
def discover_user_tenants(self, username: str) -> list:
|
||||
"""
|
||||
[DEPRECATED] Usa _verify_credentials_and_list_tenants en su lugar.
|
||||
Descubre los tenants activos a los que pertenece un usuario dado su username.
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
from sqlalchemy import and_
|
||||
|
||||
# 1. Obtener todos los tenants activos
|
||||
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
|
||||
|
||||
if not tenants:
|
||||
return []
|
||||
|
||||
# 2. Agrupar tenants por keycloak_realm para no repetir consultas admin
|
||||
realms: dict[str, list] = {}
|
||||
for tenant in tenants:
|
||||
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
|
||||
|
||||
matched_tenants = []
|
||||
|
||||
for realm_name, realm_tenants in realms.items():
|
||||
try:
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=realm_name,
|
||||
user_realm_name="master",
|
||||
verify=True,
|
||||
)
|
||||
|
||||
# Buscar por username exacto
|
||||
users = keycloak_admin.get_users({"username": username, "exact": True})
|
||||
if not users:
|
||||
# Intentar por email
|
||||
users = keycloak_admin.get_users({"email": username, "exact": True})
|
||||
|
||||
if not users:
|
||||
continue
|
||||
|
||||
keycloak_user_id = users[0]["id"]
|
||||
|
||||
# 3. Para cada tenant en este realm, verificar UserTenant
|
||||
for tenant in realm_tenants:
|
||||
has_access = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant.id,
|
||||
UserTenant.is_active,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if has_access:
|
||||
matched_tenants.append(
|
||||
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not query realm '{realm_name}' during tenant discovery: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return matched_tenants
|
||||
if response.status_code == 200:
|
||||
return TokenResponseDTO(**response.json())
|
||||
raise HTTPException(status_code=response.status_code, detail="Switch tenant failed")
|
||||
except Exception as e:
|
||||
logger.error(f"Switch tenant error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Switch tenant error")
|
||||
|
||||
@@ -27,28 +27,22 @@ router = APIRouter(prefix="/users", tags=["Users"])
|
||||
|
||||
|
||||
@router.get("/stats", response_model=UserStatsDTO)
|
||||
def get_user_statistics(
|
||||
async def get_user_statistics(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene estadísticas de usuarios del tenant actual
|
||||
|
||||
Muestra:
|
||||
- Total de usuarios
|
||||
- Usuarios activos e inactivos
|
||||
- Límite de licencia
|
||||
- Usuarios disponibles
|
||||
- Porcentaje de uso
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = UserService(db, tenant_id, company_id)
|
||||
return service.get_user_stats()
|
||||
return service.get_user_stats() # Este no es async en service.py
|
||||
|
||||
|
||||
|
||||
@router.get("/", response_model=UserListResponseDTO)
|
||||
def list_users(
|
||||
async def list_users(
|
||||
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"),
|
||||
@@ -58,17 +52,15 @@ def list_users(
|
||||
):
|
||||
"""
|
||||
Lista todos los usuarios del tenant con paginación
|
||||
|
||||
Se puede filtrar por término de búsqueda (busca en username, email, nombre)
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = UserService(db, tenant_id, company_id)
|
||||
result = service.get_tenant_users(page=page, page_size=page_size, search=search)
|
||||
result = await service.get_tenant_users(page=page, page_size=page_size, search=search)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponseDTO)
|
||||
def get_user(
|
||||
async def get_user_detail(
|
||||
user_id: str,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
@@ -76,34 +68,25 @@ def get_user(
|
||||
):
|
||||
"""
|
||||
Obtiene información detallada de un usuario específico
|
||||
|
||||
El usuario debe pertenecer al tenant actual
|
||||
"""
|
||||
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(user_id)
|
||||
return await service.get_user(user_id)
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponseDTO, status_code=201)
|
||||
def create_user(
|
||||
async def create_new_user(
|
||||
data: CreateUserRequestDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Crea un nuevo usuario en Keycloak y lo asocia al tenant
|
||||
|
||||
Validaciones:
|
||||
- Verifica que no se exceda el límite de usuarios de la licencia
|
||||
- Verifica que el email y username sean únicos
|
||||
- Crea el usuario con contraseña temporal
|
||||
|
||||
Nota: El tenant_id se obtiene automáticamente del servicio (del token del usuario actual)
|
||||
Crea un nuevo usuario a través del Hub y lo asocia al tenant
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"])
|
||||
service = UserService(db, tenant_id, company_id)
|
||||
user = service.create_user(
|
||||
user = await service.create_user(
|
||||
email=data.email,
|
||||
username=data.username,
|
||||
first_name=data.first_name,
|
||||
@@ -117,7 +100,7 @@ def create_user(
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponseDTO)
|
||||
def update_user(
|
||||
async def update_user_detail(
|
||||
user_id: str,
|
||||
data: UpdateUserRequestDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
@@ -126,17 +109,10 @@ def update_user(
|
||||
):
|
||||
"""
|
||||
Actualiza información de un usuario
|
||||
|
||||
Puede actualizar:
|
||||
- Datos personales (nombre, apellido, email)
|
||||
- Estado (habilitado/deshabilitado)
|
||||
- Verificación de email
|
||||
- Rol en el tenant
|
||||
- Perfil (avatar, teléfono, bio, preferencias)
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
|
||||
service = UserService(db, tenant_id, company_id)
|
||||
user = service.update_user(
|
||||
user = await service.update_user(
|
||||
user_id=user_id,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
@@ -153,7 +129,7 @@ def update_user(
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(
|
||||
async def delete_user_route(
|
||||
user_id: str,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
soft_delete: bool = Query(
|
||||
@@ -165,18 +141,15 @@ def delete_user(
|
||||
):
|
||||
"""
|
||||
Elimina un usuario del tenant
|
||||
|
||||
- soft_delete=True: Solo desactiva la relación (recomendado)
|
||||
- soft_delete=False: Elimina permanentemente de Keycloak
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"])
|
||||
service = UserService(db, tenant_id, company_id)
|
||||
service.delete_user(user_id, soft_delete=soft_delete)
|
||||
await service.delete_user(user_id, soft_delete=soft_delete)
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/{user_id}/change-password")
|
||||
def change_user_password(
|
||||
async def change_user_password(
|
||||
user_id: str,
|
||||
data: ChangePasswordRequestDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
@@ -184,14 +157,11 @@ def change_user_password(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Cambia la contraseña de un usuario
|
||||
|
||||
- temporary=True: Usuario debe cambiar la contraseña en el próximo login
|
||||
- temporary=False: Contraseña permanente
|
||||
Cambia la contraseña de un usuario a través del Hub
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = UserService(db, tenant_id, company_id)
|
||||
service.change_password(user_id, data.password, data.temporary)
|
||||
await service.change_password(user_id, data.password, data.temporary)
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
@@ -199,13 +169,12 @@ def change_user_password(
|
||||
|
||||
|
||||
@router.get("/me/profile", response_model=UserResponseDTO)
|
||||
def get_my_profile(
|
||||
async def get_my_profile(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Obtiene el perfil completo del usuario actual
|
||||
Incluye datos de Keycloak y datos de perfil (avatar, bio, etc.)
|
||||
"""
|
||||
keycloak_user_id = current_user.get("sub")
|
||||
if not keycloak_user_id:
|
||||
@@ -227,21 +196,17 @@ def get_my_profile(
|
||||
)
|
||||
|
||||
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
||||
return service.get_current_user_profile(keycloak_user_id)
|
||||
return await service.get_current_user_profile(keycloak_user_id)
|
||||
|
||||
|
||||
@router.put("/me/profile", response_model=UserResponseDTO)
|
||||
def update_my_profile(
|
||||
async def update_my_profile(
|
||||
data: UpdateUserRequestDTO,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Actualiza el perfil del usuario actual
|
||||
|
||||
Puede actualizar:
|
||||
- Datos de Keycloak: nombre, apellido, email
|
||||
- Datos de perfil: avatar, teléfono, biografía, preferencias
|
||||
"""
|
||||
keycloak_user_id = current_user.get("sub")
|
||||
if not keycloak_user_id:
|
||||
@@ -263,7 +228,7 @@ def update_my_profile(
|
||||
)
|
||||
|
||||
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
||||
return service.update_current_user_profile(
|
||||
return await service.update_current_user_profile(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
"""
|
||||
Servicio para gestionar usuarios de Keycloak con validación de licencias
|
||||
"""
|
||||
|
||||
import logging
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from keycloak import KeycloakAdmin, KeycloakError
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -19,24 +15,22 @@ from ..user_tenant.models import UserTenant
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_keycloak_user(
|
||||
def _normalize_user(
|
||||
user_data: Dict[str, Any],
|
||||
role: Optional[str] = None,
|
||||
user_tenant: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Normaliza los datos de usuario de Keycloak al formato esperado por el DTO
|
||||
|
||||
Keycloak usa camelCase, nuestro DTO usa snake_case
|
||||
Normaliza los datos de usuario al formato esperado por el DTO
|
||||
"""
|
||||
normalized = {
|
||||
"id": user_data.get("id"),
|
||||
"username": user_data.get("username", ""),
|
||||
"id": user_data.get("id") or user_data.get("sub"),
|
||||
"username": user_data.get("username") or user_data.get("preferred_username", ""),
|
||||
"email": user_data.get("email", ""),
|
||||
"first_name": user_data.get("firstName", ""),
|
||||
"last_name": user_data.get("lastName", ""),
|
||||
"enabled": user_data.get("enabled", False),
|
||||
"email_verified": user_data.get("emailVerified", False),
|
||||
"first_name": user_data.get("firstName") or user_data.get("name", "").split(" ")[0],
|
||||
"last_name": user_data.get("lastName") or (" ".join(user_data.get("name", "").split(" ")[1:]) if " " in user_data.get("name", "") else ""),
|
||||
"enabled": user_data.get("enabled", True),
|
||||
"email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False),
|
||||
"created_timestamp": user_data.get("createdTimestamp"),
|
||||
"role": role,
|
||||
}
|
||||
@@ -56,22 +50,13 @@ def _normalize_keycloak_user(
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Servicio para gestionar usuarios en Keycloak"""
|
||||
"""Servicio para gestionar usuarios vía Hub"""
|
||||
|
||||
def __init__(self, db: Session, tenant_id: int, company_id: int = None):
|
||||
def __init__(self, db: Session, tenant_id: int = None, company_id: int = None):
|
||||
self.db = db
|
||||
self.tenant_id = tenant_id
|
||||
self.company_id = company_id
|
||||
|
||||
# Inicializar cliente admin de Keycloak
|
||||
self.keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=settings.KEYCLOAK_REALM,
|
||||
verify=True,
|
||||
)
|
||||
|
||||
def _get_license(self) -> License:
|
||||
"""Obtiene la licencia del tenant actual"""
|
||||
license = (
|
||||
@@ -119,7 +104,7 @@ class UserService:
|
||||
f"Currently active: {active_users}. Please upgrade your license.",
|
||||
)
|
||||
|
||||
def create_user(
|
||||
async def create_user(
|
||||
self,
|
||||
email: str,
|
||||
username: str,
|
||||
@@ -131,74 +116,44 @@ class UserService:
|
||||
email_verified: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Crea un nuevo usuario en Keycloak y lo asocia al tenant
|
||||
|
||||
Args:
|
||||
email: Email del usuario
|
||||
username: Nombre de usuario
|
||||
first_name: Nombre
|
||||
last_name: Apellido
|
||||
password: Contraseña inicial
|
||||
role: Rol en el tenant
|
||||
enabled: Si el usuario está habilitado
|
||||
email_verified: Si el email está verificado
|
||||
|
||||
Returns:
|
||||
Información del usuario creado
|
||||
|
||||
Raises:
|
||||
HTTPException: Si se alcanza el límite de usuarios o falla la creación
|
||||
Crea un nuevo usuario a través del Hub y lo asocia localmente
|
||||
"""
|
||||
# Verificar límite de usuarios
|
||||
self._check_user_limit()
|
||||
|
||||
try:
|
||||
# Crear usuario en Keycloak
|
||||
new_user = {
|
||||
"email": email,
|
||||
"username": username,
|
||||
"enabled": enabled,
|
||||
"emailVerified": email_verified,
|
||||
"firstName": first_name,
|
||||
"lastName": last_name,
|
||||
"attributes": {
|
||||
"tenant_id": [
|
||||
str(self.tenant_id)
|
||||
] # Atributo requerido por Keycloak
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": password,
|
||||
"temporary": True, # Usuario debe cambiar en primer login
|
||||
# Mandar al Hub para creación en Keycloak
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
hub_response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/register",
|
||||
json={
|
||||
"email": email,
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"password": password,
|
||||
"tenant_slug": "default", # TODO: Get real slug if needed
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if hub_response.status_code != 201:
|
||||
logger.error(f"Hub registration failed: {hub_response.text}")
|
||||
raise HTTPException(status_code=hub_response.status_code, detail="Failed to create user in Hub")
|
||||
|
||||
user_id = self.keycloak_admin.create_user(new_user)
|
||||
logger.info(f"User created in Keycloak: {user_id}")
|
||||
user_data = hub_response.json()
|
||||
user_id = user_data.get("user_id")
|
||||
|
||||
# Obtener company_id si no se proporcionó
|
||||
if not self.company_id:
|
||||
# Obtener la primera company del tenant
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
|
||||
company = (
|
||||
self.db.query(Company)
|
||||
.filter(Company.tenant_id == self.tenant_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
company = self.db.query(Company).filter(Company.tenant_id == self.tenant_id).first()
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No company found for this tenant. Please create a company first.",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="No company found")
|
||||
company_id = company.id
|
||||
else:
|
||||
company_id = self.company_id
|
||||
|
||||
# Crear relación con el tenant
|
||||
# Crear relación local
|
||||
user_tenant = UserTenant(
|
||||
keycloak_user_id=user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
@@ -209,36 +164,16 @@ class UserService:
|
||||
self.db.add(user_tenant)
|
||||
self.db.commit()
|
||||
|
||||
# Obtener información completa del usuario
|
||||
user_info = self.keycloak_admin.get_user(user_id)
|
||||
return _normalize_user(user_data, role, user_tenant)
|
||||
|
||||
return _normalize_keycloak_user(user_info, role, user_tenant)
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Keycloak error creating user: {str(e)}")
|
||||
self.db.rollback()
|
||||
|
||||
# Manejar errores específicos
|
||||
if "User exists with same email" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="A user with this email already exists"
|
||||
)
|
||||
elif "User exists with same username" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="A user with this username already exists"
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error creating user in Keycloak: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating user: {str(e)}")
|
||||
logger.error(f"Error creating user: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error creating user: {str(e)}"
|
||||
)
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
def get_tenant_users(
|
||||
async def get_tenant_users(
|
||||
self, page: int = 1, page_size: int = 20, search: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -288,41 +223,18 @@ class UserService:
|
||||
user_roles_map[user_role.user_id] = []
|
||||
user_roles_map[user_role.user_id].append(user_role.company_role.name)
|
||||
|
||||
# Obtener información de Keycloak para cada usuario
|
||||
users = []
|
||||
for ut in user_tenants:
|
||||
try:
|
||||
user_info = self.keycloak_admin.get_user(ut.keycloak_user_id)
|
||||
|
||||
# Obtener roles del usuario
|
||||
roles = user_roles_map.get(ut.keycloak_user_id, [])
|
||||
role_str = ", ".join(roles) if roles else None
|
||||
|
||||
normalized_user = _normalize_keycloak_user(user_info, role_str, ut)
|
||||
# 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)
|
||||
|
||||
# Filtrar por búsqueda si se proporciona
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
if (
|
||||
search_lower in normalized_user.get("username", "").lower()
|
||||
or search_lower in normalized_user.get("email", "").lower()
|
||||
or search_lower
|
||||
in normalized_user.get("first_name", "").lower()
|
||||
or search_lower
|
||||
in normalized_user.get("last_name", "").lower()
|
||||
or search_lower
|
||||
in normalized_user.get("phone", "").lower()
|
||||
or search_lower
|
||||
in normalized_user.get("bio", "").lower()
|
||||
):
|
||||
users.append(normalized_user)
|
||||
else:
|
||||
users.append(normalized_user)
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.warning(
|
||||
f"Could not fetch user {ut.keycloak_user_id} from Keycloak: {str(e)}"
|
||||
)
|
||||
users.append(normalized_user)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing user {ut.keycloak_user_id}: {e}")
|
||||
continue
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
@@ -341,31 +253,24 @@ class UserService:
|
||||
status_code=500, detail=f"Error getting users: {str(e)}"
|
||||
)
|
||||
|
||||
def get_user(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Obtiene un usuario específico del tenant"""
|
||||
async def get_user(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Obtiene un usuario específico"""
|
||||
from ..permissions.models import UserCompanyRole
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# Verificar que el usuario pertenece al tenant
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found in this tenant")
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Obtener roles del usuario en la compañía actual
|
||||
user_roles = self.db.query(UserCompanyRole).options(
|
||||
joinedload(UserCompanyRole.company_role)
|
||||
).filter(
|
||||
# Roles locales
|
||||
user_roles = self.db.query(UserCompanyRole).options(joinedload(UserCompanyRole.company_role)).filter(
|
||||
and_(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == self.company_id,
|
||||
@@ -373,163 +278,58 @@ class UserService:
|
||||
UserCompanyRole.is_active == True
|
||||
)
|
||||
).all()
|
||||
|
||||
roles = [ur.company_role.name for ur in user_roles]
|
||||
role_str = ", ".join(roles) if roles else None
|
||||
|
||||
try:
|
||||
user_info = self.keycloak_admin.get_user(user_id)
|
||||
return _normalize_keycloak_user(user_info, role_str, user_tenant)
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Error getting user from Keycloak: {str(e)}")
|
||||
raise HTTPException(status_code=404, detail="User not found in Keycloak")
|
||||
# TODO: Call Hub if more info is needed
|
||||
return _normalize_user({"id": user_id}, role_str, user_tenant)
|
||||
|
||||
def update_user(
|
||||
self,
|
||||
user_id: str,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
email_verified: Optional[bool] = None,
|
||||
role: Optional[str] = None,
|
||||
avatar_url: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
bio: Optional[str] = None,
|
||||
preferences: Optional[dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Actualiza información de un usuario"""
|
||||
# Verificar que el usuario pertenece al tenant
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
async def update_user(self, user_id: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Actualiza información local del usuario (e identidad vía Hub si se implementa)"""
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found in this tenant")
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
try:
|
||||
# Preparar datos de actualización para Keycloak
|
||||
update_data = {}
|
||||
if first_name is not None:
|
||||
update_data["firstName"] = first_name
|
||||
if last_name is not None:
|
||||
update_data["lastName"] = last_name
|
||||
if email is not None:
|
||||
update_data["email"] = email
|
||||
if enabled is not None:
|
||||
update_data["enabled"] = enabled
|
||||
if email_verified is not None:
|
||||
update_data["emailVerified"] = email_verified
|
||||
# Actualizar campos locales
|
||||
for field in ["role", "avatar_url", "phone", "bio", "preferences"]:
|
||||
if field in kwargs and kwargs[field] is not None:
|
||||
setattr(user_tenant, field, kwargs[field])
|
||||
|
||||
# Actualizar en Keycloak si hay cambios
|
||||
if update_data:
|
||||
self.keycloak_admin.update_user(user_id, update_data)
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
return _normalize_user({"id": user_id}, user_tenant.role, user_tenant)
|
||||
|
||||
# Actualizar campos en UserTenant
|
||||
if role is not None:
|
||||
user_tenant.role = role
|
||||
if avatar_url is not None:
|
||||
user_tenant.avatar_url = avatar_url
|
||||
if phone is not None:
|
||||
user_tenant.phone = phone
|
||||
if bio is not None:
|
||||
user_tenant.bio = bio
|
||||
if preferences is not None:
|
||||
user_tenant.preferences = preferences
|
||||
async def delete_user(self, user_id: str, soft_delete: bool = True) -> None:
|
||||
"""Elimina/Desactiva usuario"""
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if soft_delete:
|
||||
user_tenant.is_active = False
|
||||
self.db.commit()
|
||||
else:
|
||||
# TODO: Call Hub to delete from Keycloak
|
||||
self.db.delete(user_tenant)
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
|
||||
# Obtener información actualizada
|
||||
user_info = self.keycloak_admin.get_user(user_id)
|
||||
|
||||
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Error updating user in Keycloak: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error updating user: {str(e)}"
|
||||
)
|
||||
|
||||
def delete_user(self, user_id: str, soft_delete: bool = True) -> None:
|
||||
"""
|
||||
Elimina un usuario del tenant
|
||||
|
||||
Args:
|
||||
user_id: ID del usuario en Keycloak
|
||||
soft_delete: Si es True, solo desactiva. Si es False, elimina de Keycloak
|
||||
"""
|
||||
# Verificar que el usuario pertenece al tenant
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found in this tenant")
|
||||
|
||||
async def change_password(self, user_id: str, password: str, temporary: bool = True) -> None:
|
||||
"""Cambia contraseña vía Hub"""
|
||||
try:
|
||||
if soft_delete:
|
||||
# Solo desactivar la relación
|
||||
user_tenant.is_active = False
|
||||
self.db.commit()
|
||||
else:
|
||||
# Eliminar permanentemente de Keycloak
|
||||
self.keycloak_admin.delete_user(user_id)
|
||||
# Eliminar relación
|
||||
self.db.delete(user_tenant)
|
||||
self.db.commit()
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Error deleting user from Keycloak: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error deleting user: {str(e)}"
|
||||
)
|
||||
|
||||
def change_password(
|
||||
self, user_id: str, password: str, temporary: bool = True
|
||||
) -> None:
|
||||
"""Cambia la contraseña de un usuario"""
|
||||
# Verificar que el usuario pertenece al tenant
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == True,
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/change-password",
|
||||
json={"user_id": user_id, "password": password, "temporary": temporary}
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found in this tenant")
|
||||
|
||||
try:
|
||||
self.keycloak_admin.set_user_password(
|
||||
user_id, password, temporary=temporary
|
||||
)
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Error changing user password: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error changing password: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
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"""
|
||||
@@ -573,93 +373,19 @@ class UserService:
|
||||
"usage_percentage": round(usage_percentage, 2),
|
||||
}
|
||||
|
||||
def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Obtiene el perfil completo del usuario actual
|
||||
Combina datos de Keycloak con datos de UserTenant
|
||||
"""
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
async def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]:
|
||||
"""Obtiene el perfil completo del usuario actual"""
|
||||
# Reutilizamos verify_token para obtener info del Hub
|
||||
from core.security import verify_token
|
||||
user_info = await verify_token(keycloak_user_id) # keycloak_user_id es el token en este contexto, o el ID
|
||||
# Nota: en routes.py se pasa el ID. Si necesitamos info real, pedimos al Hub.
|
||||
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User profile not found")
|
||||
return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant)
|
||||
|
||||
try:
|
||||
user_info = self.keycloak_admin.get_user(keycloak_user_id)
|
||||
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Error getting user from Keycloak: {str(e)}")
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
def update_current_user_profile(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
avatar_url: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
bio: Optional[str] = None,
|
||||
preferences: Optional[dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Actualiza el perfil del usuario actual
|
||||
"""
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User profile not found")
|
||||
|
||||
try:
|
||||
# Actualizar Keycloak
|
||||
update_data = {}
|
||||
if first_name is not None:
|
||||
update_data["firstName"] = first_name
|
||||
if last_name is not None:
|
||||
update_data["lastName"] = last_name
|
||||
if email is not None:
|
||||
update_data["email"] = email
|
||||
|
||||
if update_data:
|
||||
self.keycloak_admin.update_user(keycloak_user_id, update_data)
|
||||
|
||||
# Actualizar campos de perfil en UserTenant
|
||||
if avatar_url is not None:
|
||||
user_tenant.avatar_url = avatar_url
|
||||
if phone is not None:
|
||||
user_tenant.phone = phone
|
||||
if bio is not None:
|
||||
user_tenant.bio = bio
|
||||
if preferences is not None:
|
||||
user_tenant.preferences = preferences
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
|
||||
# Retornar perfil actualizado
|
||||
user_info = self.keycloak_admin.get_user(keycloak_user_id)
|
||||
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Error updating user profile: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error updating profile: {str(e)}"
|
||||
)
|
||||
async def update_current_user_profile(self, keycloak_user_id: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Actualiza el perfil del usuario actual"""
|
||||
return await self.update_user(keycloak_user_id, **kwargs)
|
||||
|
||||
@@ -25,15 +25,7 @@ class Settings(BaseSettings):
|
||||
CORE_DB_USER: str = "postgres"
|
||||
CORE_DB_PASSWORD: str = "postgres"
|
||||
|
||||
TEST_DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/anexo76_core"
|
||||
|
||||
# Keycloak
|
||||
KEYCLOAK_SERVER_URL: str = "http://localhost:8080/kcauth"
|
||||
KEYCLOAK_REALM: str = "master"
|
||||
KEYCLOAK_CLIENT_ID: str = "anexo76-backend"
|
||||
KEYCLOAK_CLIENT_SECRET: str = ""
|
||||
KEYCLOAK_ADMIN_USERNAME: str = "admin"
|
||||
KEYCLOAK_ADMIN_PASSWORD: str = "admin"
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = "change-this-secret-key-in-production"
|
||||
@@ -48,9 +40,19 @@ class Settings(BaseSettings):
|
||||
# CORS
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
# License
|
||||
LICENSE_CHECK_ENABLED: bool = True
|
||||
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
|
||||
HUB_URL: str = "http://localhost:8001"
|
||||
|
||||
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", mode="before")
|
||||
@classmethod
|
||||
def strip_quotes(cls, v: str) -> str:
|
||||
if v and isinstance(v, str):
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if not v.endswith("/"):
|
||||
v += "/"
|
||||
return v
|
||||
return v
|
||||
|
||||
# External APIs
|
||||
SITAR_API_URL: str = "api.sitar.aduanasoft.com:880"
|
||||
SITAR_API_USER: str = ""
|
||||
@@ -71,13 +73,6 @@ class Settings(BaseSettings):
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
|
||||
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", mode="before")
|
||||
@classmethod
|
||||
def strip_quotes(cls, v: str) -> str:
|
||||
if v:
|
||||
return v.strip().strip('"').strip("'")
|
||||
return v
|
||||
|
||||
@property
|
||||
def core_database_url(self) -> str:
|
||||
"""URL de conexión a la base de datos core"""
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import logging
|
||||
import time
|
||||
import httpx
|
||||
from typing import Callable
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from .config import settings
|
||||
from .database import CoreSessionLocal
|
||||
from .security import get_tenant_from_token, verify_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware original para extraer tenant_id y user_info del token.
|
||||
"""
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
# Rutas públicas que no requieren tenant
|
||||
# Permitir acceso sin autenticación a rutas de documentación y salud
|
||||
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
|
||||
public_prefixes = [
|
||||
"/api/v1/auth",
|
||||
@@ -26,14 +27,12 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
path = request.url.path
|
||||
|
||||
# 3. Bypass para rutas públicas y docs
|
||||
if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes):
|
||||
return await call_next(request)
|
||||
|
||||
if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes):
|
||||
return await call_next(request)
|
||||
|
||||
# 4. Validación estricta de Token (solo para lo que no es público ni OPTIONS)
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return JSONResponse(
|
||||
@@ -47,7 +46,7 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
try:
|
||||
user_info = verify_token(token)
|
||||
user_info = await verify_token(token)
|
||||
tenant_id = get_tenant_from_token(user_info)
|
||||
|
||||
request.state.tenant_id = tenant_id
|
||||
@@ -63,125 +62,116 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
}
|
||||
)
|
||||
|
||||
# 5. Continuar con la petición real
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware para validar la licencia del tenant antes de procesar requests
|
||||
Middleware que valida la licencia contra el Hub de Aduanasoft.
|
||||
El Hub siempre es requerido — tanto en SaaS como en self-hosted.
|
||||
Fail-closed: si el Hub no responde o la licencia es inválida, se bloquea el acceso.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
if not settings.LICENSE_CHECK_ENABLED:
|
||||
return await call_next(request)
|
||||
|
||||
# Rutas que no requieren validación de licencia
|
||||
exempt_paths = [
|
||||
"/api/docs",
|
||||
"/api/redoc",
|
||||
"/openapi.json",
|
||||
"/api/v1/auth",
|
||||
"/api/v1/auth",
|
||||
"/api/v1/status",
|
||||
"/api/v1/status",
|
||||
"/api/health",
|
||||
"/api/",
|
||||
"/api/v1/core/help-center",
|
||||
"/api/docs", "/api/redoc", "/openapi.json",
|
||||
"/api/v1/auth", "/api/v1/status", "/api/health",
|
||||
"/api/", "/api/v1/core/help-center",
|
||||
]
|
||||
|
||||
# Verificar si la ruta está exenta (comparación exacta o prefijo)
|
||||
is_exempt = False
|
||||
for path in exempt_paths:
|
||||
if request.url.path == path or (
|
||||
path != "/" and request.url.path.startswith(path)
|
||||
):
|
||||
is_exempt = True
|
||||
break
|
||||
is_exempt = any(
|
||||
request.url.path == path or (path != "/" and request.url.path.startswith(path))
|
||||
for path in exempt_paths
|
||||
)
|
||||
|
||||
if is_exempt:
|
||||
return await call_next(request)
|
||||
|
||||
# Obtener tenant_id del request state (debe ser seteado por TenantMiddleware)
|
||||
tenant_id = getattr(request.state, "tenant_id", None)
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
# Permitimos pasar para que TenantMiddleware maneje el 401
|
||||
return await call_next(request)
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
if not tenant_id:
|
||||
return await call_next(request) # Dejamos que TenantMiddleware maneje esto
|
||||
|
||||
# Validar licencia
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
# Importar aquí para evitar imports circulares
|
||||
from api.v1.modules.core.licenses.service import LicenseService
|
||||
# Validación contra el Hub Central
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.HUB_URL}/api/v1/auth/verify-license",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
license_service = LicenseService(db)
|
||||
license_info = license_service.validate_license(tenant_id)
|
||||
|
||||
if not license_info["is_valid"]:
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if not data.get("valid", False):
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={
|
||||
"error": "LICENSE_ERROR",
|
||||
"message": f"Licencia inválida: {data.get('message', 'Sin suscripción activa')}",
|
||||
"status_code": 402,
|
||||
}
|
||||
)
|
||||
request.state.license_info = data
|
||||
return await call_next(request) # <--- Único camino al éxito
|
||||
|
||||
elif response.status_code == 403:
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "HTTP_ERROR",
|
||||
"message": f"License validation failed: {license_info['reason']}",
|
||||
"status_code": 402,
|
||||
"error": "FORBIDDEN",
|
||||
"message": "El Tenant no tiene permisos en el Hub central.",
|
||||
"status_code": 403,
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.error(f"Hub error status: {response.status_code}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "HUB_ERROR",
|
||||
"message": "Error en el servidor de licencias.",
|
||||
"status_code": 503,
|
||||
}
|
||||
)
|
||||
|
||||
# Agregar info de licencia al request state
|
||||
request.state.license_info = license_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"License validation error: {str(e)}")
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
||||
logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "HTTP_ERROR",
|
||||
"message": "License validation error",
|
||||
"status_code": 500,
|
||||
"error": "HUB_OFFLINE",
|
||||
"message": "Servicio de licencias fuera de línea. Acceso denegado.",
|
||||
"status_code": 503,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected license error: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "VALIDATION_ERROR", "message": "Error interno de validación.", "status_code": 500}
|
||||
)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware para logging de requests
|
||||
Middleware original para logging de performance.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
start_time = time.time()
|
||||
|
||||
excluded_paths = [
|
||||
"/api/docs",
|
||||
"/api/redoc",
|
||||
"/openapi.json",
|
||||
"/api/v1/status",
|
||||
"/api/health",
|
||||
]
|
||||
if any(
|
||||
request.url.path == path or request.url.path.startswith(path + "/")
|
||||
for path in excluded_paths
|
||||
):
|
||||
excluded_paths = ["/api/docs", "/api/redoc", "/openapi.json", "/api/v1/status", "/api/health"]
|
||||
|
||||
if any(request.url.path == path or request.url.path.startswith(path + "/") for path in excluded_paths):
|
||||
return await call_next(request)
|
||||
|
||||
# Log request
|
||||
logger.info(f"Request: {request.method} {request.url.path}")
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Log response
|
||||
process_time = time.time() - start_time
|
||||
|
||||
logger.info(
|
||||
f"Response: {request.method} {request.url.path} "
|
||||
f"Status: {response.status_code} "
|
||||
f"Duration: {process_time:.3f}s"
|
||||
)
|
||||
|
||||
# Agregar header con tiempo de procesamiento
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
return response
|
||||
return response
|
||||
@@ -3,79 +3,167 @@ Utilidades de seguridad y autenticación con Keycloak
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from keycloak import KeycloakOpenID
|
||||
import httpx
|
||||
from cachetools import TTLCache
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from .config import settings
|
||||
from .database import get_core_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuración de Keycloak
|
||||
keycloak_openid = KeycloakOpenID(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=settings.KEYCLOAK_REALM,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
# Cache para tokens verificados (1 minuto de TTL, máximo 1000 tokens)
|
||||
token_cache = TTLCache(maxsize=1000, ttl=60)
|
||||
|
||||
# IDs de tenants ya sincronizados en este proceso (evita consultas repetidas)
|
||||
_synced_tenant_ids: Set[int] = set()
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def verify_token(token: str) -> Dict[str, Any]:
|
||||
async def verify_token(token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verifica y decodifica un token JWT de Keycloak
|
||||
|
||||
Args:
|
||||
token: Token JWT
|
||||
|
||||
Returns:
|
||||
Payload del token decodificado
|
||||
|
||||
Raises:
|
||||
HTTPException: Si el token es inválido
|
||||
Verifica un token JWT llamando al Hub central.
|
||||
"""
|
||||
# Check cache first
|
||||
if token in token_cache:
|
||||
return token_cache[token]
|
||||
|
||||
try:
|
||||
# Obtener clave pública de Keycloak
|
||||
KEYCLOAK_PUBLIC_KEY = (
|
||||
"-----BEGIN PUBLIC KEY-----\n"
|
||||
+ keycloak_openid.public_key()
|
||||
+ "\n-----END PUBLIC KEY-----"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
# Decodificar y verificar token
|
||||
options = {"verify_signature": True, "verify_aud": False, "verify_exp": True}
|
||||
|
||||
decoded_token = jwt.decode(
|
||||
token, KEYCLOAK_PUBLIC_KEY, algorithms=["RS256"], options=options
|
||||
)
|
||||
|
||||
return decoded_token
|
||||
|
||||
except JWTError as e:
|
||||
logger.error(f"Token verification failed: {str(e)}")
|
||||
if response.status_code == 200:
|
||||
user_info = response.json()
|
||||
token_cache[token] = user_info
|
||||
return user_info
|
||||
|
||||
logger.error(f"Hub token verification failed with status {response.status_code}")
|
||||
raise HTTPException(status_code=401, detail="Could not validate credentials")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Hub unreachable or error during token verification: {str(e)}")
|
||||
raise HTTPException(status_code=503, detail="Authentication service unavailable")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token verification: {str(e)}")
|
||||
raise HTTPException(status_code=401, detail="Authentication error")
|
||||
|
||||
|
||||
def _ensure_company_exists(db: Session, tenant_id: int, tenant_name: str) -> 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.
|
||||
"""
|
||||
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)
|
||||
db.commit()
|
||||
logger.info(f"Empresa creada automáticamente para tenant id={tenant_id}: '{tenant_name}'")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
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:
|
||||
"""
|
||||
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
|
||||
|
||||
try:
|
||||
# Importación local para evitar imports circulares
|
||||
from api.v1.modules.core.tenants.models import Tenant, TenantType
|
||||
|
||||
name = " ".join(word.capitalize() for word in tenant_slug.replace("-", " ").split())
|
||||
|
||||
existing = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if existing:
|
||||
# Update name/slug if they differ (Hub is source of truth)
|
||||
if existing.slug != tenant_slug or existing.name != name:
|
||||
existing.slug = tenant_slug
|
||||
existing.name = name
|
||||
db.commit()
|
||||
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)
|
||||
return
|
||||
|
||||
# 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.
|
||||
tenant = Tenant(
|
||||
id=tenant_id,
|
||||
name=name,
|
||||
slug=tenant_slug,
|
||||
type=TenantType.SHARED,
|
||||
keycloak_realm=tenant_slug,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(tenant)
|
||||
db.commit()
|
||||
_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)
|
||||
|
||||
except IntegrityError:
|
||||
# Puede ser concurrencia o colisión de slug (id diferente, mismo slug)
|
||||
db.rollback()
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
# Si el slug ya existe con diferente id, el tenant real del Hub no está registrado aún.
|
||||
# Logueamos el conflicto para depuración; el sistema continuará con tenant_id vacío.
|
||||
stale = db.query(Tenant).filter(Tenant.slug == tenant_slug).first()
|
||||
if stale and stale.id != tenant_id:
|
||||
logger.error(
|
||||
f"Conflicto de tenant: JWT dice id={tenant_id} slug='{tenant_slug}', "
|
||||
f"pero core.tenants tiene id={stale.id} mismo slug. "
|
||||
f"Elimine el registro obsoleto con: "
|
||||
f"DELETE FROM core.tenants WHERE id={stale.id};"
|
||||
)
|
||||
else:
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning(f"No se pudo sincronizar tenant {tenant_id} ({tenant_slug}): {e}")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||
db: Session = Depends(get_core_db),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Dependency para obtener el usuario actual desde el token JWT
|
||||
Dependency para obtener el usuario actual desde el token JWT.
|
||||
Auto-sincroniza el tenant en core.tenants si fue creado en el Hub
|
||||
pero aún no existe en la BD local.
|
||||
|
||||
Uso en FastAPI:
|
||||
current_user: dict = Depends(get_current_user)
|
||||
"""
|
||||
token = credentials.credentials
|
||||
user_info = verify_token(token)
|
||||
user_info = await verify_token(token)
|
||||
|
||||
# Sincronizar tenant desde Hub a BD local (solo la primera vez por tenant)
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
tenant_slug = user_info.get("tenant_slug")
|
||||
if tenant_id and tenant_slug:
|
||||
_ensure_tenant_synced(db, int(tenant_id), str(tenant_slug))
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ psycopg2-binary==2.9.11
|
||||
asyncpg==0.30.0
|
||||
|
||||
# Authentication & Authorization
|
||||
python-keycloak==5.8.1
|
||||
cachetools==5.5.0
|
||||
python-jose[cryptography]==3.5.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
||||
|
||||
@@ -35,122 +35,6 @@ services:
|
||||
memory: 256M
|
||||
shm_size: 128mb
|
||||
|
||||
# PostgreSQL - Base de datos Keycloak
|
||||
postgres-keycloak:
|
||||
image: postgres:18-alpine
|
||||
container_name: anexo76-postgres-keycloak
|
||||
environment:
|
||||
POSTGRES_DB: keycloak
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_keycloak_data:/var/lib/postgresql/data
|
||||
- ./scripts/postgres-keycloak-entrypoint.sh:/docker-entrypoint-initdb.d/init-keycloak.sh:ro
|
||||
networks:
|
||||
- auth-net
|
||||
- backend-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres -d keycloak || exit 1" ]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 256M
|
||||
shm_size: 128mb
|
||||
|
||||
# Keycloak - Servidor de autenticación
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4
|
||||
container_name: anexo76-keycloak
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
|
||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: postgres-keycloak
|
||||
KC_DB_URL_PORT: "5432"
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak
|
||||
KC_DB_USERNAME: postgres
|
||||
KC_DB_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres}
|
||||
KC_DB_SCHEMA: public
|
||||
KC_HOSTNAME: localhost
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_HOSTNAME_STRICT: "false"
|
||||
KC_HOSTNAME_STRICT_HTTPS: "false"
|
||||
KC_PROXY_HEADERS: "xforwarded"
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_METRICS_ENABLED: "true"
|
||||
KC_HOSTNAME_PATH: /kcauth
|
||||
KC_LOG_LEVEL: INFO
|
||||
JAVA_OPTS_APPEND: "-Xms256m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true"
|
||||
command:
|
||||
- start-dev
|
||||
- --http-relative-path=/kcauth
|
||||
- --db=postgres
|
||||
- --db-url-host=postgres-keycloak
|
||||
- --db-url-port=5432
|
||||
- --db-url-database=keycloak
|
||||
- --db-username=postgres
|
||||
- --db-password=${POSTGRES_KEYCLOAK_PASSWORD:-postgres}
|
||||
- --http-enabled=true
|
||||
- --hostname-strict=false
|
||||
- --proxy-headers=xforwarded
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "9000:9000"
|
||||
depends_on:
|
||||
postgres-keycloak:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- keycloak_data:/opt/keycloak/data
|
||||
networks:
|
||||
- auth-net
|
||||
- backend-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r
|
||||
|
||||
Host: localhost\r
|
||||
|
||||
Connection: close\r
|
||||
|
||||
\r
|
||||
|
||||
' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 90s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
reservations:
|
||||
memory: 512M
|
||||
|
||||
# Backend - FastAPI
|
||||
backend:
|
||||
build:
|
||||
@@ -170,10 +54,8 @@ services:
|
||||
- CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core}
|
||||
- CORE_DB_USER=${CORE_DB_USER:-postgres}
|
||||
- CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres}
|
||||
- KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth}
|
||||
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
|
||||
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
|
||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret}
|
||||
# Keycloak — apunta al Keycloak del Hub (ya no tiene Keycloak propio)
|
||||
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
|
||||
- SITAR_API_URL=${SITAR_API_URL}
|
||||
- SITAR_API_USER=${SITAR_API_USER}
|
||||
@@ -182,13 +64,13 @@ 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 interna para validación de licencias
|
||||
- HUB_URL=${HUB_URL:-http://host.docker.internal:8001}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres-a76:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- backend_cache:/app/__pycache__
|
||||
@@ -231,14 +113,12 @@ services:
|
||||
- NODE_ENV=${NODE_ENV:-development}
|
||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:8000/api/}
|
||||
- INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/}
|
||||
- VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-http://localhost:8080/kcauth}
|
||||
- VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master}
|
||||
- VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend}
|
||||
- KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080/kcauth}
|
||||
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
|
||||
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
|
||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9}
|
||||
- VITE_HUB_MODE=${VITE_HUB_MODE:-true}
|
||||
# Hub — URL pública para el browser y URL interna para server-side
|
||||
- VITE_HUB_URL=${VITE_HUB_URL:-http://localhost:8001}
|
||||
- INTERNAL_HUB_URL=${INTERNAL_HUB_URL:-http://host.docker.internal:8001}
|
||||
# CORS / CSRF — trusted origins para svelte.config.js
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3001}
|
||||
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-}
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
@@ -251,7 +131,6 @@ services:
|
||||
- ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro
|
||||
networks:
|
||||
- frontend-net
|
||||
- auth-net
|
||||
restart: unless-stopped
|
||||
command: [ "pnpm", "run", "dev", "--", "--host", "0.0.0.0" ]
|
||||
healthcheck:
|
||||
@@ -266,7 +145,7 @@ services:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# celery
|
||||
# Celery Worker
|
||||
celery_worker:
|
||||
build: ./backend
|
||||
container_name: worker
|
||||
@@ -285,10 +164,6 @@ services:
|
||||
- CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core}
|
||||
- CORE_DB_USER=${CORE_DB_USER:-postgres}
|
||||
- CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres}
|
||||
- KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth}
|
||||
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
|
||||
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
|
||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret}
|
||||
- VALKEY_URL=redis://valkey:6379/0
|
||||
- SITAR_API_URL=${SITAR_API_URL}
|
||||
- SITAR_API_USER=${SITAR_API_USER}
|
||||
@@ -303,6 +178,7 @@ services:
|
||||
networks:
|
||||
- backend-net
|
||||
|
||||
# Celery Beat
|
||||
celery_beat:
|
||||
build: ./backend
|
||||
container_name: celery_beat
|
||||
@@ -339,10 +215,6 @@ services:
|
||||
volumes:
|
||||
postgres_app_data:
|
||||
driver: local
|
||||
postgres_keycloak_data:
|
||||
driver: local
|
||||
keycloak_data:
|
||||
driver: local
|
||||
frontend_node_modules:
|
||||
driver: local
|
||||
backend_cache:
|
||||
@@ -356,13 +228,8 @@ networks:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
auth-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.21.0.0/16
|
||||
frontend-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.22.0.0/16
|
||||
- subnet: 172.22.0.0/16
|
||||
@@ -517,7 +517,16 @@ export const api = {
|
||||
api.post('/v1/auth/refresh/', { refresh_token: refreshToken }),
|
||||
logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }),
|
||||
me: () => api.get('/v1/auth/me/'),
|
||||
health: () => api.get('/health')
|
||||
health: () => api.get('/health'),
|
||||
register: (data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
tenant_slug: string;
|
||||
invite_token?: string;
|
||||
}) => api.post('/v1/auth/register', data),
|
||||
},
|
||||
|
||||
tenants: {
|
||||
|
||||
@@ -124,13 +124,16 @@
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={helpStore.isOpen}>
|
||||
<Sheet.Trigger asChild>
|
||||
<button
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
</button>
|
||||
<Sheet.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sheet.Trigger>
|
||||
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
|
||||
<Sheet.Header>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Card from "$lib/components/ui/card/index.ts";
|
||||
import {
|
||||
FieldGroup,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
} from "$lib/components/ui/field/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
} from "$lib/components/ui/field/index.ts";
|
||||
import { Input } from "$lib/components/ui/input/index.ts";
|
||||
import { Button } from "$lib/components/ui/button/index.ts";
|
||||
import { cn } from "$lib/utils.ts";
|
||||
import faviconUrl from '$lib/assets/favicon.svg';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { page } from '$app/state';
|
||||
import { page } from '$app/stores';
|
||||
import { enhance } from '$app/forms';
|
||||
import { loginWithProvider } from '$lib/sso';
|
||||
import { loginWithProvider } from '$lib/sso.ts';
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
|
||||
const id = $props.id();
|
||||
|
||||
let username = $state('demo');
|
||||
let password = $state('demo123');
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let tenantSlug = $state('');
|
||||
let loading = $state(false);
|
||||
// step 1 = credenciales, step 2 = selección de organización
|
||||
@@ -38,6 +38,11 @@
|
||||
// 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
|
||||
@@ -63,8 +68,6 @@
|
||||
async function fetchTenants(): Promise<TenantInfo[]> {
|
||||
try {
|
||||
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
|
||||
// Llama a /login SIN tenant_slug: el backend verifica credenciales primero,
|
||||
// luego devuelve las orgs. Sin contraseña válida no se revela nada.
|
||||
const res = await fetch(`${apiBase}/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -72,8 +75,10 @@
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// { status: "choose_tenant", tenants: [...] }
|
||||
// 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];
|
||||
}
|
||||
} catch {
|
||||
// ignore — el server action mostrará el error de autenticación
|
||||
|
||||
@@ -28,11 +28,21 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
// Cargar las compañías del usuario en el servidor (SSR)
|
||||
const companies = await getUserCompanies(cookies, fetch);
|
||||
|
||||
// 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) {
|
||||
const cookieId = parseInt(cookieCompanyId);
|
||||
const stillExists = companies.some((c) => c.id === cookieId);
|
||||
if (!stillExists) {
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar los tenants del usuario para el selector de organización
|
||||
let userTenants: { id: number; name: string; slug: string; is_active: boolean }[] = [];
|
||||
try {
|
||||
const tenantsRes = await authenticatedFetch(
|
||||
`v1/core/user-tenants/${userData.sub}`,
|
||||
`v1/core/user-tenants/user/${userData.sub}`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
|
||||
@@ -30,8 +30,8 @@ export const actions = {
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
const loginUrl = `${baseUrl}v1/auth/login`;
|
||||
const hubUrl = process.env.INTERNAL_HUB_URL || 'http://host.docker.internal:8001';
|
||||
const loginUrl = `${hubUrl}/api/v1/auth/login`;
|
||||
|
||||
const requestBody = {
|
||||
username,
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// Parámetros del URL — se rellenan desde el link de invitación
|
||||
let inviteToken = $state('');
|
||||
let inviteTenantSlug = $state('');
|
||||
let inviteEmail = $state('');
|
||||
let isInviteFlow = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
inviteToken = params.get('invite_token') ?? '';
|
||||
inviteTenantSlug = params.get('tenant') ?? '';
|
||||
inviteEmail = params.get('email') ?? '';
|
||||
isInviteFlow = Boolean(inviteToken && inviteTenantSlug);
|
||||
|
||||
if (isInviteFlow) {
|
||||
// Pre-rellenar campos bloqueados desde la invitación
|
||||
formData.tenant_slug = inviteTenantSlug;
|
||||
if (inviteEmail) formData.email = inviteEmail;
|
||||
}
|
||||
});
|
||||
|
||||
let formData = $state({
|
||||
username: '',
|
||||
@@ -9,25 +31,24 @@
|
||||
confirmPassword: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
tenant_slug: 'aduanasoft' // Por defecto
|
||||
tenant_slug: 'aduanasoft'
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let passwordError = $state('');
|
||||
let success = $state(false);
|
||||
|
||||
async function handleRegister(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
passwordError = '';
|
||||
|
||||
// Validar que las contraseñas coincidan
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
passwordError = 'Las contraseñas no coinciden';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar longitud de contraseña
|
||||
if (formData.password.length < 8) {
|
||||
passwordError = 'La contraseña debe tener al menos 8 caracteres';
|
||||
return;
|
||||
@@ -36,22 +57,30 @@
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const response = await api.auth.register({
|
||||
const payload: Record<string, string> = {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
tenant_slug: formData.tenant_slug
|
||||
});
|
||||
tenant_slug: formData.tenant_slug,
|
||||
};
|
||||
|
||||
if (inviteToken) {
|
||||
payload.invite_token = inviteToken;
|
||||
}
|
||||
|
||||
const response = await api.auth.register(payload as any);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
// Registro exitoso, redirigir al login
|
||||
alert(`¡Registro exitoso! Bienvenido ${response.data.username}`);
|
||||
goto('/login');
|
||||
|
||||
success = true;
|
||||
// Redirigir al login con el tenant pre-seleccionado tras unos segundos
|
||||
setTimeout(() => {
|
||||
goto(`/login?tenant=${encodeURIComponent(formData.tenant_slug)}`);
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al registrar usuario';
|
||||
} finally {
|
||||
@@ -77,6 +106,45 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Banner de invitación -->
|
||||
{#if isInviteFlow}
|
||||
<div class="mt-4 rounded-md bg-blue-50 border border-blue-200 p-4">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">Invitación válida</p>
|
||||
<p class="text-sm text-blue-700 mt-1">
|
||||
Estás registrándote en <strong>{inviteTenantSlug}</strong>.
|
||||
El enlace caduca en 48 horas y es de un solo uso.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pantalla de éxito -->
|
||||
{#if success}
|
||||
<div class="mt-8 rounded-lg bg-white px-6 py-8 shadow text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">¡Cuenta creada!</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Te hemos enviado un correo de verificación a <strong>{formData.email}</strong>.
|
||||
Confirma tu email antes de iniciar sesión.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="mt-6 inline-block rounded-md bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Ir al login
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Formulario de registro -->
|
||||
<div class="mt-8">
|
||||
<div class="rounded-lg bg-white px-6 py-8 shadow">
|
||||
@@ -108,9 +176,13 @@
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||||
readonly={isInviteFlow && Boolean(inviteEmail)}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 {isInviteFlow && inviteEmail ? 'bg-gray-50 text-gray-500 cursor-not-allowed' : ''}"
|
||||
placeholder="usuario@ejemplo.com"
|
||||
/>
|
||||
{#if isInviteFlow && inviteEmail}
|
||||
<p class="mt-1 text-xs text-gray-500">El email está fijado por la invitación.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Nombre -->
|
||||
@@ -187,15 +259,25 @@
|
||||
<label for="tenant_slug" class="block text-sm font-medium text-gray-700">
|
||||
Empresa
|
||||
</label>
|
||||
<select
|
||||
id="tenant_slug"
|
||||
bind:value={formData.tenant_slug}
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||||
>
|
||||
<option value="aduanasoft">AduanaSoft</option>
|
||||
<!-- Agregar más tenants aquí -->
|
||||
</select>
|
||||
{#if isInviteFlow}
|
||||
<input
|
||||
type="text"
|
||||
id="tenant_slug"
|
||||
value={formData.tenant_slug}
|
||||
readonly
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">Fijado por la invitación.</p>
|
||||
{:else}
|
||||
<select
|
||||
id="tenant_slug"
|
||||
bind:value={formData.tenant_slug}
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||||
>
|
||||
<option value="aduanasoft">AduanaSoft</option>
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Errores -->
|
||||
@@ -233,5 +315,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,8 +37,11 @@ wait_for_tcp() {
|
||||
# Esperar a PostgreSQL
|
||||
wait_for_tcp "${CORE_DB_HOST:-postgres-a76}" "${CORE_DB_PORT:-5432}" "PostgreSQL"
|
||||
|
||||
# Esperar a Keycloak (HTTP por defecto usa puerto 8080)
|
||||
wait_for_tcp "keycloak" "8080" "Keycloak"
|
||||
# Esperar al Hub de Aduanasoft (opcional, pero recomendado)
|
||||
# Extraer host y puerto de HUB_URL si es posible, o usar el default del docker-compose
|
||||
# HUB_HOST=$(echo $HUB_URL | sed -e 's|http://||' -e 's|:.*||')
|
||||
# HUB_PORT=$(echo $HUB_URL | sed -e 's|.*:||' -e 's|/.*||')
|
||||
# wait_for_tcp "${HUB_HOST:-host.docker.internal}" "${HUB_PORT:-8001}" "Aduanasoft Hub"
|
||||
|
||||
# Ejecutar migraciones de Alembic
|
||||
#if [ -d "/app/alembic" ]; then
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user