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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user