AuthorBot Cursor commited on
Commit
cf196a9
Β·
1 Parent(s): a825fee

Fix token display for unsubscribed authors and add SuperAdmin author removal.

Browse files

Token usage now reads only active grants; admin shows a no-subscription banner. SuperAdmin can permanently delete author accounts with confirmation.

Co-authored-by: Cursor <cursoragent@cursor.com>

app/admin/router.py CHANGED
@@ -756,23 +756,13 @@ async def get_token_usage(
756
  current_user=Depends(get_current_user),
757
  db: AsyncSession = Depends(get_db),
758
  ):
759
- """Return token budget and consumption."""
760
- from sqlalchemy import select
761
- from app.models.client_access import ClientAccess
762
-
763
- result = await db.execute(
764
- select(ClientAccess)
765
- .where(ClientAccess.author_id == current_user.id)
766
- .order_by(ClientAccess.granted_at.desc())
767
- .limit(1)
768
- )
769
- access = result.scalar_one_or_none()
770
- if not access:
771
- return {"budget": 0, "used": 0, "remaining": 0, "percent_used": 0}
772
-
773
- from app.services.token_budget import usage_summary
774
 
775
- return usage_summary(access)
 
 
776
 
777
 
778
  # ══════════════════════════════════════════════════════════════════════════════
 
756
  current_user=Depends(get_current_user),
757
  db: AsyncSession = Depends(get_db),
758
  ):
759
+ """Return token budget and consumption for the active subscription only."""
760
+ from app.repositories.access_repo import AccessRepository
761
+ from app.services.token_budget import usage_summary_or_empty
 
 
 
 
 
 
 
 
 
 
 
 
762
 
763
+ access_repo = AccessRepository(db)
764
+ access = await access_repo.get_active_for_author(current_user.id)
765
+ return usage_summary_or_empty(access)
766
 
767
 
768
  # ══════════════════════════════════════════════════════════════════════════════
app/admin/templates/admin.html CHANGED
@@ -535,6 +535,9 @@
535
  <!-- ═══ TOKEN USAGE ═══ -->
536
  <div class="page" id="page-tokens">
537
  <div class="page-header"><h1 class="page-title">Token Usage <span class="info-tip">β„Ή<span class="tip-text">Tracks your AI token consumption. Each chatbot response uses tokens from your budget. When your budget is exhausted, the chatbot will show a friendly "recharging" message until tokens are replenished.</span></span></h1><p class="page-sub">Monitor your AI token budget and consumption</p></div>
 
 
 
538
  <div class="grid-2">
539
  <div class="card" style="text-align:center;padding:40px;">
540
  <div style="position:relative;width:180px;height:180px;margin:0 auto 20px;">
