diff --git a/backend/alembic/versions/b2c3d4e5f6a7_add_invite_tokens.py b/backend/alembic/versions/b2c3d4e5f6a7_add_invite_tokens.py new file mode 100644 index 00000000..a31f128f --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_add_invite_tokens.py @@ -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") diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index ebe50f91..519bdd46 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -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 = { diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 36121f7e..65d35fa8 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -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, diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 8b090267..c8c013b2 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -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() + diff --git a/backend/api/v1/modules/core/invites/__init__.py b/backend/api/v1/modules/core/invites/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/core/invites/dto.py b/backend/api/v1/modules/core/invites/dto.py new file mode 100644 index 00000000..0a9a8f53 --- /dev/null +++ b/backend/api/v1/modules/core/invites/dto.py @@ -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 diff --git a/backend/api/v1/modules/core/invites/models.py b/backend/api/v1/modules/core/invites/models.py new file mode 100644 index 00000000..f0ea7623 --- /dev/null +++ b/backend/api/v1/modules/core/invites/models.py @@ -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) diff --git a/backend/api/v1/modules/core/invites/routes.py b/backend/api/v1/modules/core/invites/routes.py new file mode 100644 index 00000000..15912707 --- /dev/null +++ b/backend/api/v1/modules/core/invites/routes.py @@ -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, + ) diff --git a/backend/api/v1/modules/core/invites/service.py b/backend/api/v1/modules/core/invites/service.py new file mode 100644 index 00000000..4170a31a --- /dev/null +++ b/backend/api/v1/modules/core/invites/service.py @@ -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""" + +
+ + + + """ + 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) diff --git a/backend/api/v1/modules/core/router.py b/backend/api/v1/modules/core/router.py index 8e3361a6..d122e312 100644 --- a/backend/api/v1/modules/core/router.py +++ b/backend/api/v1/modules/core/router.py @@ -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"]) diff --git a/backend/core/config.py b/backend/core/config.py index 8d84749d..68755842 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index c433cd5e..7b232c1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,7 @@ services: - HUB_URL=${HUB_URL:-http://host.docker.internal:8001} - HUB_ADMIN_EMAIL=${HUB_ADMIN_EMAIL:-} - HUB_ADMIN_PASSWORD=${HUB_ADMIN_PASSWORD:-} + - APP_PUBLIC_URL=${APP_PUBLIC_URL:-http://localhost:3001} - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} diff --git a/frontend/src/lib/api/dashboard/users.ts b/frontend/src/lib/api/dashboard/users.ts index 755db998..5093e2e8 100644 --- a/frontend/src/lib/api/dashboard/users.ts +++ b/frontend/src/lib/api/dashboard/users.ts @@ -58,6 +58,21 @@ export interface ChangePasswordRequest { temporary?: boolean; } +export interface InviteUserRequest { + email: string; + company_id: number; + role_id: number; +} + +export interface InviteUserResponse { + id: number; + email: string; + role: string; + expires_at: string; + invite_url: string; + created_at: string; +} + export const usersAPI = { /** * Obtiene estadísticas de usuarios del tenant @@ -166,5 +181,16 @@ export const usersAPI = { if (response.error) { throw new Error(response.error); } + }, + + /** + * Genera un token de invitación y envía email al usuario + */ + async invite(data: InviteUserRequest): Promise