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 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, timezone
666
  from app.models.client_access import ClientAccess
667
  from app.core.access.token_crypto import create_subscription_token
668
 
669
- now = datetime.now(timezone.utc)
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": max(0, (access.expires_at - now).days),
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
- now = datetime.now(timezone.utc)
334
- base = access.expires_at if access.expires_at > now else now
 
 
 
 
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, timezone
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.now(timezone.utc)
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
- is_expired = access.expires_at < now if access.expires_at else True
 
 
 
 
 
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, timezone
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.now(timezone.utc)
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('Email is required', 'error'); return; }
778
  const btn = document.getElementById('grant-btn');
 
779
  btn.disabled = true;
 
 
 
 
 
 
780
  try {
781
- await apiPost('/super/grant', {
782
- author_email: email,
783
- plan: document.getElementById('g-plan').value,
784
- duration_days: parseInt(document.getElementById('g-duration').value),
785
- token_budget: parseInt(document.getElementById('g-tokens').value),
786
- bonus_tokens: parseInt(document.getElementById('g-bonus').value),
787
- notes: document.getElementById('g-notes').value,
 
 
 
 
 
788
  });
789
- toast(`Access granted to ${email}!`, 'success');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
790
  document.getElementById('g-email').value = '';
791
  document.getElementById('g-notes').value = '';
792
- } catch(e) { toast(e.message, 'error'); }
793
- finally { btn.disabled = false; }
 
 
 
 
 
 
 
 
 
 
 
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 {