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