Spaces:
Running
Running
AuthorBot commited on
Commit Β·
2e8c5a3
1
Parent(s): e70f2c6
Fix revoke subscription hang caused by blocking SMTP email send.
Browse filesRevocation 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 |
-
|
| 188 |
-
self._email.send_subscription_granted
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 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 |
-
|
| 264 |
-
self._email.send_subscription_revoked
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 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;
|
| 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"
|
| 710 |
-
document.getElementById('modal-
|
| 711 |
-
|
| 712 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 722 |
-
|
| 723 |
-
|
| 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 |
-
|
| 737 |
-
|
| 738 |
-
|
| 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 |
-
|
| 750 |
-
|
| 751 |
-
|
| 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 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 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 |
}
|