AuthorBot commited on
Commit
2e8c5a3
Β·
1 Parent(s): e70f2c6

Fix revoke subscription hang caused by blocking SMTP email send.

Browse files

Revocation completes instantly now with background email notifications, plus modal loading state and clearer validation feedback in SuperAdmin.

app/services/superadmin_service.py CHANGED
@@ -9,6 +9,7 @@ RULE: All revocations MUST update Redis revocation blacklist immediately.
9
  from datetime import datetime, timedelta, timezone
10
  from typing import Literal
11
 
 
12
  import structlog
13
  from redis.asyncio import Redis
14
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -63,6 +64,23 @@ class SuperAdminService:
63
  self._audit = AuditRepository(db)
64
  self._email = EmailService()
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  async def suspend_author(
67
  self, actor: User, author_id: str, reason: str = ""
68
  ) -> None:
@@ -183,16 +201,14 @@ class SuperAdminService:
183
  details={"plan": plan, "expires_at": expires_at.isoformat(), "token_budget": token_budget},
184
  )
185
 
186
- # Notify author by email
187
- try:
188
- self._email.send_subscription_granted(
189
- to=author.email,
190
- name=author.full_name or author.email,
191
- plan=plan.replace("_", " ").title(),
192
- expires_at=expires_at.strftime("%B %d, %Y"),
193
- )
194
- except Exception as e:
195
- logger.warning("Failed to send access granted email", error=str(e))
196
 
197
  logger.info("Access granted", author_id=author_id, plan=plan, grant_id=grant_id)
198
  return {
@@ -257,17 +273,15 @@ class SuperAdminService:
257
  details={"reason": reason, "author_id": access.author_id},
258
  )
259
 
260
- # Notify author
261
  author = await self._user_repo.get_by_id(access.author_id)
262
  if author:
263
- try:
264
- self._email.send_subscription_revoked(
265
- to=author.email,
266
- name=author.full_name or author.email,
267
- reason=reason,
268
- )
269
- except Exception as e:
270
- logger.warning("Failed to send revocation email", error=str(e))
271
 
272
  logger.info("Access revoked", grant_id=grant_id, reason=reason)
273
 
 
9
  from datetime import datetime, timedelta, timezone
10
  from typing import Literal
11
 
12
+ import asyncio
13
  import structlog
14
  from redis.asyncio import Redis
15
  from sqlalchemy.ext.asyncio import AsyncSession
 
64
  self._audit = AuditRepository(db)
65
  self._email = EmailService()
66
 
67
+ async def _notify_email_async(self, send_fn, *args, **kwargs) -> None:
68
+ """Fire-and-forget email β€” never block API responses on SMTP."""
69
+ if not cfg.SMTP_USERNAME or not cfg.SMTP_PASSWORD:
70
+ logger.debug("SMTP not configured β€” skipping email notification")
71
+ return
72
+
73
+ async def _worker() -> None:
74
+ try:
75
+ await asyncio.wait_for(
76
+ asyncio.to_thread(send_fn, *args, **kwargs),
77
+ timeout=5.0,
78
+ )
79
+ except Exception as e:
80
+ logger.warning("Email notification failed", error=str(e))
81
+
82
+ asyncio.create_task(_worker())
83
+
84
  async def suspend_author(
85
  self, actor: User, author_id: str, reason: str = ""
86
  ) -> None:
 
201
  details={"plan": plan, "expires_at": expires_at.isoformat(), "token_budget": token_budget},
202
  )
203
 
204
+ # Notify author by email (non-blocking)
205
+ await self._notify_email_async(
206
+ self._email.send_subscription_granted,
207
+ to=author.email,
208
+ name=author.full_name or author.email,
209
+ plan=plan.replace("_", " ").title(),
210
+ expires_at=expires_at.strftime("%B %d, %Y"),
211
+ )
 
 
212
 
213
  logger.info("Access granted", author_id=author_id, plan=plan, grant_id=grant_id)
214
  return {
 
273
  details={"reason": reason, "author_id": access.author_id},
274
  )
275
 
276
+ # Notify author (non-blocking β€” revocation is already effective)
277
  author = await self._user_repo.get_by_id(access.author_id)
278
  if author:
279
+ await self._notify_email_async(
280
+ self._email.send_subscription_revoked,
281
+ to=author.email,
282
+ name=author.full_name or author.email,
283
+ reason=reason,
284
+ )
 
 
285
 
286
  logger.info("Access revoked", grant_id=grant_id, reason=reason)
287
 
app/superadmin/router.py CHANGED
@@ -253,6 +253,8 @@ async def revoke_access(
253
  actor=superadmin, grant_id=grant_id, reason=payload.reason,
254
  )
