feat: admin invite codes with plan assignment + user detail page

- Migration 030: add email, assigned_plan, trial_duration_days, email_sent_at
  to invite_codes with CHECK constraints
- Resend email integration (graceful degradation when API key not set)
- Invite codes now support plan assignment (free/pro/team) and trial duration (1-90 days)
- Registration applies invite code plan/trial to new subscription
- Auto-downgrade expired trials on authenticated access
- Enriched GET /admin/users/{id} with account, subscription, sessions, audit logs
- New endpoints: PUT /admin/users/{id}/subscription/plan and extend-trial
- Frontend: enhanced invite codes page with email, plan, trial fields
- Frontend: new user detail page at /admin/users/:userId
- Fixed API path drift: /invite-codes -> /invites
- 11 new backend tests, 416 total passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-11 21:42:58 -05:00
parent a466400c5b
commit 50cb0fc7f0
24 changed files with 2522 additions and 1121 deletions

View File

@@ -65,14 +65,36 @@ async def get_refresh_token_payload(
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)]
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""Ensure user is active (not disabled)."""
"""Ensure user is active (not disabled). Auto-downgrades expired trials."""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account has been deactivated"
)
# Lightweight trial expiry check
if current_user.account_id:
from app.models.subscription import Subscription
from datetime import datetime, timezone
result = await db.execute(
select(Subscription).where(Subscription.account_id == current_user.account_id)
)
subscription = result.scalar_one_or_none()
if (
subscription
and subscription.status == "trialing"
and subscription.current_period_end
and subscription.current_period_end < datetime.now(timezone.utc)
):
subscription.plan = "free"
subscription.status = "active"
subscription.current_period_end = None
subscription.current_period_start = None
await db.commit()
return current_user

View File

@@ -1,15 +1,26 @@
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
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.audit import log_audit
from app.models.user import User
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.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
from app.schemas.admin import MoveUserAccount
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
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -42,13 +53,13 @@ async def list_users(
return users
@router.get("/users/{user_id}", response_model=UserResponse)
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Get user details (super admin only)."""
"""Get enriched user details (super admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
@@ -58,7 +69,104 @@ async def get_user(
detail="User not found"
)
return user
# 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,
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)
@@ -198,3 +306,69 @@ async def move_user_account(
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:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found")
return user, subscription
@router.put("/users/{user_id}/subscription/plan")
async def update_user_plan(
user_id: UUID,
data: SubscriptionPlanUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Change a user's subscription plan (super admin only)."""
if data.plan not in ("free", "pro", "team"):
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("/users/{user_id}/subscription/extend-trial")
async def extend_user_trial(
user_id: UUID,
data: ExtendTrialRequest,
db: Annotated[AsyncSession, Depends(get_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}

View File

@@ -1,6 +1,6 @@
import secrets
import string
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordRequestForm
@@ -92,38 +92,39 @@ async def register(
detail="Account invite code has expired"
)
# Validate platform invite code if required (skip if account invite was provided)
# Validate platform invite code (skip if account invite was provided)
invite_code_record = None
if not account_invite_record and settings.REQUIRE_INVITE_CODE:
if not user_data.invite_code:
if not account_invite_record:
if settings.REQUIRE_INVITE_CODE and not user_data.invite_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code is required"
)
# Look up invite code (case-insensitive)
result = await db.execute(
select(InviteCode).where(InviteCode.code == user_data.invite_code.upper())
)
invite_code_record = result.scalar_one_or_none()
if not invite_code_record:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code"
if user_data.invite_code:
# Look up invite code (case-insensitive) — applies plan/trial regardless of REQUIRE_INVITE_CODE
result = await db.execute(
select(InviteCode).where(InviteCode.code == user_data.invite_code.upper())
)
invite_code_record = result.scalar_one_or_none()
if invite_code_record.is_used:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has already been used"
)
if not invite_code_record:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code"
)
if invite_code_record.is_expired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has expired"
)
if invite_code_record.is_used:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has already been used"
)
if invite_code_record.is_expired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has expired"
)
# Check if email already exists
result = await db.execute(select(User).where(User.email == user_data.email))
@@ -175,10 +176,24 @@ async def register(
# Now set account owner and create subscription
new_account.owner_id = new_user.id
# Apply plan/trial from invite code if present
sub_plan = "free"
sub_status = "active"
period_start = None
period_end = None
if invite_code_record and invite_code_record.assigned_plan:
sub_plan = invite_code_record.assigned_plan
if invite_code_record.trial_duration_days:
sub_status = "trialing"
period_start = datetime.now(timezone.utc)
period_end = period_start + timedelta(days=invite_code_record.trial_duration_days)
new_subscription = Subscription(
account_id=new_account.id,
plan="free",
status="active",
plan=sub_plan,
status=sub_status,
current_period_start=period_start,
current_period_end=period_end,
)
db.add(new_subscription)

View File

@@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.rate_limit import limiter
from app.core.audit import log_audit
from app.core.email import EmailService
from app.models.user import User
from app.models.invite_code import InviteCode
from app.schemas.invite_code import InviteCodeCreate, InviteCodeResponse, InviteCodeValidation
@@ -23,9 +25,35 @@ async def create_invite_code(
invite_code = InviteCode(
created_by_id=current_user.id,
expires_at=invite_data.expires_at,
note=invite_data.note
note=invite_data.note,
email=invite_data.email,
assigned_plan=invite_data.assigned_plan,
trial_duration_days=invite_data.trial_duration_days,
)
db.add(invite_code)
await db.flush()
# Send invite email if email provided
email_sent = False
if invite_data.email:
email_sent = await EmailService.send_invite_email(
to_email=invite_data.email,
code=invite_code.code,
plan=invite_data.assigned_plan,
trial_days=invite_data.trial_duration_days,
)
if email_sent:
invite_code.email_sent_at = datetime.now(timezone.utc)
await log_audit(
db, current_user.id, "invite.create", "invite_code", invite_code.id,
{
"code": invite_code.code,
"plan": invite_data.assigned_plan,
"email": invite_data.email,
"email_sent": email_sent,
},
)
await db.commit()
await db.refresh(invite_code)