The marketing surface (PricingPage, Stripe products) was wired for "Starter / Pro / Enterprise" while the backend was on "free / pro / team", leaving plan_billing unseeded and BillingPlan accepting a literal that violated the FK to plan_limits. This change: - Migration 4ce3e594cb87: defensive UPDATE of any subscriptions on plan='team' to 'enterprise' (dev has zero), renames the plan_limits row team -> enterprise, inserts a starter row with caps interpolated between free and pro (max_trees=10, sessions=75, ai=15/mo). - Renames the plan tier across schemas (invite_code, billing, admin, subscription comment), is_paid/has_pro_entitlement checks in the Subscription model, admin/admin_dashboard plan validators, and the frontend useSubscription isPaidPlan check. Resource visibility uses the same string 'team' in a separate domain (Tree/StepLibrary visibility) and is intentionally untouched. - New backend/scripts/sync_stripe_plan_ids.py: idempotent upsert of plan_billing rows from Stripe products by exact name match. Picks the active monthly recurring price for tiers that have one; leaves annual fields NULL by design. Works against test or live keys. - Test fixture updates: conftest seeds the new taxonomy, the public plans helper is a true upsert so tests can override max_users, and team -> enterprise across test_admin_plan_limits and test_invite_plan. Verified: 86/86 passing across the subscription/billing/plan/invite/ admin sweep; sync script run against test mode populates plan_billing correctly for all three tiers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1430 lines
52 KiB
Python
1430 lines
52 KiB
Python
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,
|
|
}
|