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

View File

@@ -0,0 +1,32 @@
"""DTOs para el módulo de invitaciones."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
class CreateInviteDTO(BaseModel):
email: EmailStr
company_id: int
role_id: int
class InviteResponseDTO(BaseModel):
id: int
email: str
role: str
expires_at: datetime
invite_url: str
created_at: datetime
class Config:
from_attributes = True
class InviteValidationResult(BaseModel):
email: str
role: str
invite_id: int
tenant_slug: str
company_id: Optional[int] = None

View File

@@ -0,0 +1,39 @@
"""Modelo de token de invitación local para registro de usuarios en Anexo76."""
from datetime import datetime
from typing import Optional
from api.v1.common.base_models import BaseTimestampMixin
from core.database import Base
from sqlalchemy import DateTime, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
class InviteToken(Base, BaseTimestampMixin):
"""
Token de invitación de un solo uso para registro de usuarios.
El token en claro NUNCA se almacena; solo su hash SHA-256.
"""
__tablename__ = "invite_tokens"
__table_args__ = {"schema": "core", "extend_existing": True}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# sha256(token_plain) — índice único
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
tenant_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False, server_default="user")
# keycloak_user_id del admin que generó la invitación
created_by: Mapped[str] = mapped_column(String(255), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
product_ids: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
# Específico de Anexo76: empresa destino para crear UserTenant
company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)

View File

@@ -0,0 +1,48 @@
"""Rutas para gestión de invitaciones de usuarios."""
import logging
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from core.config import settings
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from .dto import CreateInviteDTO, InviteResponseDTO
from .service import InviteService
router = APIRouter(prefix="/invites", tags=["Invites"])
logger = logging.getLogger(__name__)
@router.post("", response_model=InviteResponseDTO, status_code=201)
async def create_invite(
data: CreateInviteDTO,
request: Request,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Genera un token de invitación para que un usuario externo se registre.
Requiere permiso user.create.
"""
# Validar acceso (user.create) y obtener tenant_id
validate_access_to_resource(
db,
data.company_id,
current_user,
required_permissions=["user.create"],
)
tenant_slug: str = current_user.get("tenant_slug") or ""
created_by: str = current_user.get("sub") or ""
base_url = settings.APP_PUBLIC_URL.rstrip("/")
service = InviteService(db)
return await service.create_invite(
data=data,
created_by=created_by,
tenant_slug=tenant_slug,
base_url=base_url,
)

View File