255
  return {"message": "Access revoked"}
 
 
256
  except Exception as e:
257
  return _err(e, "revoke_access")
258
 
 
253
  actor=superadmin, grant_id=grant_id, reason=payload.reason,
254
  )
255
  return {"message": "Access revoked"}
256
+ except ValueError as e:
257
+ raise HTTPException(status_code=400, detail=str(e))
258
  except Exception as e:
259
  return _err(e, "revoke_access")
260
 
app/superadmin/templates/superadmin.html CHANGED
@@ -302,7 +302,7 @@
302
  </div>
303
 
304
  <!-- TOKEN ACTION MODAL -->
305
- <div id="token-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;display:none;align-items:center;justify-content:center;">
306
  <div style="background:var(--white);border-radius:var(--radius);padding:32px;width:100%;max-width:460px;box-shadow:var(--shadow-lg);animation:scaleIn 0.2s ease;">
307
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
308
  <h2 style="font-size:18px;font-weight:700;" id="modal-title">Action</h2>
@@ -706,10 +706,26 @@ function openTokenModal(title, body, onConfirm, confirmLabel = 'Confirm', confir
706
  document.getElementById('modal-title').textContent = title;
707
  document.getElementById('modal-body').innerHTML = body;
708
  const acts = document.getElementById('modal-actions');
709
- acts.innerHTML = `<button class="btn btn-secondary" onclick="closeTokenModal()">Cancel</button><button class="btn ${confirmClass}" id="modal-confirm-btn">${confirmLabel}</button>`;
710
- document.getElementById('modal-confirm-btn').onclick = onConfirm;
711
- const modal = document.getElementById('token-modal');
712
- modal.style.display = 'flex';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  }
714
  function openExtendModal(grantId, email) {
715
  openTokenModal('Extend Subscription',
@@ -718,11 +734,9 @@ function openExtendModal(grantId, email) {
718
  <div style="font-size:12px;color:var(--muted);margin-top:8px;">πŸ’‘ If the subscription has expired, it will be extended from today.</div>`,
719
  async () => {
720
  const days = parseInt(document.getElementById('tm-extend-days').value) || 30;
721
- try {
722
- await apiPost(`/super/grants/${grantId}/extend`, { extend_days: days });
723
- toast(`Extended by ${days} days βœ…`, 'success');
724
- closeTokenModal(); loadTokenManagement(); loadSubscriptions();
725
- } catch(e) { toast(e.message, 'error'); }
726
  }, 'πŸ“… Extend Subscription'
727
  );
728
  }
@@ -733,11 +747,9 @@ function openBonusModal(grantId, email) {
733
  <div style="font-size:12px;color:var(--muted);margin-top:8px;">πŸ’‘ Bonus tokens add to the existing budget without changing expiry.</div>`,
734
  async () => {
735
  const tokens = parseInt(document.getElementById('tm-bonus-tokens').value) || 100000;
736
- try {
737
- await apiPost(`/super/grants/${grantId}/bonus-tokens`, { bonus_tokens: tokens });
738
- toast(`${tokens.toLocaleString()} bonus tokens added βœ…`, 'success');
739
- closeTokenModal(); loadTokenManagement();
740
- } catch(e) { toast(e.message, 'error'); }
741
  }, '⚑ Add Bonus Tokens'
742
  );
743
  }
@@ -746,11 +758,9 @@ function openResetModal(grantId, email) {
746
  `<div style="margin-bottom:14px;font-size:13px;color:var(--muted);">Author: <strong>${esc(email)}</strong></div>
747
  <div style="background:var(--yellow-bg);border:1px solid var(--yellow);border-radius:8px;padding:12px;font-size:13px;color:var(--text2);">⚠️ This will set <strong>tokens_used back to 0</strong> and clear any bonus tokens. The author gets a fresh token budget for the same subscription period.</div>`,
748
  async () => {
749
- try {
750
- await apiPost(`/super/grants/${grantId}/reset-tokens`, {});
751
- toast('Token usage reset to 0 βœ…', 'success');
752
- closeTokenModal(); loadTokenManagement();
753
- } catch(e) { toast(e.message, 'error'); }
754
  }, 'πŸ”„ Reset Usage', 'btn-secondary'
755
  );
756
  }
@@ -762,11 +772,10 @@ function openRevokeModal(grantId, email) {
762
  async () => {
763
  const reason = document.getElementById('tm-revoke-reason').value.trim();
764
  if (!reason) { toast('Please enter a reason', 'error'); return; }
765
- try {
766
- await apiPost(`/super/grants/${grantId}/revoke`, { reason });
767
- toast('Subscription revoked βœ…', 'success');
768
- closeTokenModal(); loadTokenManagement(); loadSubscriptions();
769
- } catch(e) { toast(e.message, 'error'); }
770
  }, '🚫 Revoke Now', 'btn-danger'
771
  );
