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:
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user