import asyncio from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, HTTPException, Depends, Query from pydantic import BaseModel from firebase_admin import auth as firebase_auth from app.internal.auth import require_admin_token from app.internal import firestore as fstore from app.internal import audit router = APIRouter(prefix="/admin/users", tags=["users"]) VALID_ROLES = {"admin", "operator", "viewer"} # --------------------------------------------------------------------------- # Pydantic models # --------------------------------------------------------------------------- class UserCreate(BaseModel): email: str role: str = "viewer" display_name: Optional[str] = None owned_node_ids: list[str] = [] class UserUpdate(BaseModel): role: Optional[str] = None owned_node_ids: Optional[list[str]] = None display_name: Optional[str] = None # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _ms_to_iso(ms: Optional[int]) -> Optional[str]: if ms is None: return None return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat() def _extract_role_nodes(fb_user: firebase_auth.UserRecord) -> tuple[str, list[str]]: claims = fb_user.custom_claims or {} if claims.get("role") == "admin" or claims.get("admin"): role = "admin" else: role = claims.get("role", "viewer") if role not in VALID_ROLES: role = "viewer" owned_node_ids = claims.get("owned_node_ids") or [] return role, owned_node_ids def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None) -> dict: role, owned_node_ids = _extract_role_nodes(fb_user) return { "uid": fb_user.uid, "email": fb_user.email, "display_name": fb_user.display_name, "role": role, "owned_node_ids": owned_node_ids, "disabled": fb_user.disabled, "creation_time": _ms_to_iso(fb_user.user_metadata.creation_timestamp), "last_sign_in": _ms_to_iso(fb_user.user_metadata.last_sign_in_timestamp), "discord_linked": bool(link and link.get("discord_user_id")), "discord_username": link.get("discord_username") if link else None, "discord_user_id": link.get("discord_user_id") if link else None, } def _list_fb_users() -> list[firebase_auth.UserRecord]: users: list[firebase_auth.UserRecord] = [] page = firebase_auth.list_users() while page: users.extend(page.users) page = page.get_next_page() return users # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("") async def list_users(decoded: dict = Depends(require_admin_token)): """List all Firebase Auth users with role, node ownership, and Discord link status.""" fb_users = await asyncio.to_thread(_list_fb_users) links: list[Optional[dict]] = await asyncio.gather(*[ fstore.doc_get("firebase_discord_links", u.uid) for u in fb_users ]) return [_format_user(u, lnk) for u, lnk in zip(fb_users, links)] @router.post("") async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_token)): """Create a new Firebase Auth user and set their role. Returns a one-time invite link.""" if body.role not in VALID_ROLES: raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}") if body.role == "operator" and not body.owned_node_ids: raise HTTPException(400, "Operator role requires at least one owned node.") try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread( firebase_auth.create_user, email=body.email, display_name=body.display_name or "", email_verified=False, ) except firebase_auth.EmailAlreadyExistsError: raise HTTPException(409, "A user with this email already exists.") except Exception as e: raise HTTPException(400, f"Failed to create user: {e}") # Set custom claims claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids} if body.role == "admin": claims["admin"] = True await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims) # Write Firestore profile now = datetime.now(timezone.utc).isoformat() await fstore.doc_set("user_profiles", fb_user.uid, { "uid": fb_user.uid, "email": body.email, "display_name": body.display_name or "", "role": body.role, "owned_node_ids": body.owned_node_ids, "created_by_uid": decoded["uid"], "created_at": now, }, merge=False) # Generate a one-time invite/password-reset link invite_link: Optional[str] = None try: invite_link = await asyncio.to_thread(firebase_auth.generate_password_reset_link, body.email) except Exception: pass await audit.write_audit( actor_uid=decoded["uid"], actor_email=decoded.get("email", ""), action="user.create", target_uid=fb_user.uid, target_email=body.email, details={"role": body.role, "owned_node_ids": body.owned_node_ids}, ) return {**_format_user(fb_user), "invite_link": invite_link} @router.get("/{uid}") async def get_user(uid: str, decoded: dict = Depends(require_admin_token)): """Get a single user with full detail, including recent sessions.""" try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) except firebase_auth.UserNotFoundError: raise HTTPException(404, "User not found.") link, raw_sessions = await asyncio.gather( fstore.doc_get("firebase_discord_links", uid), fstore.collection_where("user_sessions", [("uid", "==", uid)]), ) raw_sessions.sort(key=lambda s: s.get("timestamp", ""), reverse=True) return { **_format_user(fb_user, link), "sessions": raw_sessions[:20], } @router.patch("/{uid}") async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(require_admin_token)): """Update a user's role, owned nodes, or display name.""" try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) except firebase_auth.UserNotFoundError: raise HTTPException(404, "User not found.") current_role, current_nodes = _extract_role_nodes(fb_user) new_role = body.role if body.role is not None else current_role new_nodes = body.owned_node_ids if body.owned_node_ids is not None else current_nodes if new_role not in VALID_ROLES: raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}") if new_role == "operator" and not new_nodes: raise HTTPException(400, "Operator role requires at least one owned node.") # Merge with existing claims (preserve any other claims already set) existing_claims: dict = dict(fb_user.custom_claims or {}) new_claims = {**existing_claims, "role": new_role, "owned_node_ids": new_nodes} if new_role == "admin": new_claims["admin"] = True else: new_claims.pop("admin", None) await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims) if body.display_name is not None: await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name) profile_data: dict = {"uid": uid, "role": new_role, "owned_node_ids": new_nodes} if body.display_name is not None: profile_data["display_name"] = body.display_name await fstore.doc_set("user_profiles", uid, profile_data, merge=True) await audit.write_audit( actor_uid=decoded["uid"], actor_email=decoded.get("email", ""), action="user.update", target_uid=uid, target_email=fb_user.email, details={ "old_role": current_role, "new_role": new_role, "old_nodes": current_nodes, "new_nodes": new_nodes, }, ) updated: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) link = await fstore.doc_get("firebase_discord_links", uid) return _format_user(updated, link) @router.post("/{uid}/disable") async def disable_user(uid: str, decoded: dict = Depends(require_admin_token)): """Disable a user — they can no longer sign in but their data is preserved.""" if uid == decoded.get("uid"): raise HTTPException(400, "Cannot disable your own account.") try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) except firebase_auth.UserNotFoundError: raise HTTPException(404, "User not found.") await asyncio.to_thread(firebase_auth.update_user, uid, disabled=True) await audit.write_audit( actor_uid=decoded["uid"], actor_email=decoded.get("email", ""), action="user.disable", target_uid=uid, target_email=fb_user.email, ) return {"ok": True} @router.post("/{uid}/enable") async def enable_user(uid: str, decoded: dict = Depends(require_admin_token)): """Re-enable a previously disabled user.""" try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) except firebase_auth.UserNotFoundError: raise HTTPException(404, "User not found.") await asyncio.to_thread(firebase_auth.update_user, uid, disabled=False) await audit.write_audit( actor_uid=decoded["uid"], actor_email=decoded.get("email", ""), action="user.enable", target_uid=uid, target_email=fb_user.email, ) return {"ok": True} @router.delete("/{uid}") async def delete_user(uid: str, decoded: dict = Depends(require_admin_token)): """Permanently delete a user from Firebase Auth and clean up Firestore data.""" if uid == decoded.get("uid"): raise HTTPException(400, "Cannot delete your own account.") try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) except firebase_auth.UserNotFoundError: raise HTTPException(404, "User not found.") email = fb_user.email # Clean up Discord link if present link = await fstore.doc_get("firebase_discord_links", uid) if link and link.get("discord_user_id"): await asyncio.gather( fstore.doc_delete("discord_links", link["discord_user_id"]), fstore.doc_delete("firebase_discord_links", uid), ) # Delete Firestore profile (sessions are kept for audit history) await fstore.doc_delete("user_profiles", uid) # Delete from Firebase Auth await asyncio.to_thread(firebase_auth.delete_user, uid) await audit.write_audit( actor_uid=decoded["uid"], actor_email=decoded.get("email", ""), action="user.delete", target_uid=uid, target_email=email, ) return {"ok": True}