"""Users & Organization — team management within an org, role-scoped.""" import secrets from typing import Optional from fastapi import APIRouter, Body, Depends, HTTPException, status from app.core.config import settings from app.core.deps import require_org, require_client_roles, Principal from app.core.security import create_token from app.models import User, OrgMember, Plan, AuditLog from app.models.documents import now from app.models.enums import ClientRole from app.services.email import send_invite_email import csv from io import StringIO from fastapi.responses import StreamingResponse from app.core.security import hash_password router = APIRouter(prefix="/users", tags=["users"]) async def _member_out(m: OrgMember) -> dict: user = await User.get(m.user_id) if m.user_id else None return { "member_id": str(m.id), "user_id": m.user_id, "name": user.name if user else (m.invited_email or "Invited user"), "email": user.email if user else m.invited_email, "avatar_url": user.avatar_url if user else None, "role": m.role, "department": m.department, "status": m.status, "last_login": user.last_login if user else None, "joined_at": m.joined_at or m.created_at, } from collections import Counter @router.get("") async def list_users(p: Principal = Depends(require_org)): members = await OrgMember.find(OrgMember.org_id == str(p.org.id)).to_list() rows = [await _member_out(m) for m in members] role_summary = Counter() for r in rows: role_summary[r["role"]] += 1 plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None return { "items": rows, "role_summary": dict(role_summary), "kpis": { "total": len(rows), "active": sum(1 for r in rows if r["status"] == "active"), "pending": sum(1 for r in rows if r["status"] == "pending"), "disabled": sum(1 for r in rows if r["status"] == "disabled"), "user_limit": plan.user_limit if plan else None, }, } @router.get("/export") async def export_users( p: Principal = Depends(require_client_roles(ClientRole.admin)) ): members = await OrgMember.find( OrgMember.org_id == str(p.org.id) ).to_list() rows = [await _member_out(m) for m in members] output = StringIO() writer = csv.writer(output) writer.writerow([ "Name", "Email", "Role", "Department", "Status", "Last Login", "Joined On", ]) for r in rows: writer.writerow([ r["name"], r["email"], r["role"], r["department"] or "", r["status"], r["last_login"] or "", r["joined_at"] or "", ]) output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={ "Content-Disposition": "attachment; filename=users.csv" }, ) @router.post("/invite", status_code=201) async def invite_user( body: dict = Body(...), p: Principal = Depends(require_client_roles(ClientRole.admin)) ): email = body["email"].strip().lower() if await User.find_one(User.email == email): raise HTTPException( status.HTTP_409_CONFLICT, "Email already exists", ) user = User( name=body["name"], email=email, hashed_password=hash_password(body["password"]), email_verified=True, ) await user.insert() member = OrgMember( org_id=str(p.org.id), user_id=str(user.id), invited_email=email, role=ClientRole(body["role"]), department=body.get("department"), status="active", joined_at=now(), ) await member.insert() await AuditLog( org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name, action="user.create", module="users", details=f"Created user {email}", ).insert() return await _member_out(member) @router.get("/{member_id}/activity") async def user_activity(member_id: str, p: Principal = Depends(require_org)): logs = await ( AuditLog.find( AuditLog.org_id == str(p.org.id), AuditLog.target_member_id == member_id, ) .sort(-AuditLog.created_at) .to_list() ) return { "items": [ { "id": str(log.id), "action": log.action, "module": log.module, "details": log.details, "created_at": log.created_at, } for log in logs ] } @router.put("/{member_id}") async def update_member( member_id: str, body: dict = Body(...), p: Principal = Depends(require_client_roles(ClientRole.admin)) ): # Find member m = await OrgMember.get(member_id) if not m or m.org_id != str(p.org.id): raise HTTPException( status.HTTP_404_NOT_FOUND, "Member not found" ) # Find linked user user = await User.get(m.user_id) if user: # Update name if body.get("name"): user.name = body["name"] # Update email (optional) if body.get("email"): user.email = body["email"].strip().lower() # Update password (optional) if body.get("password"): user.hashed_password = hash_password(body["password"]) await user.save() # Update OrgMember fields if "role" in body: m.role = ClientRole(body["role"]) if "department" in body: m.department = body["department"] if ( "status" in body and body["status"] in ("active", "disabled", "pending") ): m.status = body["status"] await m.save() # Audit log await AuditLog( org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name, action="user.update", module="users", details=f"Updated member {member_id}", ).insert() return await _member_out(m) @router.delete("/{member_id}", status_code=204) async def remove_member(member_id: str, p: Principal = Depends(require_client_roles(ClientRole.admin))): m = await OrgMember.get(member_id) if not m or m.org_id != str(p.org.id): raise HTTPException(status.HTTP_404_NOT_FOUND, "Member not found") if m.user_id == str(p.user.id): raise HTTPException(status.HTTP_400_BAD_REQUEST, "You cannot remove yourself") await m.delete() p.org.user_count = max(1, p.org.user_count - 1) await p.org.save()