""" 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 datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from motor.motor_asyncio import AsyncIOMotorDatabase 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 ( Invitation, InvitationAcceptRequest, InvitationCreate, InvitationPreviewResponse, InvitationResponse, ) from ...models.organization import OrgRole from ...models.user import AuthProvider, User, UserRole from ...core.authz import bump_user_membership_cache 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(timezone.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=timezone.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.", ) 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=timezone.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 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}}, ) # 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 access_token = create_access_token(subject=user_id) 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", }