video-accessibility/backend/app/api/v1/routes_invitations.py
Vadym Samoilenko 31199f8705 chore: push all session changes — backend hardening, tests, apache config, deploy scripts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 15:52:14 +01:00

369 lines
13 KiB
Python

"""
Invitation flow — org admins invite users; users accept via a public token URL.
Public endpoints (no auth):
POST /invitations/preview — inspect token without accepting
POST /invitations/accept — accept an invitation
Protected endpoints:
POST /organizations/:id/invitations — create (org ADMIN+)
GET /organizations/:id/invitations — list (org ADMIN+)
DELETE /organizations/:id/invitations/:inv_id — revoke (org ADMIN+)
"""
import hashlib
import re
import secrets
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from motor.motor_asyncio import AsyncIOMotorDatabase
from ...core.authz import bump_user_membership_cache
from ...core.database import get_database
from ...core.dependencies import get_current_user
from ...core.security import (
create_access_token,
create_refresh_token,
get_password_hash,
)
from ...models.invitation import (
InvitationAcceptRequest,
InvitationCreate,
InvitationPreviewResponse,
InvitationResponse,
)
from ...models.organization import OrgRole
from ...models.user import AuthProvider, User, UserRole
from ...services.emailer import email_service
from ...services.membership_service import get_membership, upsert_membership
router = APIRouter(tags=["invitations"])
def _now() -> datetime:
return datetime.now(UTC)
def _hash_token(plaintext: str) -> str:
return hashlib.sha256(plaintext.encode()).hexdigest()
def _make_token() -> tuple[str, str]:
"""Return (plaintext, sha256_hash) for a new invitation token."""
plaintext = secrets.token_urlsafe(32)
return plaintext, _hash_token(plaintext)
def _inv_from_doc(doc: dict) -> InvitationResponse:
now = _now()
expires_at = doc["expires_at"].replace(tzinfo=UTC) if doc["expires_at"].tzinfo is None else doc["expires_at"]
return InvitationResponse(
id=str(doc["_id"]),
email=doc["email"],
organization_id=doc["organization_id"],
role_in_org=OrgRole(doc["role_in_org"]),
invited_by_user_id=doc["invited_by_user_id"],
expires_at=expires_at,
accepted_at=doc.get("accepted_at"),
revoked_at=doc.get("revoked_at"),
created_at=doc.get("created_at"),
is_expired=expires_at < now and doc.get("accepted_at") is None,
is_accepted=doc.get("accepted_at") is not None,
is_revoked=doc.get("revoked_at") is not None,
)
async def _get_org_name(org_id: str, db: AsyncIOMotorDatabase) -> tuple[str, str]:
"""Return (name, slug) for an org, checking both collections."""
doc = await db.organizations.find_one({"_id": org_id})
if not doc:
doc = await db.clients.find_one({"_id": org_id})
if not doc:
return (org_id, org_id)
return doc["name"], doc["slug"]
async def _assert_org_admin(org_id: str, user: User, db: AsyncIOMotorDatabase) -> None:
if user.role == UserRole.ADMIN:
return
membership = await get_membership(user.id, org_id, db)
if not membership or membership.role_in_org < OrgRole.ADMIN:
raise HTTPException(status_code=403, detail="Org ADMIN role required")
# ---------------------------------------------------------------------------
# Org-scoped invitation endpoints (mounted on the organizations router)
# ---------------------------------------------------------------------------
org_router = APIRouter(prefix="/organizations", tags=["invitations"])
@org_router.post("/{org_id}/invitations", response_model=InvitationResponse, status_code=201)
async def create_invitation(
org_id: str,
body: InvitationCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _assert_org_admin(org_id, current_user, db)
email_lower = body.email.lower()
# Check for existing pending invitation to the same email+org
existing = await db.invitations.find_one({
"email": email_lower,
"organization_id": org_id,
"accepted_at": None,
"revoked_at": None,
"expires_at": {"$gt": _now()},
})
if existing:
raise HTTPException(
status_code=409,
detail="A pending invitation already exists for this email. Revoke it first to re-invite.",
)
# MT-19: ensure all target_team_ids belong to this org (client_id == org_id)
if body.target_team_ids:
valid_teams = await db.teams.count_documents({
"_id": {"$in": body.target_team_ids},
"client_id": org_id,
})
if valid_teams != len(body.target_team_ids):
raise HTTPException(
status_code=400,
detail="One or more target_team_ids do not belong to this organization.",
)
plaintext, token_hash = _make_token()
now = _now()
expires_at = now + timedelta(days=body.expires_in_days)
from bson import ObjectId
doc = {
"_id": str(ObjectId()),
"email": email_lower,
"organization_id": org_id,
"role_in_org": body.role_in_org.value,
"target_team_ids": body.target_team_ids,
"token_hash": token_hash,
"invited_by_user_id": current_user.id,
"expires_at": expires_at,
"accepted_at": None,
"revoked_at": None,
"created_at": now,
}
await db.invitations.insert_one(doc)
org_name, org_slug = await _get_org_name(org_id, db)
from ...core.config import settings
base_url = settings.cors_origins_list[0].rstrip("/") if settings.cors_origins_list else "https://app.example.com"
accept_url = f"{base_url}/video-accessibility/accept-invite?token={plaintext}"
await email_service.send_invitation_email(
to_email=email_lower,
inviter_name=current_user.full_name,
org_name=org_name,
accept_url=accept_url,
expires_at=expires_at,
)
return _inv_from_doc(doc)
@org_router.get("/{org_id}/invitations", response_model=list[InvitationResponse])
async def list_invitations(
org_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _assert_org_admin(org_id, current_user, db)
docs = []
async for doc in db.invitations.find({"organization_id": org_id}).sort("created_at", -1):
docs.append(_inv_from_doc(doc))
return docs
@org_router.delete("/{org_id}/invitations/{invitation_id}", status_code=204)
async def revoke_invitation(
org_id: str,
invitation_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _assert_org_admin(org_id, current_user, db)
result = await db.invitations.update_one(
{"_id": invitation_id, "organization_id": org_id, "accepted_at": None, "revoked_at": None},
{"$set": {"revoked_at": _now()}},
)
if result.matched_count == 0:
raise HTTPException(status_code=404, detail="Invitation not found or already accepted/revoked")
# ---------------------------------------------------------------------------
# Public endpoints (no auth required)
# ---------------------------------------------------------------------------
@router.post("/invitations/preview", response_model=InvitationPreviewResponse)
async def preview_invitation(
body: dict,
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Inspect an invitation token without consuming it."""
token = body.get("token", "")
if not token:
raise HTTPException(status_code=400, detail="token is required")
token_hash = _hash_token(token)
doc = await db.invitations.find_one({"token_hash": token_hash})
if not doc:
raise HTTPException(status_code=410, detail="Invitation not found or has expired")
now = _now()
expires_at = doc["expires_at"].replace(tzinfo=UTC) if doc["expires_at"].tzinfo is None else doc["expires_at"]
if doc.get("revoked_at"):
raise HTTPException(status_code=410, detail="This invitation has been revoked")
if doc.get("accepted_at"):
raise HTTPException(status_code=409, detail="This invitation has already been accepted")
if expires_at < now:
raise HTTPException(status_code=410, detail="This invitation has expired. Ask your admin to re-send.")
org_name, org_slug = await _get_org_name(doc["organization_id"], db)
# Find inviter name
inviter = await db.users.find_one({"_id": doc["invited_by_user_id"]})
inviter_name = inviter["full_name"] if inviter else "Your admin"
# Check if user already exists
email_lower = doc["email"]
existing_user = await db.users.find_one(
{"email": {"$regex": f"^{re.escape(email_lower)}$", "$options": "i"}}
)
# Check if already a member
is_existing_member = False
if existing_user:
mem = await get_membership(str(existing_user["_id"]), doc["organization_id"], db)
is_existing_member = mem is not None
requires_password_setup = existing_user is None
requires_microsoft_signin = (
existing_user is not None
and existing_user.get("auth_provider") == "microsoft"
and existing_user.get("hashed_password") is None
)
return InvitationPreviewResponse(
org_name=org_name,
org_slug=org_slug,
inviter_name=inviter_name,
role_in_org=OrgRole(doc["role_in_org"]),
target_email=email_lower,
requires_password_setup=requires_password_setup,
requires_microsoft_signin=requires_microsoft_signin,
is_existing_member=is_existing_member,
)
@router.post("/invitations/accept")
async def accept_invitation(
body: InvitationAcceptRequest,
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Accept an invitation. Creates user if needed, creates membership, returns tokens."""
token_hash = _hash_token(body.token)
now = _now()
# Atomically mark the invitation as accepted (single-use)
doc = await db.invitations.find_one_and_update(
{
"token_hash": token_hash,
"accepted_at": None,
"revoked_at": None,
"expires_at": {"$gt": now},
},
{"$set": {"accepted_at": now}},
return_document=True,
)
if not doc:
raise HTTPException(
status_code=410,
detail="Invitation not found, already accepted, revoked, or expired",
)
email_lower = doc["email"]
org_id = doc["organization_id"]
role_in_org = OrgRole(doc["role_in_org"])
# Find or create user
existing_user = await db.users.find_one(
{"email": {"$regex": f"^{re.escape(email_lower)}$", "$options": "i"}}
)
if existing_user:
user_id = str(existing_user["_id"])
else:
# New user: require full_name + password
if not body.full_name or not body.password:
raise HTTPException(
status_code=422,
detail="full_name and password are required for new accounts",
)
from bson import ObjectId
user_id = str(ObjectId())
new_user = {
"_id": user_id,
"email": email_lower,
"full_name": body.full_name,
"hashed_password": get_password_hash(body.password),
"role": UserRole.CLIENT.value,
"auth_provider": AuthProvider.LOCAL.value,
"is_active": True,
"pm_client_ids": [],
"created_at": now,
"updated_at": now,
}
await db.users.insert_one(new_user)
existing_user = new_user
# Create or upgrade membership
await upsert_membership(user_id, org_id, role_in_org, doc["invited_by_user_id"], db)
await bump_user_membership_cache(user_id)
# Auto-add to target teams — write to both Team.member_user_ids (legacy) and Membership.team_ids (MT-17)
for team_id in doc.get("target_team_ids", []):
await db.teams.update_one(
{"_id": team_id, "client_id": org_id},
{"$addToSet": {"member_user_ids": user_id}},
)
await db.memberships.update_one(
{"user_id": user_id, "organization_id": org_id},
{"$addToSet": {"team_ids": team_id}},
)
# Send welcome email
if not existing_user.get("_welcomed"):
org_name, org_slug = await _get_org_name(org_id, db)
await email_service.send_welcome_email(
to_email=email_lower,
full_name=existing_user.get("full_name", ""),
org_name=org_name,
)
# Issue JWT tokens with org_ids claim
_inv_org_ids = [m["organization_id"] async for m in db.memberships.find({"user_id": user_id}, {"organization_id": 1})]
access_token = create_access_token(subject=user_id, org_ids=[str(o) for o in _inv_org_ids if o])
refresh_token = create_refresh_token(subject=user_id)
org_name, org_slug = await _get_org_name(org_id, db)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"user_id": user_id,
"email": email_lower,
"full_name": existing_user.get("full_name", ""),
"redirect_to": f"/org/{org_slug}/dashboard",
}