"""Guest management routes — invite, list, delete guests (admin only). ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest Guests are now regular users with role='guest' in user_tenants. They authenticate via the normal login flow and are managed through the standard user system. This router provides admin endpoints for inviting and managing guest users. """ from __future__ import annotations import hashlib import secrets import uuid from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, EmailStr, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.core.auth import get_redis, hash_password from app.core.db import get_db from app.deps import require_admin from app.models.user import User, UserTenant router = APIRouter(prefix="/api/v1/guests", tags=["guests"]) settings = get_settings() class GuestInviteRequest(BaseModel): """Invite a guest user.""" email: EmailStr name: str = Field(..., min_length=1, max_length=200) def _hash_token(token: str) -> str: """Hash a token using SHA-256.""" return hashlib.sha256(token.encode()).hexdigest() @router.post("/invite") async def invite_guest( request: Request, body: GuestInviteRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Invite a guest user. Admin only. Creates a regular user with role='guest'. ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest """ email = body.email name = body.name tenant_id = uuid.UUID(current_user["tenant_id"]) # Check if user already exists by email user_q = await db.execute( select(User).where(User.email == email) ) existing_user = user_q.scalar_one_or_none() if existing_user: # Check if already a member of this tenant ut_q = await db.execute( select(UserTenant).where( UserTenant.user_id == existing_user.id, UserTenant.tenant_id == tenant_id, ) ) existing_ut = ut_q.scalar_one_or_none() if existing_ut: if existing_ut.status == "active" and existing_ut.role == "guest": raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"detail": "Guest already active", "code": "guest_exists"}, ) # Re-invite: update existing membership existing_ut.role = "guest" existing_ut.status = "invited" await db.flush() else: # Create new tenant membership with guest role ut = UserTenant( user_id=existing_user.id, tenant_id=tenant_id, is_default=False, role="guest", status="invited", ) db.add(ut) await db.flush() else: # Create new user with a random password (will be set on acceptance) raw_token = secrets.token_urlsafe(32) new_user = User( email=email, name=name, password_hash=hash_password(raw_token), # Temporary password is_active=True, ) db.add(new_user) await db.flush() # Create tenant membership with guest role ut = UserTenant( user_id=new_user.id, tenant_id=tenant_id, is_default=False, role="guest", status="invited", ) db.add(ut) await db.commit() await db.refresh(new_user) return { "email": email, "name": name, "status": "invited", "role": "guest", "message": "Guest invited — they can now log in via the normal login flow", } @router.get("") async def list_guests( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """List all guest users for the current tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) # Query user_tenants with role='guest' and join users result = await db.execute( select(UserTenant, User) .join(User, UserTenant.user_id == User.id) .where(UserTenant.tenant_id == tenant_id) .where(UserTenant.role == "guest") .order_by(UserTenant.created_at.desc()) ) rows = result.all() return [ { "id": str(ut.user_id), "email": user.email, "name": user.name, "status": ut.status, "role": "guest", "created_at": ut.created_at.isoformat() if ut.created_at else None, } for ut, user in rows ] @router.delete("/{guest_id}") async def delete_guest( guest_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Revoke a guest user's tenant membership and invalidate sessions.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: gid = uuid.UUID(guest_id) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Invalid guest ID", "code": "invalid_id"}, ) from None # Find the user_tenants entry for this guest ut_q = await db.execute( select(UserTenant) .where(UserTenant.user_id == gid) .where(UserTenant.tenant_id == tenant_id) .where(UserTenant.role == "guest") ) ut = ut_q.scalar_one_or_none() if not ut: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"detail": "Guest not found", "code": "not_found"}, ) # Revoke: set status to disabled ut.status = "disabled" await db.commit() # Invalidate all active sessions for this user redis = get_redis() if redis: try: from app.core.auth import invalidate_all_user_sessions await invalidate_all_user_sessions(redis, gid) except Exception: pass return {"message": "Guest revoked, all sessions invalidated", "status": "disabled"}