Spaces:
Sleeping
Sleeping
AuthorBot
Fix SuperAdmin logout loop (this-binding) + add Fix Grant button to restore revoked embed grants
9916f85 | """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() | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |