Spaces:
Running
Running
AuthorBot commited on
Commit Β·
7e0e662
1
Parent(s): 8916c9a
fix: naive vs aware datetime error in grants/extend/embed-token, grant button loading state + 15s timeout + token display modal
Browse files- app/admin/router.py +9 -3
- app/services/superadmin_service.py +6 -2
- app/superadmin/router.py +11 -6
- app/superadmin/templates/superadmin.html +51 -12
app/admin/router.py
CHANGED
|
@@ -662,11 +662,11 @@ async def get_embed_token(
|
|
| 662 |
Token is regenerated from stored grant data β never stored in plaintext.
|
| 663 |
"""
|
| 664 |
from sqlalchemy import select
|
| 665 |
-
from datetime import datetime
|
| 666 |
from app.models.client_access import ClientAccess
|
| 667 |
from app.core.access.token_crypto import create_subscription_token
|
| 668 |
|
| 669 |
-
now = datetime.
|
| 670 |
result = await db.execute(
|
| 671 |
select(ClientAccess)
|
| 672 |
.where(
|
|
@@ -692,12 +692,18 @@ async def get_embed_token(
|
|
| 692 |
expires_at=access.expires_at,
|
| 693 |
)
|
| 694 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
return {
|
| 696 |
"token": token,
|
| 697 |
"grant_id": access.id,
|
| 698 |
"plan": access.plan,
|
| 699 |
"expires_at": access.expires_at.isoformat(),
|
| 700 |
-
"days_remaining":
|
| 701 |
"tokens_remaining": max(0, (access.token_budget + access.bonus_tokens) - access.tokens_used),
|
| 702 |
}
|
| 703 |
|
|
|
|
| 662 |
Token is regenerated from stored grant data β never stored in plaintext.
|
| 663 |
"""
|
| 664 |
from sqlalchemy import select
|
| 665 |
+
from datetime import datetime
|
| 666 |
from app.models.client_access import ClientAccess
|
| 667 |
from app.core.access.token_crypto import create_subscription_token
|
| 668 |
|
| 669 |
+
now = datetime.utcnow() # naive β matches SQLite stored datetimes
|
| 670 |
result = await db.execute(
|
| 671 |
select(ClientAccess)
|
| 672 |
.where(
|
|
|
|
| 692 |
expires_at=access.expires_at,
|
| 693 |
)
|
| 694 |
|
| 695 |
+
# Calculate days remaining safely (both naive)
|
| 696 |
+
exp = access.expires_at
|
| 697 |
+
if exp and exp.tzinfo is not None:
|
| 698 |
+
exp = exp.replace(tzinfo=None)
|
| 699 |
+
days_remaining = max(0, (exp - now).days) if exp else 0
|
| 700 |
+
|
| 701 |
return {
|
| 702 |
"token": token,
|
| 703 |
"grant_id": access.id,
|
| 704 |
"plan": access.plan,
|
| 705 |
"expires_at": access.expires_at.isoformat(),
|
| 706 |
+
"days_remaining": days_remaining,
|
| 707 |
"tokens_remaining": max(0, (access.token_budget + access.bonus_tokens) - access.tokens_used),
|
| 708 |
}
|
| 709 |
|
app/services/superadmin_service.py
CHANGED
|
@@ -330,8 +330,12 @@ class SuperAdminService:
|
|
| 330 |
raise ValueError("Cannot extend a revoked subscription β create a new grant instead")
|
| 331 |
|
| 332 |
# Extend from NOW if already expired, otherwise from current expiry
|
| 333 |
-
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
new_expiry = base + timedelta(days=extend_days)
|
| 336 |
|
| 337 |
updated = await self._access_repo.update(grant_id, access.author_id, {
|
|
|
|
| 330 |
raise ValueError("Cannot extend a revoked subscription β create a new grant instead")
|
| 331 |
|
| 332 |
# Extend from NOW if already expired, otherwise from current expiry
|
| 333 |
+
# Use naive utcnow() to match SQLite naive datetimes
|
| 334 |
+
now = datetime.utcnow()
|
| 335 |
+
exp = access.expires_at
|
| 336 |
+
if exp and exp.tzinfo is not None:
|
| 337 |
+
exp = exp.replace(tzinfo=None) # strip tz if stored as aware
|
| 338 |
+
base = exp if (exp and exp > now) else now
|
| 339 |
new_expiry = base + timedelta(days=extend_days)
|
| 340 |
|
| 341 |
updated = await self._access_repo.update(grant_id, access.author_id, {
|
app/superadmin/router.py
CHANGED
|
@@ -439,7 +439,7 @@ async def list_all_grants(
|
|
| 439 |
from sqlalchemy import select
|
| 440 |
from app.models.client_access import ClientAccess
|
| 441 |
from app.models.user import User
|
| 442 |
-
from datetime import datetime
|
| 443 |
|
| 444 |
result = await db.execute(
|
| 445 |
select(ClientAccess, User)
|
|
@@ -449,12 +449,17 @@ async def list_all_grants(
|
|
| 449 |
)
|
| 450 |
rows = result.all()
|
| 451 |
|
| 452 |
-
now = datetime.
|
| 453 |
grants = []
|
| 454 |
for access, user in rows:
|
| 455 |
total_budget = access.token_budget + access.bonus_tokens
|
| 456 |
pct = round((access.tokens_used / total_budget * 100), 1) if total_budget > 0 else 0
|
| 457 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
grants.append({
|
| 459 |
"grant_id": access.id,
|
| 460 |
"author_id": access.author_id,
|
|
@@ -513,15 +518,15 @@ async def get_author_embed_token(
|
|
| 513 |
from app.models.client_access import ClientAccess
|
| 514 |
from app.models.user import User
|
| 515 |
from app.core.access.token_crypto import create_subscription_token
|
| 516 |
-
from datetime import datetime
|
| 517 |
|
| 518 |
# Get author info
|
| 519 |
user = await db.get(User, author_id)
|
| 520 |
if not user:
|
| 521 |
raise HTTPException(404, "Author not found")
|
| 522 |
|
| 523 |
-
# Get their active grant
|
| 524 |
-
now = datetime.
|
| 525 |
result = await db.execute(
|
| 526 |
select(ClientAccess)
|
| 527 |
.where(
|
|
|
|
| 439 |
from sqlalchemy import select
|
| 440 |
from app.models.client_access import ClientAccess
|
| 441 |
from app.models.user import User
|
| 442 |
+
from datetime import datetime
|
| 443 |
|
| 444 |
result = await db.execute(
|
| 445 |
select(ClientAccess, User)
|
|
|
|
| 449 |
)
|
| 450 |
rows = result.all()
|
| 451 |
|
| 452 |
+
now = datetime.utcnow() # naive β matches SQLite stored datetimes
|
| 453 |
grants = []
|
| 454 |
for access, user in rows:
|
| 455 |
total_budget = access.token_budget + access.bonus_tokens
|
| 456 |
pct = round((access.tokens_used / total_budget * 100), 1) if total_budget > 0 else 0
|
| 457 |
+
exp = access.expires_at
|
| 458 |
+
# Strip tz if aware (defensive: handle both naive and aware)
|
| 459 |
+
if exp and exp.tzinfo is not None:
|
| 460 |
+
from datetime import timezone
|
| 461 |
+
exp = exp.replace(tzinfo=None)
|
| 462 |
+
is_expired = exp < now if exp else True
|
| 463 |
grants.append({
|
| 464 |
"grant_id": access.id,
|
| 465 |
"author_id": access.author_id,
|
|
|
|
| 518 |
from app.models.client_access import ClientAccess
|
| 519 |
from app.models.user import User
|
| 520 |
from app.core.access.token_crypto import create_subscription_token
|
| 521 |
+
from datetime import datetime
|
| 522 |
|
| 523 |
# Get author info
|
| 524 |
user = await db.get(User, author_id)
|
| 525 |
if not user:
|
| 526 |
raise HTTPException(404, "Author not found")
|
| 527 |
|
| 528 |
+
# Get their active grant β use naive utcnow to match SQLite naive datetimes
|
| 529 |
+
now = datetime.utcnow()
|
| 530 |
result = await db.execute(
|
| 531 |
select(ClientAccess)
|
| 532 |
.where(
|
app/superadmin/templates/superadmin.html
CHANGED
|
@@ -773,26 +773,65 @@ document.getElementById('token-modal').addEventListener('click', e => { if (e.ta
|
|
| 773 |
|
| 774 |
/* GRANT ACCESS */
|
| 775 |
document.getElementById('grant-btn').onclick = async () => {
|
| 776 |
-
const email = document.getElementById('g-email').value;
|
| 777 |
-
if (!email) { toast('
|
| 778 |
const btn = document.getElementById('grant-btn');
|
|
|
|
| 779 |
btn.disabled = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
try {
|
| 781 |
-
await
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
});
|
| 789 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
document.getElementById('g-email').value = '';
|
| 791 |
document.getElementById('g-notes').value = '';
|
| 792 |
-
|
| 793 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 794 |
};
|
| 795 |
|
|
|
|
| 796 |
/* PLATFORM ANALYTICS */
|
| 797 |
async function loadPlatformAnalytics() {
|
| 798 |
try {
|
|
|
|
| 773 |
|
| 774 |
/* GRANT ACCESS */
|
| 775 |
document.getElementById('grant-btn').onclick = async () => {
|
| 776 |
+
const email = document.getElementById('g-email').value.trim();
|
| 777 |
+
if (!email) { toast('Author email is required', 'error'); return; }
|
| 778 |
const btn = document.getElementById('grant-btn');
|
| 779 |
+
const originalText = btn.textContent;
|
| 780 |
btn.disabled = true;
|
| 781 |
+
btn.textContent = 'β³ Granting access...';
|
| 782 |
+
|
| 783 |
+
// 15 second timeout to prevent infinite hang
|
| 784 |
+
const controller = new AbortController();
|
| 785 |
+
const timeout = setTimeout(() => { controller.abort(); }, 15000);
|
| 786 |
+
|
| 787 |
try {
|
| 788 |
+
const res = await fetch(API + '/super/grant', {
|
| 789 |
+
method: 'POST',
|
| 790 |
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
|
| 791 |
+
body: JSON.stringify({
|
| 792 |
+
author_email: email,
|
| 793 |
+
plan: document.getElementById('g-plan').value,
|
| 794 |
+
duration_days: parseInt(document.getElementById('g-duration').value),
|
| 795 |
+
token_budget: parseInt(document.getElementById('g-tokens').value),
|
| 796 |
+
bonus_tokens: parseInt(document.getElementById('g-bonus').value) || 0,
|
| 797 |
+
notes: document.getElementById('g-notes').value,
|
| 798 |
+
}),
|
| 799 |
+
signal: controller.signal,
|
| 800 |
});
|
| 801 |
+
clearTimeout(timeout);
|
| 802 |
+
if (res.status === 401 || res.status === 403) { localStorage.removeItem('sa_token'); location.reload(); return; }
|
| 803 |
+
const data = await res.json();
|
| 804 |
+
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
|
| 805 |
+
|
| 806 |
+
// Show the token in a modal so admin can copy it
|
| 807 |
+
const tok = data.token || '';
|
| 808 |
+
showModal('β
Access Granted!',
|
| 809 |
+
`<div style="margin-bottom:12px;font-size:14px;">Access granted to <strong>${esc(email)}</strong></div>
|
| 810 |
+
<div style="margin-bottom:8px;font-size:13px;font-weight:600;color:var(--muted);">Subscription Token (share with author):</div>
|
| 811 |
+
<div style="background:#0d1117;color:#e6edf3;border-radius:8px;padding:14px;font-size:11px;font-family:monospace;word-break:break-all;line-height:1.6;max-height:120px;overflow-y:auto;">${esc(tok)}</div>
|
| 812 |
+
<div style="margin-top:10px;font-size:12px;color:var(--muted);">β οΈ This token is auto-injected into the author's Embed page. They don't need to copy it manually.</div>`,
|
| 813 |
+
`<button class="btn btn-secondary" onclick="navigator.clipboard.writeText('${tok.replace(/'/g,"\\'")}');toast('Token copied!','success');">π Copy Token</button><button class="btn btn-primary" onclick="closeModal()">Done</button>`
|
| 814 |
+
);
|
| 815 |
+
|
| 816 |
+
// Clear form
|
| 817 |
document.getElementById('g-email').value = '';
|
| 818 |
document.getElementById('g-notes').value = '';
|
| 819 |
+
document.getElementById('g-tokens').value = '500000';
|
| 820 |
+
document.getElementById('g-bonus').value = '0';
|
| 821 |
+
} catch(e) {
|
| 822 |
+
clearTimeout(timeout);
|
| 823 |
+
if (e.name === 'AbortError') {
|
| 824 |
+
toast('Request timed out β server may still be processing. Check Subscriptions page.', 'error');
|
| 825 |
+
} else {
|
| 826 |
+
toast(e.message || 'Grant failed', 'error');
|
| 827 |
+
}
|
| 828 |
+
} finally {
|
| 829 |
+
btn.disabled = false;
|
| 830 |
+
btn.textContent = originalText;
|
| 831 |
+
}
|
| 832 |
};
|
| 833 |
|
| 834 |
+
|
| 835 |
/* PLATFORM ANALYTICS */
|
| 836 |
async function loadPlatformAnalytics() {
|
| 837 |
try {
|