"""Invitation endpoints — admin issues, invitee previews + accepts.""" from __future__ import annotations import logging import uuid from flask import Blueprint, g, jsonify, make_response, request from pydantic import BaseModel, Field, ValidationError from app.api._validation import Email from app.core.auth_decorators import require_auth, require_perm from app.core.rate_limit import limiter from app.services import invitations as inv_svc bp = Blueprint("invitations", __name__, url_prefix="/invitations") log = logging.getLogger("metamorph.api.invitations") class CreateInvitationPayload(BaseModel): email_hint: Email | None = None group_ids: list[uuid.UUID] = Field(default_factory=list) ttl_days: int | None = Field(default=None, ge=1, le=30) class AcceptInvitationPayload(BaseModel): email: Email password: str = Field(min_length=8) display_name: str | None = None @bp.post("") @require_auth @require_perm("invitation.create") def create(): try: payload = CreateInvitationPayload.model_validate(request.get_json(silent=True) or {}) except ValidationError as e: return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400 from datetime import timedelta ttl = ( timedelta(days=payload.ttl_days) if payload.ttl_days is not None else inv_svc.INVITATION_TTL ) result = inv_svc.create_invitation( created_by_user_id=g.current_user.id, email_hint=payload.email_hint, group_ids=payload.group_ids, ttl=ttl, ) log.info( "metamorph.invitation.created", extra={ "invitation_id": str(result.invitation_id), "by_user_id": str(g.current_user.id), "expires_at": result.expires_at.isoformat(), }, ) return make_response( jsonify( { "id": str(result.invitation_id), "token": result.raw_token, # shown ONCE "expires_at": result.expires_at.isoformat(), } ), 201, ) @bp.get("") @require_auth @require_perm("invitation.read") def list_active(): rows = inv_svc.list_active() return jsonify( [ { "id": str(r.id), "email_hint": r.email_hint, "expires_at": r.expires_at.isoformat(), "groups": [g.name for g in r.pre_assigned_groups], } for r in rows ] ) @bp.post("//revoke") @require_auth @require_perm("invitation.revoke") def revoke(invitation_id: str): try: iid = uuid.UUID(invitation_id) except ValueError: return jsonify({"error": "invalid_id"}), 400 ok = inv_svc.revoke(iid) if not ok: return jsonify({"error": "not_revocable"}), 404 return jsonify({"ok": True}) @bp.get("/preview/") @limiter.limit("20 per minute") def preview(token: str): p = inv_svc.preview(token) return jsonify( { "is_valid": p.is_valid, "reason": p.reason, "email_hint": p.email_hint, "expires_at": p.expires_at.isoformat(), "groups": p.groups, } ) @bp.post("/accept/") @limiter.limit("10 per minute") def accept(token: str): try: payload = AcceptInvitationPayload.model_validate(request.get_json(silent=True) or {}) except ValidationError as e: return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400 try: user_id = inv_svc.accept( token, email=payload.email, password=payload.password, display_name=payload.display_name, ) except inv_svc.InvitationExpired: return jsonify({"error": "invitation_expired"}), 410 except inv_svc.InvitationConsumed: return jsonify({"error": "invitation_consumed"}), 410 except inv_svc.InvitationRevoked: return jsonify({"error": "invitation_revoked"}), 410 except inv_svc.InvitationError as e: return jsonify({"error": "invitation_invalid", "message": str(e)}), 400 except ValueError as e: return jsonify({"error": "invalid_request", "message": str(e)}), 400 return make_response(jsonify({"ok": True, "user_id": str(user_id)}), 201)