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
|
||||
|
||||
0
backend/api/v1/modules/core/invites/__init__.py
Normal file
0
backend/api/v1/modules/core/invites/__init__.py
Normal file
32
backend/api/v1/modules/core/invites/dto.py
Normal file
32
backend/api/v1/modules/core/invites/dto.py
Normal 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
|
||||
39
backend/api/v1/modules/core/invites/models.py
Normal file
39
backend/api/v1/modules/core/invites/models.py
Normal 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)
|
||||
48
backend/api/v1/modules/core/invites/routes.py
Normal file
48
backend/api/v1/modules/core/invites/routes.py
Normal 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,
|
||||
)
|
||||
233
backend/api/v1/modules/core/invites/service.py
Normal file
233
backend/api/v1/modules/core/invites/service.py
Normal 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)
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user