import secrets import string from datetime import datetime, timezone, timedelta from typing import Annotated, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, or_ from sqlalchemy.orm import selectinload, aliased from app.core.admin_database import get_admin_db from app.core.audit import log_audit from app.core.config import settings from app.core.security import get_password_hash, generate_temp_password, create_password_reset_token, decode_token, hash_token from app.core.email import EmailService from app.models.user import User from app.models.refresh_token import RefreshToken from app.models.password_reset_token import PasswordResetToken from app.models.account import Account from app.models.subscription import Subscription from app.models.session import Session from app.models.audit_log import AuditLog from app.models.invite_code import InviteCode from app.models.account_invite import AccountInvite from app.models.tree import Tree from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate from app.schemas.admin import ( MoveUserAccount, AdminUserCreate, AdminUserCreateResponse, AdminPasswordReset, AdminPasswordResetResponse, HardDeleteCheckResponse, AdminUserListItem, AdminUserListResponse, AdminAccountMember, AdminAccountListItem, AdminAccountListResponse, AdminAccountOwnerSummary, AdminAccountSubscriptionSummary, AdminAccountUsageSummary, AdminAccountDetailResponse, AdminAccountInviteSummary, AdminAccountCreate, AdminAccountUpdate, ) from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest from app.schemas.user_detail import ( UserDetailResponse, AccountSummary, SubscriptionSummary, SessionSummary, AuditLogSummary, InviteCodeUsedSummary, ) from app.api.deps import require_admin from app.core.subscriptions import get_account_usage router = APIRouter(prefix="/admin", tags=["admin"]) @router.get("/users", response_model=AdminUserListResponse) async def list_users( db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], page: Optional[int] = Query(None, ge=1), size: Optional[int] = Query(None, ge=1, le=100), search: Optional[str] = Query(None, description="Search by user or account fields"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100), is_active: Optional[bool] = Query(None, description="Filter by active status"), role: Optional[str] = Query(None, description="Filter by role"), account_id: Optional[UUID] = Query(None, description="Filter by account"), include_archived: bool = Query(False, description="Include archived (soft-deleted) users"), ): """List users for super admin global people search.""" resolved_limit = size or limit resolved_skip = skip current_page = 1 if page is not None: resolved_skip = (page - 1) * resolved_limit current_page = page elif resolved_limit > 0: current_page = (resolved_skip // resolved_limit) + 1 count_query = ( select(func.count()) .select_from(User) .outerjoin(Account, User.account_id == Account.id) ) query = ( select( User, Account.name.label("account_name"), Account.display_code.label("account_display_code"), ) .outerjoin(Account, User.account_id == Account.id) ) if not include_archived: query = query.where(User.deleted_at.is_(None)) count_query = count_query.where(User.deleted_at.is_(None)) if is_active is not None: query = query.where(User.is_active == is_active) count_query = count_query.where(User.is_active == is_active) if role: query = query.where(User.role == role) count_query = count_query.where(User.role == role) if account_id: query = query.where(User.account_id == account_id) count_query = count_query.where(User.account_id == account_id) if search: search_term = f"%{search.strip()}%" search_filter = or_( User.name.ilike(search_term), User.email.ilike(search_term), Account.name.ilike(search_term), Account.display_code.ilike(search_term), ) query = query.where(search_filter) count_query = count_query.where(search_filter) total_result = await db.execute(count_query) total = total_result.scalar() or 0 query = query.order_by(User.created_at.desc()).offset(resolved_skip).limit(resolved_limit) result = await db.execute(query) rows = result.all() items = [ AdminUserListItem( id=user.id, email=user.email, name=user.name, role=user.role, is_super_admin=user.is_super_admin, is_active=user.is_active, account_id=user.account_id, account_role=user.account_role, account_name=account_name, account_display_code=account_display_code, created_at=user.created_at, last_login=user.last_login, deleted_at=user.deleted_at, ) for user, account_name, account_display_code in rows ] return AdminUserListResponse( items=items, total=total, page=current_page, per_page=resolved_limit, ) @router.get("/accounts", response_model=AdminAccountListResponse) async def list_accounts( db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], page: int = Query(1, ge=1), size: int = Query(12, ge=1, le=100), search: Optional[str] = Query(None, description="Search by account, display code, or owner"), plan: Optional[str] = Query(None, description="Filter by subscription plan"), status: Optional[str] = Query(None, description="Filter by subscription status"), include_archived: bool = Query(False, description="Include archived users in account member lists"), ): """List accounts with embedded members for the admin panel.""" owner_user = aliased(User) count_query = ( select(func.count(func.distinct(Account.id))) .select_from(Account) .outerjoin(owner_user, Account.owner_id == owner_user.id) .outerjoin(Subscription, Subscription.account_id == Account.id) ) accounts_query = ( select( Account, owner_user.id.label("owner_user_id"), owner_user.name.label("owner_name"), owner_user.email.label("owner_email"), Subscription.id.label("subscription_id"), Subscription.plan.label("subscription_plan"), Subscription.status.label("subscription_status"), Subscription.billing_interval.label("subscription_billing_interval"), Subscription.current_period_end.label("subscription_current_period_end"), Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"), ) .outerjoin(owner_user, Account.owner_id == owner_user.id) .outerjoin(Subscription, Subscription.account_id == Account.id) ) if search: search_term = f"%{search.strip()}%" search_filter = or_( Account.name.ilike(search_term), Account.display_code.ilike(search_term), owner_user.name.ilike(search_term), owner_user.email.ilike(search_term), ) count_query = count_query.where(search_filter) accounts_query = accounts_query.where(search_filter) if plan: count_query = count_query.where(Subscription.plan == plan) accounts_query = accounts_query.where(Subscription.plan == plan) if status: count_query = count_query.where(Subscription.status == status) accounts_query = accounts_query.where(Subscription.status == status) total_result = await db.execute(count_query) total = total_result.scalar() or 0 accounts_result = await db.execute( accounts_query .order_by(Account.created_at.desc()) .offset((page - 1) * size) .limit(size) ) rows = accounts_result.all() accounts = [row.Account for row in rows] account_ids = [account.id for account in accounts] members_by_account: dict[UUID, list[AdminAccountMember]] = {account_id: [] for account_id in account_ids} pending_invites_by_account: dict[UUID, int] = {account_id: 0 for account_id in account_ids} usage_by_account: dict[UUID, AdminAccountUsageSummary] = {} if account_ids: members_query = select(User).where(User.account_id.in_(account_ids)) if not include_archived: members_query = members_query.where(User.deleted_at.is_(None)) members_query = members_query.order_by(User.created_at.asc()) members_result = await db.execute(members_query) for member in members_result.scalars().all(): members_by_account.setdefault(member.account_id, []).append( AdminAccountMember( id=member.id, email=member.email, name=member.name, role=member.role, is_super_admin=member.is_super_admin, is_active=member.is_active, account_role=member.account_role, created_at=member.created_at, last_login=member.last_login, deleted_at=member.deleted_at, ) ) pending_invites_result = await db.execute( select(AccountInvite.account_id, func.count(AccountInvite.id)) .where( AccountInvite.account_id.in_(account_ids), AccountInvite.used_at.is_(None), ) .group_by(AccountInvite.account_id) ) pending_invites_by_account.update({row[0]: row[1] for row in pending_invites_result.all()}) for account_id in account_ids: usage = await get_account_usage(account_id, db) usage_by_account[account_id] = AdminAccountUsageSummary( tree_count=usage.get("tree_count", 0), session_count_this_month=usage.get("session_count_this_month", 0), ) items = [ AdminAccountListItem( id=row.Account.id, name=row.Account.name, display_code=row.Account.display_code, created_at=row.Account.created_at, owner_id=row.Account.owner_id, owner=( AdminAccountOwnerSummary( id=row.owner_user_id, name=row.owner_name, email=row.owner_email, ) if row.owner_user_id and row.owner_name and row.owner_email else None ), subscription=( AdminAccountSubscriptionSummary( id=row.subscription_id, plan=row.subscription_plan, status=row.subscription_status, billing_interval=row.subscription_billing_interval, current_period_end=row.subscription_current_period_end, cancel_at_period_end=row.subscription_cancel_at_period_end or False, ) if row.subscription_id and row.subscription_plan and row.subscription_status else None ), usage=usage_by_account.get(row.Account.id, AdminAccountUsageSummary()), member_count=len(members_by_account.get(row.Account.id, [])), active_member_count=sum(1 for member in members_by_account.get(row.Account.id, []) if member.is_active), pending_invite_count=pending_invites_by_account.get(row.Account.id, 0), sso_enabled=row.Account.sso_enabled, branding_company_name=row.Account.branding_company_name, members=members_by_account.get(row.Account.id, []), ) for row in rows ] return AdminAccountListResponse( items=items, total=total, page=page, per_page=size, ) def _generate_display_code() -> str: """Generate a random 8-character alphanumeric display code.""" chars = string.ascii_uppercase + string.digits return ''.join(secrets.choice(chars) for _ in range(8)) async def _generate_unique_display_code(db: AsyncSession) -> str: """Generate a unique display code for a new account.""" while True: display_code = _generate_display_code() existing = await db.execute(select(Account.id).where(Account.display_code == display_code)) if existing.scalar_one_or_none() is None: return display_code async def _get_account_detail_payload( account_id: UUID, db: AsyncSession, include_archived: bool = False, ) -> AdminAccountDetailResponse: owner_user = aliased(User) result = await db.execute( select( Account, owner_user.id.label("owner_user_id"), owner_user.name.label("owner_name"), owner_user.email.label("owner_email"), Subscription.id.label("subscription_id"), Subscription.plan.label("subscription_plan"), Subscription.status.label("subscription_status"), Subscription.billing_interval.label("subscription_billing_interval"), Subscription.current_period_end.label("subscription_current_period_end"), Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"), ) .outerjoin(owner_user, Account.owner_id == owner_user.id) .outerjoin(Subscription, Subscription.account_id == Account.id) .where(Account.id == account_id) ) row = result.one_or_none() if not row: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc()) if not include_archived: members_query = members_query.where(User.deleted_at.is_(None)) members_result = await db.execute(members_query) members = [ AdminAccountMember( id=member.id, email=member.email, name=member.name, role=member.role, is_super_admin=member.is_super_admin, is_active=member.is_active, account_role=member.account_role, created_at=member.created_at, last_login=member.last_login, deleted_at=member.deleted_at, ) for member in members_result.scalars().all() ] invites_result = await db.execute( select(AccountInvite) .where(AccountInvite.account_id == account_id) .order_by(AccountInvite.created_at.desc()) ) invites = [ AdminAccountInviteSummary( id=invite.id, email=invite.email, role=invite.role, expires_at=invite.expires_at, created_at=invite.created_at, used_at=invite.used_at, ) for invite in invites_result.scalars().all() if invite.used_at is None ] usage = await get_account_usage(account_id, db) return AdminAccountDetailResponse( id=row.Account.id, name=row.Account.name, display_code=row.Account.display_code, created_at=row.Account.created_at, owner_id=row.Account.owner_id, owner=( AdminAccountOwnerSummary( id=row.owner_user_id, name=row.owner_name, email=row.owner_email, ) if row.owner_user_id and row.owner_name and row.owner_email else None ), subscription=( AdminAccountSubscriptionSummary( id=row.subscription_id, plan=row.subscription_plan, status=row.subscription_status, billing_interval=row.subscription_billing_interval, current_period_end=row.subscription_current_period_end, cancel_at_period_end=row.subscription_cancel_at_period_end or False, ) if row.subscription_id and row.subscription_plan and row.subscription_status else None ), usage=AdminAccountUsageSummary( tree_count=usage.get("tree_count", 0), session_count_this_month=usage.get("session_count_this_month", 0), ), member_count=len(members), active_member_count=sum(1 for member in members if member.is_active), pending_invite_count=len(invites), sso_enabled=row.Account.sso_enabled, branding_company_name=row.Account.branding_company_name, members=members, invites=invites, ) @router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED) async def create_account( data: AdminAccountCreate, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Create a new account without requiring an initial user.""" owner_id = None if data.owner_email: result = await db.execute(select(User).where(User.email == data.owner_email.strip())) owner = result.scalar_one_or_none() if not owner: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No user found with email '{data.owner_email}'") owner_id = owner.id display_code = await _generate_unique_display_code(db) new_account = Account( name=data.name.strip(), display_code=display_code, owner_id=owner_id, ) db.add(new_account) await db.flush() new_subscription = Subscription( account_id=new_account.id, plan=data.plan, status="active", ) db.add(new_subscription) await log_audit( db, current_user.id, "account.create_admin", "account", new_account.id, {"name": new_account.name, "plan": data.plan, "owner_email": data.owner_email}, ) await db.commit() return await _get_account_detail_payload(new_account.id, db) @router.get("/accounts/{account_id}", response_model=AdminAccountDetailResponse) async def get_account_detail( account_id: UUID, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], include_archived: bool = Query(False), ): """Get detailed account information for admin management.""" return await _get_account_detail_payload(account_id, db, include_archived=include_archived) @router.put("/accounts/{account_id}", response_model=AdminAccountDetailResponse) async def update_account( account_id: UUID, data: AdminAccountUpdate, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Update account settings from the admin panel.""" result = await db.execute(select(Account).where(Account.id == account_id)) account = result.scalar_one_or_none() if not account: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") old_name = account.name account.name = data.name.strip() await log_audit( db, current_user.id, "account.update_admin", "account", account.id, {"old_name": old_name, "new_name": account.name}, ) await db.commit() return await _get_account_detail_payload(account.id, db) @router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED) async def create_user( data: AdminUserCreate, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Create a new user with a temporary password (super admin only). Supports two modes: - existing: Join an existing account (resolved by display_code) - personal: Create a new personal account for the user """ # Validate mode-specific fields if data.account_mode == "existing": if not data.account_display_code: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="account_display_code is required for existing mode", ) if not data.account_role: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="account_role is required for existing mode", ) # Check email uniqueness result = await db.execute(select(User).where(User.email == data.email)) if result.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered", ) # Generate temp password temp_password = generate_temp_password() password_hash = get_password_hash(temp_password) if data.account_mode == "existing": # Resolve account by display code result = await db.execute( select(Account).where(Account.display_code == data.account_display_code) ) account = result.scalar_one_or_none() if not account: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Account not found for the given display code", ) new_user = User( email=data.email, password_hash=password_hash, name=data.name, role="engineer", account_id=account.id, account_role=data.account_role, must_change_password=True, ) db.add(new_user) await db.flush() else: # Personal mode: create new account + user as owner new_account = Account( name=f"{data.name}'s Account", display_code=_generate_display_code(), ) db.add(new_account) await db.flush() new_user = User( email=data.email, password_hash=password_hash, name=data.name, role="engineer", account_id=new_account.id, account_role="owner", must_change_password=True, ) db.add(new_user) await db.flush() new_account.owner_id = new_user.id # Create free subscription for the new account new_subscription = Subscription( account_id=new_account.id, plan="free", status="active", ) db.add(new_subscription) await log_audit( db, current_user.id, "user.create_admin", "user", new_user.id, {"email": data.email, "account_mode": data.account_mode}, ) await db.commit() await db.refresh(new_user) # Send welcome email (best-effort) email_sent = False if data.send_email: email_sent = await EmailService.send_welcome_email( to_email=data.email, temp_password=temp_password, ) return AdminUserCreateResponse( user={ "id": str(new_user.id), "email": new_user.email, "name": new_user.name, "role": new_user.role, "is_active": new_user.is_active, "is_super_admin": new_user.is_super_admin, "account_id": str(new_user.account_id) if new_user.account_id else None, "account_role": new_user.account_role, "must_change_password": new_user.must_change_password, "created_at": new_user.created_at.isoformat() if new_user.created_at else None, }, temporary_password=temp_password, email_sent=email_sent, ) @router.get("/users/{user_id}", response_model=UserDetailResponse) async def get_user( user_id: UUID, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)] ): """Get enriched user details (super admin only).""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) # Account + subscription account_summary = None subscription_summary = None if user.account_id: acc_result = await db.execute(select(Account).where(Account.id == user.account_id)) account = acc_result.scalar_one_or_none() if account: account_summary = AccountSummary( id=account.id, name=account.name, display_code=getattr(account, "display_code", None), ) sub_result = await db.execute( select(Subscription).where(Subscription.account_id == user.account_id) ) subscription = sub_result.scalar_one_or_none() if subscription: subscription_summary = SubscriptionSummary( id=subscription.id, plan=subscription.plan, status=subscription.status, current_period_start=subscription.current_period_start, current_period_end=subscription.current_period_end, ) # Recent sessions (latest 10 + total) total_sessions_result = await db.execute( select(func.count()).select_from(Session).where(Session.user_id == user_id) ) total_sessions = total_sessions_result.scalar() or 0 sessions_result = await db.execute( select(Session).options(selectinload(Session.tree)) .where(Session.user_id == user_id) .order_by(Session.started_at.desc()) .limit(10) ) sessions = sessions_result.scalars().all() recent_sessions = [ SessionSummary( id=s.id, tree_name=s.tree.name if s.tree else None, started_at=s.started_at, completed_at=s.completed_at, outcome=s.outcome, ) for s in sessions ] # Recent audit logs (latest 10 + total) total_audits_result = await db.execute( select(func.count()).select_from(AuditLog).where(AuditLog.user_id == user_id) ) total_audit_logs = total_audits_result.scalar() or 0 audits_result = await db.execute( select(AuditLog).where(AuditLog.user_id == user_id) .order_by(AuditLog.created_at.desc()) .limit(10) ) audits = audits_result.scalars().all() recent_audit_logs = [ AuditLogSummary( id=a.id, action=a.action, resource_type=a.resource_type, resource_id=str(a.resource_id) if a.resource_id else None, created_at=a.created_at, details=a.details, ) for a in audits ] # Invite code used invite_code_used = None if user.invite_code_id: ic_result = await db.execute( select(InviteCode).where(InviteCode.id == user.invite_code_id) ) ic = ic_result.scalar_one_or_none() if ic: creator_email = None if ic.created_by_id: creator_result = await db.execute( select(User.email).where(User.id == ic.created_by_id) ) creator_email = creator_result.scalar_one_or_none() invite_code_used = InviteCodeUsedSummary( code=ic.code, assigned_plan=ic.assigned_plan, trial_duration_days=ic.trial_duration_days, created_by_email=creator_email, ) return UserDetailResponse( id=user.id, email=user.email, full_name=user.name, role=user.role, is_active=user.is_active, is_super_admin=user.is_super_admin, is_team_admin=getattr(user, "is_team_admin", False), created_at=user.created_at, deleted_at=user.deleted_at, account=account_summary, subscription=subscription_summary, invite_code_used=invite_code_used, recent_sessions=recent_sessions, total_sessions=total_sessions, recent_audit_logs=recent_audit_logs, total_audit_logs=total_audit_logs, ) @router.put("/users/{user_id}/role", response_model=UserResponse) async def update_user_role( user_id: UUID, role_data: RoleUpdate, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)] ): """Change user role (super admin only).""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) if user.id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change your own role" ) old_role = user.role user.role = role_data.role await log_audit(db, current_user.id, "user.role_change", "user", user.id, {"old_role": old_role, "new_role": role_data.role}) await db.commit() await db.refresh(user) return user @router.put("/users/{user_id}/account-role", response_model=UserResponse) async def update_account_role( user_id: UUID, data: AccountRoleUpdate, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)] ): """Change a user's account role (super admin only).""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) old_role = user.account_role user.account_role = data.account_role await log_audit(db, current_user.id, "user.account_role_change", "user", user.id, {"old_account_role": old_role, "new_account_role": data.account_role}) await db.commit() await db.refresh(user) return user @router.put("/users/{user_id}/super-admin", response_model=UserResponse) async def update_super_admin_status( user_id: UUID, data: dict, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)] ): """Promote or demote a user to/from super admin (super admin only).""" is_super_admin = data.get("is_super_admin") if not isinstance(is_super_admin, bool): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="is_super_admin must be a boolean" ) result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) if user.id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change your own super admin status" ) old_status = user.is_super_admin user.is_super_admin = is_super_admin action = "user.promote_super_admin" if is_super_admin else "user.demote_super_admin" await log_audit(db, current_user.id, action, "user", user.id, {"email": user.email, "old_is_super_admin": old_status, "new_is_super_admin": is_super_admin}) await db.commit() await db.refresh(user) return user @router.put("/users/{user_id}/deactivate", response_model=UserResponse) async def deactivate_user( user_id: UUID, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)] ): """Deactivate a user account (super admin only).""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) if user.id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot deactivate your own account" ) user.is_active = False await log_audit(db, current_user.id, "user.deactivate", "user", user.id) await db.commit() await db.refresh(user) return user @router.put("/users/{user_id}/activate", response_model=UserResponse) async def activate_user( user_id: UUID, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)] ): """Reactivate a user account (super admin only).""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) user.is_active = True await log_audit(db, current_user.id, "user.activate", "user", user.id) await db.commit() await db.refresh(user) return user @router.put("/users/{user_id}/move-account", response_model=UserResponse) async def move_user_account( user_id: UUID, data: MoveUserAccount, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Move a user to a different account (super admin only).""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") result = await db.execute(select(Account).where(Account.display_code == data.display_code)) target_account = result.scalar_one_or_none() if not target_account: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target account not found") old_account_id = user.account_id user.account_id = target_account.id user.account_role = "engineer" # Reset to engineer on move await log_audit(db, current_user.id, "user.move_account", "user", user.id, {"old_account_id": str(old_account_id), "new_account_id": str(target_account.id)}) await db.commit() await db.refresh(user) return user async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User, Subscription]: """Helper to load user and their subscription.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") if not user.account_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User has no account") sub_result = await db.execute( select(Subscription).where(Subscription.account_id == user.account_id) ) subscription = sub_result.scalar_one_or_none() if not subscription: # Auto-create a free subscription for accounts that predate the subscription system subscription = Subscription( account_id=user.account_id, plan="free", status="active", ) db.add(subscription) await db.flush() return user, subscription async def _get_account_subscription(account_id: UUID, db: AsyncSession) -> tuple[Account, Subscription]: """Helper to load account and its subscription.""" account_result = await db.execute(select(Account).where(Account.id == account_id)) account = account_result.scalar_one_or_none() if not account: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") sub_result = await db.execute( select(Subscription).where(Subscription.account_id == account.id) ) subscription = sub_result.scalar_one_or_none() if not subscription: subscription = Subscription( account_id=account.id, plan="free", status="active", ) db.add(subscription) await db.flush() return account, subscription @router.put("/users/{user_id}/subscription/plan") async def update_user_plan( user_id: UUID, data: SubscriptionPlanUpdate, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Change a user's subscription plan (super admin only).""" if data.plan not in ("free", "pro", "starter", "enterprise"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan") user, subscription = await _get_user_subscription(user_id, db) old_plan = subscription.plan subscription.plan = data.plan await log_audit(db, current_user.id, "subscription.plan_change", "subscription", subscription.id, {"old_plan": old_plan, "new_plan": data.plan, "user_id": str(user_id)}) await db.commit() return {"plan": subscription.plan, "status": subscription.status} @router.put("/accounts/{account_id}/subscription/plan") async def update_account_plan( account_id: UUID, data: SubscriptionPlanUpdate, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Change an account subscription plan (super admin only).""" if data.plan not in ("free", "pro", "starter", "enterprise"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan") account, subscription = await _get_account_subscription(account_id, db) old_plan = subscription.plan subscription.plan = data.plan await log_audit( db, current_user.id, "subscription.plan_change", "subscription", subscription.id, {"old_plan": old_plan, "new_plan": data.plan, "account_id": str(account_id)}, ) await db.commit() return {"plan": subscription.plan, "status": subscription.status} @router.put("/users/{user_id}/subscription/extend-trial") async def extend_user_trial( user_id: UUID, data: ExtendTrialRequest, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Extend or start a trial for a user's subscription (super admin only).""" if data.days < 1 or data.days > 90: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Days must be 1-90") user, subscription = await _get_user_subscription(user_id, db) now = datetime.now(timezone.utc) if subscription.status == "trialing" and subscription.current_period_end: # Extend existing trial new_end = subscription.current_period_end + timedelta(days=data.days) else: # Start new trial subscription.status = "trialing" subscription.current_period_start = now new_end = now + timedelta(days=data.days) subscription.current_period_end = new_end await log_audit(db, current_user.id, "subscription.extend_trial", "subscription", subscription.id, {"days": data.days, "new_end": new_end.isoformat(), "user_id": str(user_id)}) await db.commit() return {"plan": subscription.plan, "status": subscription.status, "current_period_end": subscription.current_period_end} @router.put("/accounts/{account_id}/subscription/extend-trial") async def extend_account_trial( account_id: UUID, data: ExtendTrialRequest, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Extend or start a trial for an account subscription (super admin only).""" if data.days < 1 or data.days > 90: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Days must be 1-90") account, subscription = await _get_account_subscription(account_id, db) now = datetime.now(timezone.utc) if subscription.status == "trialing" and subscription.current_period_end: new_end = subscription.current_period_end + timedelta(days=data.days) else: subscription.status = "trialing" subscription.current_period_start = now new_end = now + timedelta(days=data.days) subscription.current_period_end = new_end await log_audit( db, current_user.id, "subscription.extend_trial", "subscription", subscription.id, {"days": data.days, "new_end": new_end.isoformat(), "account_id": str(account.id)}, ) await db.commit() return { "plan": subscription.plan, "status": subscription.status, "current_period_end": subscription.current_period_end, } @router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse) async def admin_reset_password( user_id: UUID, data: AdminPasswordReset, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Admin-triggered password reset (super admin only). Two modes: - email_link: sends a reset email to the user - temp_password: generates a temp password and returns it once """ result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") # Revoke all refresh tokens rt_result = await db.execute( select(RefreshToken).where( RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None) ) ) for rt in rt_result.scalars().all(): rt.revoked_at = datetime.now(timezone.utc) user.must_change_password = True if data.mode == "email_link": # Create reset token and send email raw_token = create_password_reset_token(str(user.id)) payload = decode_token(raw_token) if payload and payload.get("jti"): token_record = PasswordResetToken( token_hash=hash_token(payload["jti"]), user_id=user.id, expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc), created_by_admin_id=current_user.id, ) db.add(token_record) await log_audit(db, current_user.id, "user.password_reset.admin_email", "user", user.id) await db.commit() email_sent = False if settings.FRONTEND_URL: reset_url = f"{settings.FRONTEND_URL}/reset-password?token={raw_token}" email_sent = await EmailService.send_password_reset_email( to_email=user.email, reset_url=reset_url, ) return AdminPasswordResetResponse( message="Password reset email sent" if email_sent else "Reset token created (email not configured)", email_sent=email_sent, ) else: # temp_password temp_pw = generate_temp_password() user.password_hash = get_password_hash(temp_pw) await log_audit(db, current_user.id, "user.password_reset.admin_temp", "user", user.id) await db.commit() return AdminPasswordResetResponse( message="Temporary password generated", temporary_password=temp_pw, email_sent=False, ) @router.put("/users/{user_id}/archive", response_model=UserResponse) async def archive_user( user_id: UUID, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Archive (soft delete) a user (super admin only).""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") if user.id == current_user.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot archive yourself") if user.deleted_at: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User is already archived") user.deleted_at = datetime.now(timezone.utc) user.deleted_by = current_user.id user.is_active = False # Revoke all refresh tokens rt_result = await db.execute( select(RefreshToken).where(RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None)) ) for rt in rt_result.scalars().all(): rt.revoked_at = datetime.now(timezone.utc) await log_audit(db, current_user.id, "user.archive", "user", user.id) await db.commit() await db.refresh(user) return user @router.put("/users/{user_id}/restore", response_model=UserResponse) async def restore_user( user_id: UUID, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Restore an archived user (super admin only).""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") if not user.deleted_at: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User is not archived") user.deleted_at = None user.deleted_by = None user.is_active = True await log_audit(db, current_user.id, "user.restore", "user", user.id) await db.commit() await db.refresh(user) return user @router.get("/users/{user_id}/hard-delete-check", response_model=HardDeleteCheckResponse) async def hard_delete_check( user_id: UUID, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Check if a user can be hard-deleted (super admin only). Returns blockers.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") blockers: dict = {} # Check if user owns any accounts with OTHER members (true blocker). # Sole-member accounts (e.g. personal accounts) are cleaned up during delete. owned_account_ids_result = await db.execute( select(Account.id).where(Account.owner_id == user_id) ) owned_account_ids = [row[0] for row in owned_account_ids_result.all()] shared_accounts = 0 for acc_id in owned_account_ids: other_members = (await db.execute( select(func.count()).select_from(User).where( User.account_id == acc_id, User.id != user_id ) )).scalar() or 0 if other_members > 0: shared_accounts += 1 if shared_accounts > 0: blockers["owned_accounts_with_other_members"] = shared_accounts # Check authored trees authored_trees = (await db.execute( select(func.count()).select_from(Tree).where(Tree.author_id == user_id) )).scalar() or 0 if authored_trees > 0: blockers["authored_trees"] = authored_trees # Check sessions sessions_count = (await db.execute( select(func.count()).select_from(Session).where(Session.user_id == user_id) )).scalar() or 0 if sessions_count > 0: blockers["sessions"] = sessions_count # Check audit logs audit_count = (await db.execute( select(func.count()).select_from(AuditLog).where(AuditLog.user_id == user_id) )).scalar() or 0 if audit_count > 0: blockers["audit_logs"] = audit_count # Check invite codes created invites_created = (await db.execute( select(func.count()).select_from(InviteCode).where(InviteCode.created_by_id == user_id) )).scalar() or 0 if invites_created > 0: blockers["invite_codes_created"] = invites_created # Check account invites account_invites = (await db.execute( select(func.count()).select_from(AccountInvite).where(AccountInvite.invited_by_id == user_id) )).scalar() or 0 if account_invites > 0: blockers["account_invites_created"] = account_invites return HardDeleteCheckResponse( can_delete=len(blockers) == 0, blockers=blockers, ) @router.delete("/users/{user_id}/hard-delete", status_code=status.HTTP_204_NO_CONTENT) async def hard_delete_user( user_id: UUID, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Permanently delete a user (super admin only). User must be archived first.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") if user.id == current_user.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete yourself") if user.is_super_admin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot hard-delete a super admin") if not user.deleted_at: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="User must be archived before hard-deleting" ) # Run precheck precheck = await hard_delete_check(user_id, db, current_user) if not precheck.can_delete: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Cannot delete: user has dependencies ({', '.join(precheck.blockers.keys())})" ) # Audit BEFORE delete await log_audit(db, current_user.id, "user.hard_delete", "user", user.id, {"email": user.email, "name": user.name}) from sqlalchemy import delete as sa_delete # Delete technical artifacts await db.execute(sa_delete(RefreshToken).where(RefreshToken.user_id == user_id)) await db.execute(sa_delete(PasswordResetToken).where(PasswordResetToken.user_id == user_id)) # Clean up sole-member owned accounts (personal accounts) owned_accounts_result = await db.execute( select(Account).where(Account.owner_id == user_id) ) for account in owned_accounts_result.scalars().all(): # Null out owner_id first (RESTRICT FK) account.owner_id = None await db.flush() # Delete subscription if exists await db.execute(sa_delete(Subscription).where(Subscription.account_id == account.id)) # Delete the account await db.delete(account) # Delete the user await db.delete(user) await db.commit() @router.post("/invites", status_code=status.HTTP_201_CREATED) async def admin_create_invite( data: dict, db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_admin)], ): """Quick-invite a user to an account (super admin only). Body: {email, account_display_code, role} Creates an AccountInvite and sends the invite email. """ email = data.get("email") account_display_code = data.get("account_display_code") role = data.get("role", "engineer") if not email or not account_display_code: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="email and account_display_code are required", ) if role not in ("engineer", "viewer"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="role must be 'engineer' or 'viewer'", ) # Resolve account result = await db.execute( select(Account).where(Account.display_code == account_display_code) ) account = result.scalar_one_or_none() if not account: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Account with display code '{account_display_code}' not found", ) # Check if email already has a pending invite to this account existing = await db.execute( select(AccountInvite).where( AccountInvite.account_id == account.id, AccountInvite.email == email, AccountInvite.accepted_by_id.is_(None), ) ) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A pending invite already exists for this email and account", ) # Generate invite code code = secrets.token_urlsafe(16) invite = AccountInvite( account_id=account.id, invited_by_id=current_user.id, email=email, code=code, role=role, expires_at=datetime.now(timezone.utc) + timedelta(days=7), ) db.add(invite) await log_audit( db, current_user.id, "user.invite_admin", "account_invite", invite.id, {"email": email, "account_id": str(account.id), "role": role}, ) await db.commit() # Send email (best-effort) email_sent = await EmailService.send_account_invite_email( to_email=email, code=code, account_name=account.name or account_display_code, role=role, ) return { "id": str(invite.id), "email": email, "code": code, "role": role, "account_display_code": account_display_code, "email_sent": email_sent, }