Merge branch 'development' into feature/transportista-completar-modelo

This commit is contained in:
2026-05-08 08:44:01 -06:00
18 changed files with 1112 additions and 57 deletions

View File

@@ -0,0 +1,78 @@
"""add invite_tokens table
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-05-06 12:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b2c3d4e5f6a7"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"invite_tokens",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
sa.Column("tenant_slug", sa.String(100), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("role", sa.String(50), nullable=False, server_default="user"),
sa.Column("created_by", sa.String(255), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("product_ids", sa.JSON(), nullable=True),
sa.Column("company_id", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
schema="core",
)
op.create_index(
"ix_core_invite_tokens_token_hash",
"invite_tokens",
["token_hash"],
unique=True,
schema="core",
)
op.create_index(
"ix_core_invite_tokens_tenant_slug",
"invite_tokens",
["tenant_slug"],
schema="core",
)
op.add_column(
'invite_tokens',
sa.Column('hub_invite_token', sa.String(length=255), nullable=True),
schema='core',
)
def downgrade() -> None:
op.drop_index(
"ix_core_invite_tokens_tenant_slug",
table_name="invite_tokens",
schema="core",
)
op.drop_index(
"ix_core_invite_tokens_token_hash",
table_name="invite_tokens",
schema="core",
)
op.drop_table("invite_tokens", schema="core")

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)
@@ -135,6 +202,27 @@ async def get_current_user_info(
return await service.get_user_info(credentials.credentials)
@router.post("/lazy-link", status_code=200)
async def lazy_link(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_core_db),
):
"""
Vincula un invite pendiente al usuario autenticado (lazy-link).
Se llama después de un SSO login desde el workspace para crear el UserTenant
si hay un invite_token pendiente para el email del usuario.
"""
service = AuthService(db)
try:
await service._link_pending_invite(
credentials.credentials, # username_or_email = token (fallback)
access_token=credentials.credentials,
)
except Exception:
pass
return {"ok": True}
@router.post("/logout")
async def logout(
logout_data: LogoutRequestDTO,

View File

@@ -49,7 +49,12 @@ class AuthService:
tenants=[TenantInfoDTO(**t) for t in data["tenants"]]
)
# Si devolvió tokens
# Si devolvió tokens — lazy-link: verificar si hay invite pendiente
try:
await self._link_pending_invite(login_data.username)
except Exception as exc:
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
# AUDIT LOG: Login Success
try:
from api.v1.modules.a76.audit_log.services.service import AuditService
@@ -131,21 +136,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
@@ -210,3 +340,122 @@ class AuthService:
except Exception as e:
logger.error(f"SSO exchange error: {str(e)}")
raise HTTPException(status_code=500, detail="SSO exchange error")
async def _link_pending_invite(self, username_or_email: str, access_token: str = None) -> None:
"""
Lazy-link: después de un login exitoso comprueba si existe un invite_token
pendiente para el email del usuario. Si lo hay, crea la fila UserTenant
y consume el token.
Si se provee access_token, extrae hub_user_id y email directamente del JWT
sin necesidad de un lookup extra al Hub.
"""
from datetime import datetime, timezone
from api.v1.modules.core.invites.models import InviteToken
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
hub_user_id = None
user_email = username_or_email
# Si tenemos el access_token, extraer info del JWT directamente
if access_token:
try:
from core.security import verify_token
claims = await verify_token(access_token)
hub_user_id = claims.get("sub")
user_email = claims.get("email") or username_or_email
except Exception as exc:
logger.debug("_link_pending_invite: JWT decode failed: %s", exc)
# Sin access_token: buscar usuario en el Hub vía service account
if not hub_user_id:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
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:
return
svc_token = login_resp.json().get("access_token", "")
search_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins",
params={"email": username_or_email},
headers={"Authorization": f"Bearer {svc_token}"},
)
if search_resp.status_code == 200:
items = search_resp.json()
items = items if isinstance(items, list) else items.get("items", [])
matches = [
u for u in items
if u.get("email", "").lower() == username_or_email.lower()
or u.get("username", "").lower() == username_or_email.lower()
]
if matches:
hub_user_id = matches[0].get("id")
user_email = matches[0].get("email", username_or_email)
if not hub_user_id:
return
except Exception as exc:
logger.debug("_link_pending_invite: hub lookup failed: %s", exc)
return
now = datetime.now(timezone.utc)
pending = (
self.db.query(InviteToken)
.filter(
InviteToken.email == user_email,
InviteToken.used_at.is_(None),
InviteToken.expires_at > now,
)
.first()
)
if not pending:
return
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == pending.tenant_slug)
.first()
)
if not tenant:
logger.warning("_link_pending_invite: tenant %s not found", pending.tenant_slug)
return
# Evitar duplicados
existing = (
self.db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == hub_user_id,
UserTenant.tenant_id == tenant.id,
)
.first()
)
if existing:
# Vincular existe, solo consumir el token
pending.used_at = now
self.db.commit()
return
try:
ut = UserTenant(
keycloak_user_id=hub_user_id,
tenant_id=tenant.id,
company_id=pending.company_id,
role=pending.role,
is_active=True,
)
self.db.add(ut)
pending.used_at = now
self.db.commit()
logger.info(
"Lazy-link: UserTenant created for user=%s tenant=%s role=%s",
hub_user_id,
tenant.slug,
pending.role,
)
except Exception as exc:
logger.warning("_link_pending_invite: could not create UserTenant: %s", exc)
self.db.rollback()

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,42 @@
"""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)
# Token generado en el Hub (para la URL de registro del workspace)
hub_invite_token: Mapped[Optional[str]] = mapped_column(String(255), 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,269 @@
"""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()
def _extract_token_from_url(url: str) -> Optional[str]:
"""Extract invite_token query param from a URL string."""
from urllib.parse import urlparse, parse_qs
parsed = urlparse(url)
params = parse_qs(parsed.query)
tokens = params.get("invite_token", [])
return tokens[0] if tokens else None
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:
import httpx
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")
# Crear invite en el Hub para que el usuario use el form del workspace
hub_invite_token: Optional[str] = None
invite_url: str = ""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
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", "")
hub_resp = await client.post(
f"{settings.HUB_URL}api/v1/hub/invites",
json={"email": str(data.email), "tenant_slug": tenant_slug},
headers={"Authorization": f"Bearer {svc_token}"},
)
if hub_resp.status_code in (200, 201):
hub_data = hub_resp.json()
hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", ""))
invite_url = hub_data.get("invite_url", "")
except Exception as exc:
logger.warning("Hub invite creation failed (non-blocking): %s", exc)
# Fallback: URL local si el Hub falló
if not invite_url:
invite_url = (
f"{base_url}/register"
f"?invite_token={token_plain}"
f"&tenant={tenant_slug}"
f"&email={data.email}"
)
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,
hub_invite_token=hub_invite_token,
)
self.db.add(invite)
self.db.commit()
self.db.refresh(invite)
# Enviar email (best-effort)
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"])

View File

@@ -45,6 +45,9 @@ class Settings(BaseSettings):
HUB_ADMIN_EMAIL: str = ""
HUB_ADMIN_PASSWORD: str = ""
# URL pública del frontend — usada en links de email (invitaciones, etc.)
APP_PUBLIC_URL: str = "http://localhost:3000"
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", mode="before")
@classmethod
def strip_quotes(cls, v: str) -> str: