"""User management service for superadmin operations.""" import logging from datetime import datetime, timezone from gatehouse_app.models.user.user import User from gatehouse_app.models.user.session import Session from gatehouse_app.models.organization.organization import Organization from gatehouse_app.models.organization.organization_member import OrganizationMember from gatehouse_app.extensions import db logger = logging.getLogger(__name__) class SuperadminUserService: """Service for managing users across the platform.""" @staticmethod def list_users( page: int = 1, per_page: int = 20, org_id: str = None, status: str = None, search: str = None, ) -> dict: """List users with filters and pagination. Args: page: Page number per_page: Items per page org_id: Filter by organization status: Filter by status (active/suspended) search: Search by email or name Returns: Paginated user list with metadata """ query = User.query.filter(User.deleted_at.is_(None)) # Filter by organization if org_id: member_user_ids = db.session.query(OrganizationMember.user_id).filter( OrganizationMember.organization_id == org_id, OrganizationMember.deleted_at.is_(None), ).all() user_ids = [m.user_id for m in member_user_ids] query = query.filter(User.id.in_(user_ids)) # Filter by status if status == "suspended": query = query.filter(User.status == "GLOBAL_SUSPENDED") elif status == "active": query = query.filter(User.status != "GLOBAL_SUSPENDED") # Search if search: search_filter = f"%{search}%" query = query.filter( db.or_( User.email.ilike(search_filter), User.full_name.ilike(search_filter), ) ) query = query.order_by(User.created_at.desc()) total = query.count() users = query.offset((page - 1) * per_page).limit(per_page).all() items = [] for user in users: # Get org memberships memberships = OrganizationMember.query.filter( OrganizationMember.user_id == user.id, OrganizationMember.deleted_at.is_(None), ).all() orgs = [] for m in memberships: org = Organization.query.get(m.organization_id) if org: orgs.append({ "org_id": org.id, "org_name": org.name, "role": m.role, }) # Get active sessions count active_sessions = Session.query.filter( Session.user_id == user.id, Session.deleted_at.is_(None), Session.status == "active", ).count() items.append({ "id": user.id, "email": user.email, "full_name": user.full_name, "status": user.status, "org_count": len(orgs), "orgs": orgs, "active_sessions": active_sessions, "last_login_at": user.last_login_at.isoformat() + "Z" if user.last_login_at else None, "created_at": user.created_at.isoformat() + "Z" if user.created_at else None, }) return { "items": items, "total": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page if per_page > 0 else 0, } @staticmethod def get_user_detail(user_id: str) -> dict: """Get detailed user information. Args: user_id: User UUID Returns: User detail with orgs, sessions, security methods """ user = User.query.get(user_id) if not user or user.deleted_at is not None: raise ValueError("User not found") # Get org memberships memberships = OrganizationMember.query.filter( OrganizationMember.user_id == user_id, OrganizationMember.deleted_at.is_(None), ).all() orgs = [] for m in memberships: org = Organization.query.get(m.organization_id) if org: orgs.append({ "org_id": org.id, "org_name": org.name, "org_slug": org.slug, "role": m.role, "joined_at": m.created_at.isoformat() + "Z" if m.created_at else None, }) # Get active sessions sessions = Session.query.filter( Session.user_id == user_id, Session.deleted_at.is_(None), Session.status == "active", ).all() active_sessions = [{ "id": s.id, "ip_address": s.ip_address, "user_agent": s.user_agent, "created_at": s.created_at.isoformat() + "Z" if s.created_at else None, } for s in sessions] # Security methods security_methods = [] if hasattr(user, 'totp_enabled') and user.totp_enabled: security_methods.append({"type": "totp", "enabled": True}) if hasattr(user, 'webauthn_enabled') and user.webauthn_enabled: security_methods.append({"type": "webauthn", "enabled": True}) return { "user": { "id": user.id, "email": user.email, "full_name": user.full_name, "status": user.status, "mfa_enabled": user.mfa_enabled if hasattr(user, 'mfa_enabled') else False, "last_login_at": user.last_login_at.isoformat() + "Z" if user.last_login_at else None, "created_at": user.created_at.isoformat() + "Z" if user.created_at else None, }, "organizations": orgs, "active_sessions": active_sessions, "security_methods": security_methods, } @staticmethod def suspend_user(user_id: str) -> dict: """Globally suspend a user. Args: user_id: User UUID Returns: Updated user info and count of revoked sessions """ user = User.query.get(user_id) if not user or user.deleted_at is not None: raise ValueError("User not found") if user.status == "GLOBAL_SUSPENDED": raise ValueError("User is already suspended") user.status = "GLOBAL_SUSPENDED" db.session.commit() # Revoke all sessions revoked_count = Session.query.filter( Session.user_id == user_id, Session.deleted_at.is_(None), ).update({"status": "revoked", "deleted_at": db.func.now()}) db.session.commit() return { "user": { "id": user.id, "email": user.email, "status": user.status, }, "sessions_revoked": revoked_count, } @staticmethod def unsuspend_user(user_id: str) -> dict: """Remove global suspension from a user. Args: user_id: User UUID Returns: Updated user info """ user = User.query.get(user_id) if not user or user.deleted_at is not None: raise ValueError("User not found") if user.status != "GLOBAL_SUSPENDED": raise ValueError("User is not suspended") user.status = "active" db.session.commit() return { "user": { "id": user.id, "email": user.email, "status": user.status, }, } @staticmethod def reset_password(user_id: str) -> dict: """Trigger password reset for user. Args: user_id: User UUID Returns: Email of user """ user = User.query.get(user_id) if not user or user.deleted_at is not None: raise ValueError("User not found") # In production, this would call AuthService.send_password_reset_email logger.info(f"[SuperadminUserService] Password reset requested for {user.email}") return {"email": user.email} @staticmethod def revoke_all_sessions(user_id: str) -> dict: """Revoke all sessions for a user. Args: user_id: User UUID Returns: Count of revoked sessions """ user = User.query.get(user_id) if not user or user.deleted_at is not None: raise ValueError("User not found") result = Session.query.filter( Session.user_id == user_id, Session.deleted_at.is_(None), ).update({"status": "revoked", "deleted_at": db.func.now()}) db.session.commit() return { "user_id": user_id, "count": result, } @staticmethod def add_to_org(user_id: str, org_id: str, role: str = "member") -> dict: """Add a user to an organization. Args: user_id: User UUID org_id: Organization UUID role: Membership role Returns: Membership details """ user = User.query.get(user_id) if not user or user.deleted_at is not None: raise ValueError("User not found") org = Organization.query.get(org_id) if not org or org.deleted_at is not None: raise ValueError("Organization not found") # Check if already a member existing = OrganizationMember.query.filter( OrganizationMember.user_id == user_id, OrganizationMember.organization_id == org_id, OrganizationMember.deleted_at.is_(None), ).first() if existing: raise ValueError("User is already a member of this organization") membership = OrganizationMember( user_id=user_id, organization_id=org_id, role=role, ) db.session.add(membership) db.session.commit() return { "user_id": user_id, "organization_id": org_id, "role": role, "joined_at": membership.created_at.isoformat() + "Z" if membership.created_at else None, } @staticmethod def remove_from_org(user_id: str, org_id: str) -> dict: """Remove a user from an organization. Args: user_id: User UUID org_id: Organization UUID Returns: Confirmation """ membership = OrganizationMember.query.filter( OrganizationMember.user_id == user_id, OrganizationMember.organization_id == org_id, OrganizationMember.deleted_at.is_(None), ).first() if not membership: raise ValueError("User is not a member of this organization") # Check if user is the only owner if membership.role == "owner": owner_count = OrganizationMember.query.filter( OrganizationMember.organization_id == org_id, OrganizationMember.role == "owner", OrganizationMember.deleted_at.is_(None), ).count() if owner_count <= 1: raise ValueError("Cannot remove the only owner from an organization. Transfer ownership first.") membership.deleted_at = db.func.now() db.session.commit() return { "user_id": user_id, "organization_id": org_id, }