@@ -1168,14 +1171,23 @@ async function loadAnalytics(days = 30) {
1168
  async function loadTokenUsage() {
1169
  try {
1170
  const data = await apiGet(`/admin/${authorSlug}/token-usage`);
1171
- const budget = data.budget || 0;
1172
- const used = data.used || 0;
1173
- const remaining = data.remaining || 0;
1174
- const pct = data.percent_used || 0;
1175
- document.getElementById('token-pct').textContent = pct + '%';
1176
- document.getElementById('token-budget').textContent = budget.toLocaleString();
 
 
 
 
 
 
 
 
 
1177
  document.getElementById('token-used').textContent = used.toLocaleString();
1178
- document.getElementById('token-remaining').textContent = remaining.toLocaleString();
1179
  // Gauge
1180
  const canvas = document.getElementById('chart-token-gauge');
1181
  if (canvas) {
@@ -1183,8 +1195,10 @@ async function loadTokenUsage() {
1183
  const cx = 90, cy = 90, r = 75, lw = 12;
1184
  ctx.clearRect(0, 0, 180, 180);
1185
  ctx.beginPath(); ctx.arc(cx, cy, r, 0.75 * Math.PI, 2.25 * Math.PI); ctx.strokeStyle = '#f1f3f9'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke();
1186
- ctx.beginPath(); ctx.arc(cx, cy, r, 0.75 * Math.PI, (0.75 + 1.5 * pct / 100) * Math.PI);
1187
- ctx.strokeStyle = pct > 90 ? '#ef4444' : pct > 75 ? '#f59e0b' : '#6366f1'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke();
 
 
1188
  }
1189
  } catch(e) { console.error('Token usage:', e); }
1190
  }
 
535
  <!-- ═══ TOKEN USAGE ═══ -->
536
  <div class="page" id="page-tokens">
537
  <div class="page-header"><h1 class="page-title">Token Usage <span class="info-tip">β„Ή<span class="tip-text">Tracks your AI token consumption. Each chatbot response uses tokens from your budget. When your budget is exhausted, the chatbot will show a friendly "recharging" message until tokens are replenished.</span></span></h1><p class="page-sub">Monitor your AI token budget and consumption</p></div>
538
+ <div id="token-subscription-banner" class="hidden" style="margin-bottom:16px;padding:14px 18px;border-radius:10px;background:var(--yellow-bg);border:1px solid var(--yellow);font-size:13px;color:var(--text2);">
539
+ ⚠️ <strong>No active subscription.</strong> Contact your platform administrator to get a package before using the chatbot.
540
+ </div>
541
  <div class="grid-2">
542
  <div class="card" style="text-align:center;padding:40px;">
543
  <div style="position:relative;width:180px;height:180px;margin:0 auto 20px;">
 
1171
  async function loadTokenUsage() {
1172
  try {
1173
  const data = await apiGet(`/admin/${authorSlug}/token-usage`);
1174
+ const banner = document.getElementById('token-subscription-banner');
1175
+ const hasSub = data.has_active_subscription !== false && data.subscription_status === 'active';
1176
+
1177
+ if (banner) {
1178
+ if (hasSub) banner.classList.add('hidden');
1179
+ else banner.classList.remove('hidden');
1180
+ }
1181
+
1182
+ const budget = hasSub ? (data.budget || 0) : 0;
1183
+ const used = hasSub ? (data.used || 0) : 0;
1184
+ const remaining = hasSub ? (data.remaining || 0) : 0;
1185
+ const pct = hasSub ? (data.percent_used || 0) : 0;
1186
+
1187
+ document.getElementById('token-pct').textContent = hasSub ? (pct + '%') : 'β€”';
1188
+ document.getElementById('token-budget').textContent = hasSub ? budget.toLocaleString() : 'No plan';
1189
  document.getElementById('token-used').textContent = used.toLocaleString();
1190
+ document.getElementById('token-remaining').textContent = hasSub ? remaining.toLocaleString() : 'β€”';
1191
  // Gauge
1192
  const canvas = document.getElementById('chart-token-gauge');
1193
  if (canvas) {
 
1195
  const cx = 90, cy = 90, r = 75, lw = 12;
1196
  ctx.clearRect(0, 0, 180, 180);
1197
  ctx.beginPath(); ctx.arc(cx, cy, r, 0.75 * Math.PI, 2.25 * Math.PI); ctx.strokeStyle = '#f1f3f9'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke();
1198
+ if (hasSub && pct > 0) {
1199
+ ctx.beginPath(); ctx.arc(cx, cy, r, 0.75 * Math.PI, (0.75 + 1.5 * pct / 100) * Math.PI);
1200
+ ctx.strokeStyle = pct > 90 ? '#ef4444' : pct > 75 ? '#f59e0b' : '#6366f1'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke();
1201
+ }
1202
  }
1203
  } catch(e) { console.error('Token usage:', e); }
1204
  }
app/services/superadmin_service.py CHANGED
@@ -132,6 +132,56 @@ class SuperAdminService:
132
  )
133
  logger.info("Author unsuspended", author_id=author_id)
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  async def grant_access(
136
  self,
137
  actor: User,
 
132
  )
133
  logger.info("Author unsuspended", author_id=author_id)
134
 
135
+ async def delete_author(self, actor: User, author_id: str) -> None:
136
+ """Permanently delete an author account and all associated data.
137
+
138
+ Args:
139
+ actor: The SuperAdmin performing the action.
140
+ author_id: UUID of the author to delete.
141
+
142
+ Raises:
143
+ ValueError: If author not found, is SuperAdmin, or is the actor.
144
+ """
145
+ author = await self._user_repo.get_by_id(author_id)
146
+ if not author:
147
+ raise ValueError(f"Author {author_id} not found")
148
+ if author.role == "superadmin":
149
+ raise ValueError("Cannot delete SuperAdmin accounts")
150
+ if author.id == actor.id:
151
+ raise ValueError("Cannot delete your own account")
152
+
153
+ from sqlalchemy import select
154
+
155
+ result = await self._db.execute(
156
+ select(ClientAccess).where(ClientAccess.author_id == author_id)
157
+ )
158
+ for access in result.scalars().all():
159
+ if not access.is_revoked:
160
+ await revoke_token_in_redis(
161
+ redis=self._redis,
162
+ token_hash=access.token_hash,
163
+ expires_at=access.expires_at,
164
+ )
165
+
166
+ await clear_token_usage_counters(self._redis, author_id)
167
+
168
+ email = author.email
169
+ name = author.full_name or email
170
+
171
+ await self._audit.log(
172
+ actor_id=actor.id,
173
+ actor_email=actor.email,
174
+ action="delete_author",
175
+ target_type="author",
176
+ target_id=author_id,
177
+ details={"email": email, "full_name": name},
178
+ )
179
+
180
+ await self._db.delete(author)
181
+ await self._db.flush()
182
+
183
+ logger.info("Author deleted", author_id=author_id, email=email)
184
+
185
  async def grant_access(
186
  self,
187
  actor: User,
app/services/token_budget.py CHANGED
@@ -36,9 +36,32 @@ def usage_summary(access: ClientAccess) -> dict:
36
  "used": used,
37
  "remaining": max(0, budget - used),
38
  "percent_used": percent_used(access),
 
 
 
 
39
  }
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  async def record_token_usage(
43
  db,
44
  redis,
 
36
  "used": used,
37
  "remaining": max(0, budget - used),
38
  "percent_used": percent_used(access),
39
+ "has_active_subscription": True,
40
+ "subscription_status": "active",
41
+ "plan": access.plan,
42
+ "expires_at": access.expires_at.isoformat() if access.expires_at else None,
43
  }
44
 
45
 
46
+ NO_SUBSCRIPTION_SUMMARY: dict = {
47
+ "budget": 0,
48
+ "used": 0,
49
+ "remaining": 0,
50
+ "percent_used": 0,
51
+ "has_active_subscription": False,
52
+ "subscription_status": "none",
53
+ "plan": None,
54
+ "expires_at": None,
55
+ }
56
+
57
+
58
+ def usage_summary_or_empty(access: ClientAccess | None) -> dict:
59
+ """Return usage stats for an active grant, or zeros when unsubscribed."""
60
+ if not access:
61
+ return dict(NO_SUBSCRIPTION_SUMMARY)
62
+ return usage_summary(access)
63
+
64
+
65
  async def record_token_usage(
66
  db,
67
  redis,
app/superadmin/router.py CHANGED
@@ -214,6 +214,22 @@ async def unsuspend_author(
214
  return _err(e, "unsuspend_author")
215
 
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  # ── Grant Access ──────────────────────────────────────────────────────────────
218
 
219
  @router.post("/authors/{author_id}/grant", status_code=201)
 
214
  return _err(e, "unsuspend_author")
215
 
216
 
217
+ @router.delete("/authors/{author_id}", status_code=200)
218
+ async def delete_author(
219
+ author_id: str,
220
+ superadmin=Depends(get_current_superadmin),
221
+ db: AsyncSession = Depends(get_db),
222
+ redis=Depends(get_redis),
223
+ ):
224
+ """Permanently delete an author account and all associated data."""
225
+ try:
226
+ service = SuperAdminService(db, redis)
227
+ await service.delete_author(actor=superadmin, author_id=author_id)
228
+ return {"message": "Author deleted"}
229
+ except Exception as e:
230
+ return _err(e, "delete_author")
231
+
232
+
233
  # ── Grant Access ──────────────────────────────────────────────────────────────
234
 
235
  @router.post("/authors/{author_id}/grant", status_code=201)
app/superadmin/templates/superadmin.html CHANGED
@@ -249,7 +249,7 @@
249
 
250
  <!-- AUTHORS -->
251
  <div class="page" id="page-authors">
252
- <div class="page-header"><h1 class="page-title">Author Management <span class="info-tip">β„Ή<span class="tip-text">All registered authors on the platform. You can suspend/unsuspend accounts, view their book counts, and manage their subscriptions. Click an author row for details.</span></span></h1><p class="page-sub">Manage all registered author accounts</p></div>
253
  <div class="toolbar">
254
  <div class="search-bar"><span>πŸ”</span><input type="text" placeholder="Search authors..." id="author-search" oninput="filterAuthors()"></div>
255
  <div class="toolbar-spacer"></div>
@@ -515,6 +515,11 @@ async function apiPost(path, body) {
515
  token = auth.getAccessToken();
516
  return data;
517
  }
 
 
 
 
 
518
 
519
  function animateNumber(id, target) {
520
  const el = document.getElementById(id); if (!el) return;
@@ -561,6 +566,7 @@ function renderAuthors(list) {
561
  ? `<button class="btn btn-success btn-sm" onclick="toggleSuspend('${a.id}', false)">Unsuspend</button>`
562
  : `<button class="btn btn-danger btn-sm" onclick="toggleSuspend('${a.id}', true)">Suspend</button>`}
563
  <button class="btn btn-secondary btn-sm" onclick="viewAuthorDetail('${a.id}')">Details</button>
 
564
  </td>
565
  </tr>`).join('');
566
  }
@@ -582,6 +588,28 @@ async function executeSuspend(id) {
582
  try { await apiPost(`/super/authors/${id}/suspend?reason=${encodeURIComponent(reason)}`, {}); closeModal(); toast('Author suspended', 'success'); loadAuthors(); } catch(e) { toast(e.message, 'error'); }
583
  }
584
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  function viewAuthorDetail(id) {
586
  const a = allAuthors.find(x => x.id === id);
587
  if (!a) return;
 
249
 
250
  <!-- AUTHORS -->
251
  <div class="page" id="page-authors">
252
+ <div class="page-header"><h1 class="page-title">Author Management <span class="info-tip">β„Ή<span class="tip-text">All registered authors on the platform. Suspend, unsuspend, or permanently remove accounts. Deleting an author removes all books, chats, and subscription data.</span></span></h1><p class="page-sub">Manage all registered author accounts</p></div>
253
  <div class="toolbar">
254
  <div class="search-bar"><span>πŸ”</span><input type="text" placeholder="Search authors..." id="author-search" oninput="filterAuthors()"></div>
255
  <div class="toolbar-spacer"></div>
 
515
  token = auth.getAccessToken();
516
  return data;
517
  }
518
+ async function apiDelete(path) {
519
+ const data = await auth.delete(path);
520
+ token = auth.getAccessToken();
521
+ return data;
522
+ }
523
 
524
  function animateNumber(id, target) {
525
  const el = document.getElementById(id); if (!el) return;
 
566
  ? `<button class="btn btn-success btn-sm" onclick="toggleSuspend('${a.id}', false)">Unsuspend</button>`
567
  : `<button class="btn btn-danger btn-sm" onclick="toggleSuspend('${a.id}', true)">Suspend</button>`}
568
  <button class="btn btn-secondary btn-sm" onclick="viewAuthorDetail('${a.id}')">Details</button>
569
+ <button class="btn btn-danger btn-sm" onclick="confirmDeleteAuthor('${a.id}','${esc(a.email)}')" title="Permanently delete this author">Remove</button>
570
  </td>
571
  </tr>`).join('');
572
  }
 
588
  try { await apiPost(`/super/authors/${id}/suspend?reason=${encodeURIComponent(reason)}`, {}); closeModal(); toast('Author suspended', 'success'); loadAuthors(); } catch(e) { toast(e.message, 'error'); }
589
  }
590
 
591
+ function confirmDeleteAuthor(id, email) {
592
+ showModal('Remove Author',
593
+ `<div class="modal-sub">Permanently delete <strong>${email}</strong>? This removes all books, chat history, embeddings, and subscriptions. <strong>This cannot be undone.</strong></div>
594
+ <div class="form-group"><label class="form-label">Type DELETE to confirm</label><input type="text" class="form-input" id="delete-author-confirm" placeholder="DELETE"></div>`,
595
+ `<button class="btn btn-secondary" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="executeDeleteAuthor('${id}')">Remove Author</button>`);
596
+ }
597
+
598
+ async function executeDeleteAuthor(id) {
599
+ const input = document.getElementById('delete-author-confirm');
600
+ if (!input || input.value.trim().toUpperCase() !== 'DELETE') {
601
+ toast('Type DELETE to confirm removal', 'error');
602
+ return;
603
+ }
604
+ try {
605
+ await apiDelete(`/super/authors/${id}`);
606
+ closeModal();
607
+ toast('Author removed', 'success');
608
+ loadAuthors();
609
+ if (typeof loadOverview === 'function') loadOverview();
610
+ } catch(e) { toast(e.message, 'error'); }
611
+ }
612
+
613
  function viewAuthorDetail(id) {
614
  const a = allAuthors.find(x => x.id === id);
615
  if (!a) return;
tests/unit/test_suite_a_token_consistency.py CHANGED
@@ -76,3 +76,16 @@ def test_superadmin_and_admin_budget_formula_match(client_access):
76
  super_total = total_budget(client_access)
77
  admin_budget = usage_summary(client_access)["budget"]
78
  assert admin_budget == super_total
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  super_total = total_budget(client_access)
77
  admin_budget = usage_summary(client_access)["budget"]
78
  assert admin_budget == super_total
79
+
80
+
81
+ @pytest.mark.p0
82
+ def test_no_active_subscription_returns_zeros():
83
+ """Authors without an active grant must see zero budget, not stale grants."""
84
+ from app.services.token_budget import usage_summary_or_empty
85
+
86
+ result = usage_summary_or_empty(None)
87
+ assert result["budget"] == 0
88
+ assert result["used"] == 0
89
+ assert result["remaining"] == 0
90
+ assert result["has_active_subscription"] is False
91
+ assert result["subscription_status"] == "none"