feat: implement user invitation system

- Add InviteToken model and Alembic migration (core.invite_tokens)
- Add invites module: DTOs, service (create/validate/consume), routes
- Register invites router in core router
- Extend auth register endpoint to support invite_token flow:
  - Validate local token, create user via Hub admin API (service account),
    handle existing user (link instead of duplicate), create UserTenant, consume token
- Add GET /auth/register/check endpoint to validate token without consuming
- Add HUB_ADMIN_EMAIL, HUB_ADMIN_PASSWORD, APP_PUBLIC_URL to config
- Frontend: add invite() method and types to users.ts API client
- Frontend: add invite dialog with real roles dropdown to users page
- Frontend: update register page to handle invite flow and user-exists case
This commit is contained in:
2026-05-06 16:57:29 -05:00
parent 5dd65df3e8
commit 1f2f8d1f83
15 changed files with 881 additions and 28 deletions

View File

@@ -98,6 +98,7 @@ class RegisterRequestDTO(BaseModel):
first_name: str = Field(..., min_length=2, max_length=50, description="Nombre")
last_name: str = Field(..., min_length=2, max_length=50, description="Apellido")
tenant_slug: str = Field(..., description="Slug del tenant")
invite_token: Optional[str] = Field(None, description="Token de invitación local (opcional)")
class Config:
json_schema_extra = {

View File

@@ -4,7 +4,7 @@ Endpoints API para autenticación
from core.database import get_core_db
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, Response, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
@@ -28,6 +28,73 @@ router = APIRouter(prefix="/auth", tags=["Authentication"])
security = HTTPBearer()
@router.get("/register/check")
async def check_register(
invite_token: str = Query(..., description="Token de invitación"),
tenant_slug: str = Query(..., description="Slug del tenant"),
email: str = Query(..., description="Email del usuario invitado"),
db: Session = Depends(get_core_db),
):
"""
Valida un token de invitación y verifica si el email ya existe en Keycloak.
No consume el token. Responde con user_exists y datos básicos del usuario si ya existe.
"""
from api.v1.modules.core.invites.service import InviteService
import httpx
from core.config import settings
invite_service = InviteService(db)
# Valida token (lanza 403 si es inválido)
invite_result = invite_service.validate(invite_token, tenant_slug, email)
# Intentar verificar si el email ya existe en el Hub usando service account
user_exists = False
user_info: dict = {}
if settings.HUB_ADMIN_EMAIL and settings.HUB_ADMIN_PASSWORD:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Login con service account
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
)
if login_resp.status_code == 200:
svc_token = login_resp.json().get("access_token", "")
if svc_token:
# Buscar admin por email
admins_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins",
params={"email": email},
headers={"Authorization": f"Bearer {svc_token}"},
)
if admins_resp.status_code == 200:
admins = admins_resp.json()
if isinstance(admins, list):
matches = [a for a in admins if a.get("email", "").lower() == email.lower()]
elif isinstance(admins, dict) and "items" in admins:
matches = [a for a in admins["items"] if a.get("email", "").lower() == email.lower()]
else:
matches = []
if matches:
user_exists = True
a = matches[0]
user_info = {
"username": a.get("username", ""),
"first_name": a.get("first_name", ""),
"last_name": a.get("last_name", ""),
}
except Exception as exc:
import logging
logging.getLogger(__name__).warning("register/check Hub lookup failed: %s", exc)
return {
"email": invite_result.email,
"role": invite_result.role,
"user_exists": user_exists,
**user_info,
}
@router.post("/register", response_model=RegisterResponseDTO, status_code=201)
async def register(
register_data: RegisterRequestDTO, db: Session = Depends(get_core_db)

View File

@@ -131,21 +131,146 @@ class AuthService:
async def register(self, register_data: Any) -> Any:
"""
Registra un usuario a través del Hub
Registra un usuario.
- Si trae invite_token: valida el token local, crea usuario en Hub y
genera la fila UserTenant local, luego consume el token.
- Si no trae invite_token: reenvía directamente al Hub (flujo original).
"""
if getattr(register_data, "invite_token", None):
return await self._register_with_invite(register_data)
# Flujo original — reenviar al Hub sin invite_token
try:
payload = register_data.model_dump(exclude={"invite_token"})
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()
json=payload,
)
if response.status_code == 201:
return response.json()
raise HTTPException(status_code=response.status_code, detail=response.text)
except HTTPException:
raise
except Exception as e:
logger.error(f"Registration error: {str(e)}")
raise HTTPException(status_code=500, detail="Registration error")
async def _register_with_invite(self, register_data: Any) -> Any:
"""Flujo de registro con token de invitación local."""
from api.v1.modules.core.invites.service import InviteService
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
invite_service = InviteService(self.db)
# 1. Validar invite token (sin consumir)
invite_result = invite_service.validate(
register_data.invite_token,
register_data.tenant_slug,
str(register_data.email),
)
# 2. Buscar tenant local
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == register_data.tenant_slug)
.first()
)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado")
# 3. Obtener token de service account y gestionar usuario en Hub
hub_user_id = None
try:
async with httpx.AsyncClient(timeout=15.0) as client:
# Login con service account
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={
"username": settings.HUB_ADMIN_EMAIL,
"password": settings.HUB_ADMIN_PASSWORD,
},
)
if login_resp.status_code != 200:
raise HTTPException(status_code=503, detail="No se pudo autenticar con el sistema de autenticación")
svc_token = login_resp.json().get("access_token", "")
# Verificar si el usuario ya existe en el Hub
search_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins",
params={"email": str(register_data.email)},
headers={"Authorization": f"Bearer {svc_token}"},
)
existing_user = None
if search_resp.status_code == 200:
admins = search_resp.json()
items = admins if isinstance(admins, list) else admins.get("items", [])
matches = [a for a in items if a.get("email", "").lower() == str(register_data.email).lower()]
if matches:
existing_user = matches[0]
if existing_user:
# Usuario ya existe — solo vinculamos (no creamos nuevo)
hub_user_id = existing_user.get("id")
else:
# Crear usuario via admin endpoint (no requiere invite_token)
hub_payload = {
"username": register_data.username,
"email": str(register_data.email),
"password": register_data.password,
"first_name": register_data.first_name,
"last_name": register_data.last_name,
"tenant_slug": register_data.tenant_slug,
}
create_resp = await client.post(
f"{settings.HUB_URL}api/v1/hub/admins",
json=hub_payload,
headers={"Authorization": f"Bearer {svc_token}"},
)
if create_resp.status_code in (200, 201):
hub_user_id = create_resp.json().get("id")
else:
try:
detail = create_resp.json().get("detail", create_resp.text)
except Exception:
detail = create_resp.text
raise HTTPException(status_code=create_resp.status_code, detail=detail)
except HTTPException:
raise
except Exception as exc:
logger.error("Hub admin create error during invite flow: %s", exc)
raise HTTPException(status_code=503, detail="Error al crear usuario en el sistema de autenticación")
# 4. Crear fila UserTenant local
if hub_user_id and invite_result.company_id:
try:
ut = UserTenant(
keycloak_user_id=hub_user_id,
tenant_id=tenant.id,
company_id=invite_result.company_id,
role=invite_result.role,
is_active=True,
first_name=register_data.first_name,
last_name=register_data.last_name,
)
self.db.add(ut)
self.db.commit()
except Exception as exc:
logger.warning("Could not create UserTenant (may already exist): %s", exc)
self.db.rollback()
# 5. Consumir invite token
invite_service.consume_by_id(invite_result.invite_id)
return {
"user_id": hub_user_id or "",
"username": register_data.username,
"email": str(register_data.email),
"message": "Usuario registrado exitosamente",
}
async def exchange_code(self, exchange_data: Any) -> TokenResponseDTO:
"""
Intercambia código por tokens a través del Hub