"""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 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() 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: dict, 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.get("email", "") name = body.get("name", "") if not email or not name: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Email and name required", "code": "missing_fields"}, ) 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"}