772
  }
 
302
  </div>
303
 
304
  <!-- TOKEN ACTION MODAL -->
305
+ <div id="token-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;align-items:center;justify-content:center;">
306
  <div style="background:var(--white);border-radius:var(--radius);padding:32px;width:100%;max-width:460px;box-shadow:var(--shadow-lg);animation:scaleIn 0.2s ease;">
307
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
308
  <h2 style="font-size:18px;font-weight:700;" id="modal-title">Action</h2>
 
706
  document.getElementById('modal-title').textContent = title;
707
  document.getElementById('modal-body').innerHTML = body;
708
  const acts = document.getElementById('modal-actions');
709
+ acts.innerHTML = `<button class="btn btn-secondary" id="modal-cancel-btn">Cancel</button><button class="btn ${confirmClass}" id="modal-confirm-btn">${confirmLabel}</button>`;
710
+ document.getElementById('modal-cancel-btn').onclick = closeTokenModal;
711
+ document.getElementById('modal-confirm-btn').onclick = async () => {
712
+ const btn = document.getElementById('modal-confirm-btn');
713
+ const cancelBtn = document.getElementById('modal-cancel-btn');
714
+ const original = btn.textContent;
715
+ btn.disabled = true;
716
+ cancelBtn.disabled = true;
717
+ btn.textContent = '⏳ Working...';
718
+ try {
719
+ await onConfirm();
720
+ } catch (e) {
721
+ toast(e.message || 'Action failed', 'error');
722
+ } finally {
723
+ btn.disabled = false;
724
+ cancelBtn.disabled = false;
725
+ btn.textContent = original;
726
+ }
727
+ };
728
+ document.getElementById('token-modal').style.display = 'flex';
729
  }
730
  function openExtendModal(grantId, email) {
731
  openTokenModal('Extend Subscription',
 
734
  <div style="font-size:12px;color:var(--muted);margin-top:8px;">πŸ’‘ If the subscription has expired, it will be extended from today.</div>`,
735
  async () => {
736
  const days = parseInt(document.getElementById('tm-extend-days').value) || 30;
737
+ await apiPost(`/super/grants/${grantId}/extend`, { extend_days: days });
738
+ toast(`Extended by ${days} days βœ…`, 'success');
739
+ closeTokenModal(); loadTokenManagement(); loadSubscriptions();
 
 
740
  }, 'πŸ“… Extend Subscription'
741
  );
742
  }
 
747
  <div style="font-size:12px;color:var(--muted);margin-top:8px;">πŸ’‘ Bonus tokens add to the existing budget without changing expiry.</div>`,
748
  async () => {
749
  const tokens = parseInt(document.getElementById('tm-bonus-tokens').value) || 100000;
750
+ await apiPost(`/super/grants/${grantId}/bonus-tokens`, { bonus_tokens: tokens });
751
+ toast(`${tokens.toLocaleString()} bonus tokens added βœ…`, 'success');
752
+ closeTokenModal(); loadTokenManagement();
 
 
753
  }, '⚑ Add Bonus Tokens'
754
  );
755
  }
 
758
  `<div style="margin-bottom:14px;font-size:13px;color:var(--muted);">Author: <strong>${esc(email)}</strong></div>
759
  <div style="background:var(--yellow-bg);border:1px solid var(--yellow);border-radius:8px;padding:12px;font-size:13px;color:var(--text2);">⚠️ This will set <strong>tokens_used back to 0</strong> and clear any bonus tokens. The author gets a fresh token budget for the same subscription period.</div>`,
760
  async () => {
761
+ await apiPost(`/super/grants/${grantId}/reset-tokens`, {});
762
+ toast('Token usage reset to 0 βœ…', 'success');
763
+ closeTokenModal(); loadTokenManagement();
 
 
764
  }, 'πŸ”„ Reset Usage', 'btn-secondary'
765
  );
766
  }
 
772
  async () => {
773
  const reason = document.getElementById('tm-revoke-reason').value.trim();
774
  if (!reason) { toast('Please enter a reason', 'error'); return; }
775
+ if (reason.length < 5) { toast('Reason must be at least 5 characters', 'error'); return; }
776
+ await apiPost(`/super/grants/${grantId}/revoke`, { reason });
777
+ toast('Subscription revoked βœ…', 'success');
778
+ closeTokenModal(); loadTokenManagement(); loadSubscriptions();
 
779
  }, '🚫 Revoke Now', 'btn-danger'
780
  );
781
  }