@@ -0,0 +1,233 @@
"""Servicio de invitaciones de usuarios para Anexo76."""
import hashlib
import logging
import secrets
import ssl
from datetime import datetime, timedelta, timezone
from typing import Optional
import aiosmtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from fastapi import HTTPException
from sqlalchemy.orm import Session
from core.config import settings
from .dto import CreateInviteDTO, InviteResponseDTO, InviteValidationResult
from .models import InviteToken
logger = logging.getLogger(__name__)
INVITE_TTL_HOURS = 48
def _hash_token(token_plain: str) -> str:
return hashlib.sha256(token_plain.encode()).hexdigest()
class InviteService:
def __init__(self, db: Session):
self.db = db
async def create_invite(
self,
data: CreateInviteDTO,
created_by: str,
tenant_slug: str,
base_url: str,
) -> InviteResponseDTO:
from api.v1.modules.core.tenants.models import Tenant
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == tenant_slug, Tenant.is_active == True)
.first()
)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado")
token_plain = secrets.token_urlsafe(32)
token_hash = _hash_token(token_plain)
expires_at = datetime.now(timezone.utc) + timedelta(hours=INVITE_TTL_HOURS)
from api.v1.modules.core.permissions.models import CompanyRole
company_role = (
self.db.query(CompanyRole)
.filter(
CompanyRole.id == data.role_id,
CompanyRole.company_id == data.company_id,
CompanyRole.is_active == True,
)
.first()
)
if not company_role:
raise HTTPException(status_code=404, detail="Rol no encontrado")
invite = InviteToken(
token_hash=token_hash,
tenant_slug=tenant_slug,
email=str(data.email),
role=company_role.code,
created_by=created_by,
expires_at=expires_at,
company_id=data.company_id,
)
self.db.add(invite)
self.db.commit()
self.db.refresh(invite)
invite_url = (
f"{base_url}/register"
f"?invite_token={token_plain}"
f"&tenant={tenant_slug}"
f"&email={data.email}"
)
# Enviar email (best-effort — si falla se loguea la URL para el admin)
try:
await self._send_invite_email(
to_email=str(data.email),
tenant_name=tenant.name,
invite_url=invite_url,
)
except Exception as exc:
logger.warning(
"Invite email send failed (non-blocking): %s — invite_url=%s",
exc,
invite_url,
)
return InviteResponseDTO(
id=invite.id,
email=invite.email,
role=invite.role,
expires_at=invite.expires_at,
invite_url=invite_url,
created_at=invite.created_at,
)
def validate(
self,
token_plain: str,
tenant_slug: str,
email: Optional[str] = None,
) -> InviteValidationResult:
"""Valida el token sin consumirlo. Lanza 403 genérico por seguridad."""
token_hash = _hash_token(token_plain)
now = datetime.now(timezone.utc)
invite = (
self.db.query(InviteToken)
.filter(
InviteToken.token_hash == token_hash,
InviteToken.tenant_slug == tenant_slug,
InviteToken.used_at.is_(None),
InviteToken.expires_at > now,
)
.first()
)
if not invite:
raise HTTPException(
status_code=403,
detail="Token de invitación inválido o expirado",
)
if email and invite.email.lower() != email.lower():
raise HTTPException(
status_code=403,
detail="Token de invitación inválido o expirado",
)
return InviteValidationResult(
email=invite.email,
role=invite.role,
invite_id=invite.id,
tenant_slug=invite.tenant_slug,
company_id=invite.company_id,
)
def consume_by_id(self, invite_id: int) -> None:
invite = self.db.query(InviteToken).filter(InviteToken.id == invite_id).first()
if invite:
invite.used_at = datetime.now(timezone.utc)
self.db.commit()
async def _send_invite_email(
self,
to_email: str,
tenant_name: str,
invite_url: str,
) -> None:
msg = MIMEMultipart("alternative")
msg["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
msg["To"] = to_email
msg["Subject"] = f"Invitación para unirse a {tenant_name} en Anexo76"
html = f"""
<html>
<body style="font-family: Arial, sans-serif; background: #f3f4f6; padding: 40px 0;">
<div style="max-width: 600px; margin: 0 auto; background: #ffffff; border-radius: 8px;
overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
<div style="background: #2563eb; padding: 32px 40px;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Anexo76</h1>
<p style="color: #bfdbfe; margin: 8px 0 0;">Sistema de gestión aduanal</p>
</div>
<div style="padding: 40px;">
<h2 style="color: #111827; font-size: 20px; margin-top: 0;">
Te han invitado a {tenant_name}
</h2>
<p style="color: #4b5563; line-height: 1.6;">
Has recibido una invitación para unirte a <strong>{tenant_name}</strong>
en Anexo76. Haz clic en el botón para crear tu cuenta.
</p>
<div style="text-align: center; margin: 32px 0;">
<a href="{invite_url}"
style="display: inline-block; background: #2563eb; color: #ffffff;
text-decoration: none; padding: 14px 32px; border-radius: 6px;
font-weight: 600; font-size: 16px;">
Aceptar invitación
</a>
</div>
<p style="color: #6b7280; font-size: 13px; line-height: 1.5;">
Este enlace es válido por <strong>48 horas</strong> y es de
<strong>un solo uso</strong>.<br>
Si no esperabas esta invitación, puedes ignorar este correo.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
O copia este enlace en tu navegador:<br>
<span style="color: #2563eb; word-break: break-all;">{invite_url}</span>
</p>
</div>
</div>
</body>
</html>
"""
msg.attach(MIMEText(html, "html"))
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
if settings.SMTP_PORT == 465:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
use_tls=True,
tls_context=ssl_ctx,
) as smtp:
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(msg)
else:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
tls_context=ssl_ctx,
) as smtp:
await smtp.starttls(tls_context=ssl_ctx)
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(msg)

View File

@@ -1,4 +1,5 @@
from .auth.routes import router as auth_router
from .invites.routes import router as invites_router
from .licenses.routes import router as licenses_router
from .permissions.routes import router as permissions_router
from .tenants.routes import router as tenants_router
@@ -12,6 +13,7 @@ from fastapi import APIRouter
router = APIRouter()
router.include_router(auth_router)
router.include_router(invites_router, prefix="/core", tags=["core / invites"])
router.include_router(tenants_router, prefix="/core", tags=["core / tenants"])
router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"])
router.include_router(users_router, prefix="/core", tags=["core / users"])