Arag / app /superadmin /routers /authors.py
AuthorBot
Fix SuperAdmin logout loop (this-binding) + add Fix Grant button to restore revoked embed grants
9916f85
Raw
History Blame Contribute Delete
15.1 kB
"""superadmin/routers/authors.py — Author account management routes.
Routes:
GET /authors
POST /authors
GET /authors/{author_id}
POST /authors/{author_id}/suspend
POST /authors/{author_id}/unsuspend
DELETE /authors/{author_id}
POST /authors/{author_id}/grant
GET /authors/{author_id}/embed-token
"""
from datetime import datetime, timezone
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.access.token_crypto import create_subscription_token
from app.dependencies import get_db, get_current_superadmin, get_redis
from app.models.book import Book
from app.models.client_access import ClientAccess
from app.models.user import User
from app.repositories.access_repo import AccessRepository
from app.repositories.audit_repo import AuditRepository
from app.repositories.user_repo import UserRepository
from app.schemas.superadmin import CreateAuthorRequest, GrantAccessRequest
from app.services.auth_service import AuthService
from app.services.author_onboarding_service import AuthorOnboardingService, author_onboarding_dict
from app.services.superadmin_service import SuperAdminService
from app.superadmin.routers._utils import _err
log = structlog.get_logger(__name__)
router = APIRouter()
@router.post("/authors", status_code=201)
async def create_author(
payload: CreateAuthorRequest,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Create a new author account. Optionally assign a subscription plan."""
try:
auth_svc = AuthService(db)
result = await auth_svc.register(
email=payload.email,
password=payload.password,
full_name=payload.full_name,
)
author_id = result["author_slug"]
user = await UserRepository(db).get_by_id(author_id)
if user:
user.provisioned_via = "superadmin"
user.activation_status = "active"
await db.commit()
audit = AuditRepository(db)
await audit.log(
actor_id=superadmin.id,
actor_email=superadmin.email,
action="create_author",
target_type="user",
target_id=author_id,
details=f"Created author {payload.email}",
)
response = {
"message": f"Author '{payload.full_name}' created successfully",
"author_id": author_id,
"email": payload.email,
}
if payload.plan:
service = SuperAdminService(db, redis)
grant_result = await service.grant_access(
actor=superadmin,
author_id=author_id,
plan=payload.plan,
auto_renew=payload.auto_renew,
)
response["subscription"] = {
"plan": payload.plan,
"token": grant_result.get("token"),
"expires_at": str(grant_result.get("expires_at", "")),
}
return response
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
return _err(e, "create_author")
@router.get("/authors")
async def list_authors(
limit: int = Query(50, ge=1, le=500),
cursor: str | None = Query(None),
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
):
"""List all author accounts with subscription status."""
try:
user_repo = UserRepository(db)
users = await user_repo.list_all_authors(limit=limit, cursor=cursor)
clients = []
for u in users:
try:
access_result = await db.execute(
select(ClientAccess)
.where(ClientAccess.author_id == u.id, ClientAccess.is_revoked == False)
.order_by(ClientAccess.granted_at.desc())
.limit(1)
)
access = access_result.scalar_one_or_none()
book_count = await db.scalar(
select(func.count()).where(Book.author_id == u.id)
) or 0
clients.append({
"id": u.id, "email": u.email, "full_name": u.full_name,
"role": u.role,
"created_at": u.created_at.isoformat() if u.created_at else None,
"chatbot_is_active": u.chatbot_is_active,
"suspended": not u.chatbot_is_active,
"book_count": book_count,
"plan": access.plan if access else None,
"token_budget": access.token_budget if access else 0,
"expires_at": access.expires_at.isoformat() if access and access.expires_at else None,
"granted_at": access.granted_at.isoformat() if access and access.granted_at else None,
**author_onboarding_dict(u),
})
except Exception as inner_e:
log.warning("Error enriching author", author_id=u.id, error=str(inner_e))
clients.append({
"id": u.id, "email": u.email, "full_name": u.full_name,
"role": u.role, "created_at": None, "chatbot_is_active": True,
"suspended": False, "book_count": 0, "plan": None,
"token_budget": 0, "expires_at": None, "granted_at": None,
})
return {"clients": clients, "count": len(clients)}
except Exception as e:
return _err(e, "list_authors")
@router.get("/authors/{author_id}")
async def get_author(
author_id: str,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
):
"""Get full detail for one author including active subscription."""
try:
user = await UserRepository(db).get_by_id(author_id)
if not user:
raise HTTPException(404, "Author not found")
access = await AccessRepository(db).get_active_for_author(author_id)
return {
"user": {
"id": user.id, "email": user.email, "full_name": user.full_name,
"website_url": user.website_url, "chatbot_is_active": user.chatbot_is_active,
"created_at": user.created_at.isoformat() if user.created_at else None,
**author_onboarding_dict(user),
},
"access": {
"grant_id": access.id, "plan": access.plan,
"expires_at": access.expires_at.isoformat() if access.expires_at else None,
"is_revoked": access.is_revoked, "token_budget": access.token_budget,
"bonus_tokens": access.bonus_tokens,
} if access else None,
}
except HTTPException:
raise
except Exception as e:
return _err(e, "get_author")
@router.post("/authors/{author_id}/suspend", status_code=200)
async def suspend_author(
author_id: str,
reason: str = "",
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Suspend an author account immediately."""
try:
await SuperAdminService(db, redis).suspend_author(
actor=superadmin, author_id=author_id, reason=reason
)
return {"message": "Author suspended"}
except Exception as e:
return _err(e, "suspend_author")
@router.post("/authors/{author_id}/unsuspend", status_code=200)
async def unsuspend_author(
author_id: str,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Reinstate a suspended author account."""
try:
await SuperAdminService(db, redis).unsuspend_author(
actor=superadmin, author_id=author_id
)
return {"message": "Author unsuspended"}
except Exception as e:
return _err(e, "unsuspend_author")
@router.delete("/authors/{author_id}", status_code=200)
async def delete_author(
author_id: str,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Permanently delete an author account and all associated data."""
try:
await SuperAdminService(db, redis).delete_author(
actor=superadmin, author_id=author_id
)
return {"message": "Author deleted"}
except Exception as e:
return _err(e, "delete_author")
@router.post("/authors/{author_id}/grant", status_code=201)
async def grant_access(
author_id: str,
payload: GrantAccessRequest,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Grant a subscription with token budget to an author."""
try:
result = await SuperAdminService(db, redis).grant_access(
actor=superadmin,
author_id=author_id,
plan=payload.plan,
auto_renew=payload.auto_renew,
notes=payload.notes,
custom_token_budget=payload.custom_token_budget,
)
return result
except Exception as e:
return _err(e, "grant_access")
@router.get("/authors/{author_id}/embed-token")
async def get_author_embed_token(
author_id: str,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
):
"""Get the active subscription token for an author (for embed script generation)."""
try:
user = await db.get(User, author_id)
if not user:
raise HTTPException(404, "Author not found")
now = datetime.now(timezone.utc).replace(tzinfo=None)
result = await db.execute(
select(ClientAccess)
.where(
ClientAccess.author_id == author_id,
ClientAccess.is_revoked == False,
ClientAccess.expires_at > now,
)
.order_by(ClientAccess.expires_at.desc())
.limit(1)
)
access = result.scalar_one_or_none()
if not access:
raise HTTPException(404, "No active subscription found for this author")
token = create_subscription_token(
author_id=author_id,
grant_id=access.id,
granted_at=access.granted_at,
expires_at=access.expires_at,
)
return {
"token": token,
"grant_id": access.id,
"plan": access.plan,
"expires_at": access.expires_at.isoformat(),
"author_slug": user.id,
}
except HTTPException:
raise
except Exception as e:
return _err(e, "get_author_embed_token")
@router.post("/authors/{author_id}/restore-grant", status_code=200)
async def restore_revoked_grant(
author_id: str,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Un-revoke the most recent in-term grant for an author (SuperAdmin override).
Restores the most recently expiring grant that is revoked but still within
its validity period. Clears any Redis blacklist entry for tokens pointing
to that grant. Does NOT create a new grant — the existing embed HTML
on the author's website continues to work without any changes.
"""
try:
from datetime import datetime, timezone
from app.core.access.subscription import REVOCATION_REDIS_PREFIX, _is_hard_revoke
from app.core.access.token_crypto import create_subscription_token, hash_subscription_token
user = await db.get(User, author_id)
if not user:
raise HTTPException(404, "Author not found")
now = datetime.now(timezone.utc)
result = await db.execute(
select(ClientAccess)
.where(ClientAccess.author_id == author_id)
.order_by(ClientAccess.expires_at.desc())
)
grant = None
for row in result.scalars():
exp = row.expires_at
if exp is not None:
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp <= now:
continue
grant = row
break
if grant is None:
raise HTTPException(404, "No in-term grant found for this author")
was_revoked = grant.is_revoked
grant.is_revoked = False
grant.revoked_at = None
grant.revoke_reason = None
if not user.chatbot_is_active:
user.chatbot_is_active = True
await db.commit()
await db.refresh(grant)
# Clear Redis blacklist for the deterministic token pointing to this grant
try:
probe = create_subscription_token(
author_id=author_id,
grant_id=grant.id,
granted_at=grant.granted_at,
expires_at=grant.expires_at,
)
await redis.delete(f"{REVOCATION_REDIS_PREFIX}{hash_subscription_token(probe)}")
except Exception:
pass
audit = AuditRepository(db)
await audit.log(
actor_id=superadmin.id,
actor_email=superadmin.email,
action="restore_grant",
target_type="client_access",
target_id=grant.id,
details=f"Restored grant for {user.email} (was_revoked={was_revoked})",
)
return {
"message": "Grant restored — visitors with existing embed HTML will work immediately.",
"grant_id": grant.id,
"plan": grant.plan,
"expires_at": grant.expires_at.isoformat() if grant.expires_at else None,
"was_revoked": was_revoked,
}
except HTTPException:
raise
except Exception as e:
return _err(e, "restore_grant")
@router.post("/authors/{author_id}/resend-credentials", status_code=202)
async def resend_author_credentials(
author_id: str,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
):
"""Queue a login email to an author (no password unless auto-provisioned)."""
try:
return await AuthorOnboardingService(db).resend_credentials_email(author_id)
except HTTPException:
raise
except Exception as e:
return _err(e, "resend_credentials")
@router.post("/authors/{author_id}/generate-support-password", status_code=200)
async def generate_support_password(
author_id: str,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
):
"""Generate a one-time temp password, email it, return once to SuperAdmin."""
try:
return await AuthorOnboardingService(db).generate_support_password(author_id)
except HTTPException:
raise
except Exception as e:
return _err(e, "generate_support_password")