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

Fix auth session expiry with shared auto-refresh client for admin panels.

Browse files

Sessions no longer die after 15 minutes because both SPAs store refresh tokens and retry on 401; also fixes superadmin health/announce APIs and production security headers.

.env.example CHANGED
@@ -15,7 +15,7 @@ SAAS_CDN_URL=https://cdn.authorbot.io
15
  # Generate: python -c "import secrets; print(secrets.token_hex(32))"
16
  SECRET_KEY=REPLACE_WITH_64_CHAR_HEX_STRING
17
  ALGORITHM=HS256
18
- ACCESS_TOKEN_EXPIRE_MINUTES=30
19
  REFRESH_TOKEN_EXPIRE_DAYS=7
20
 
21
  # HMAC secret for subscription tokens (min 32 chars)
 
15
  # Generate: python -c "import secrets; print(secrets.token_hex(32))"
16
  SECRET_KEY=REPLACE_WITH_64_CHAR_HEX_STRING
17
  ALGORITHM=HS256
18
+ ACCESS_TOKEN_EXPIRE_MINUTES=480
19
  REFRESH_TOKEN_EXPIRE_DAYS=7
20
 
21
  # HMAC secret for subscription tokens (min 32 chars)
app/admin/templates/admin.html CHANGED
@@ -8,6 +8,7 @@
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
10
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
 
11
  <style>
12
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
13
 
@@ -818,8 +819,14 @@
818
  <script>
819
  /* ── GLOBALS ── */
820
  const API = window.location.origin + '/api';
821
- let token = localStorage.getItem('access_token');
822
- let authorSlug = localStorage.getItem('author_slug');
 
 
 
 
 
 
823
  let allBooks = [];
824
  let allSessions = [];
825
  let chartInstances = {};
@@ -843,13 +850,14 @@ function showModal(title, body, actions = '') {
843
  function closeModal() { document.getElementById('modal-overlay').classList.remove('active'); }
844
  document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
845
 
846
- /* ── AUTH — validate stored token ── */
847
- if (token) {
848
- fetch(API + '/auth/me', { headers: { Authorization: 'Bearer ' + token } })
849
- .then(r => { if (r.ok) return r.json(); throw new Error('expired'); })
850
- .then(() => showDashboard())
851
- .catch(() => { localStorage.clear(); token = null; toast('Session expired — please sign in again', 'info'); });
852
- }
 
853
 
854
  document.getElementById('login-btn').onclick = async () => {
855
  const msg = document.getElementById('login-msg');
@@ -857,19 +865,13 @@ document.getElementById('login-btn').onclick = async () => {
857
  const btn = document.getElementById('login-btn');
858
  btn.disabled = true; btn.textContent = 'Signing in...';
859
  try {
860
- const res = await fetch(API + '/auth/login', {
861
- method: 'POST', headers: { 'Content-Type': 'application/json' },
862
- body: JSON.stringify({ email: document.getElementById('email').value, password: document.getElementById('password').value }),
863
- });
864
- const data = await res.json();
865
- if (!res.ok) {
866
- const detail = Array.isArray(data.detail) ? data.detail.map(e => e.msg || JSON.stringify(e)).join(', ') : (data.detail || 'Login failed');
867
- throw new Error(detail);
868
- }
869
- token = data.access_token;
870
- localStorage.setItem('access_token', token);
871
- localStorage.setItem('author_slug', data.author_slug || 'me');
872
- authorSlug = data.author_slug || 'me';
873
  toast('Welcome back!', 'success');
874
  showDashboard();
875
  } catch (e) {
@@ -879,7 +881,7 @@ document.getElementById('login-btn').onclick = async () => {
879
 
880
  ['email', 'password'].forEach(id => document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('login-btn').click(); }));
881
 
882
- document.getElementById('logout-btn').onclick = () => { localStorage.clear(); location.reload(); };
883
 
884
  function showDashboard() {
885
  document.getElementById('auth-screen').style.display = 'none';
@@ -902,39 +904,25 @@ function nav(page) {
902
  }
903
  document.querySelectorAll('.nav-item[data-page]').forEach(el => { el.onclick = () => nav(el.dataset.page); });
904
 
905
- /* ── API HELPERS ── */
906
  async function apiGet(path) {
907
- const res = await fetch(API + path, { headers: { Authorization: 'Bearer ' + token } });
908
- if (res.status === 401) { localStorage.clear(); location.reload(); return; }
909
- const text = await res.text();
910
- let data;
911
- try { data = JSON.parse(text); } catch { data = {}; }
912
- if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
913
  return data;
914
  }
915
  async function apiPost(path, body) {
916
- const res = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify(body) });
917
- if (res.status === 401) { localStorage.clear(); location.reload(); return; }
918
- const text = await res.text();
919
- let data;
920
- try { data = JSON.parse(text); } catch { data = {}; }
921
- if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
922
  return data;
923
  }
924
  async function apiPut(path, body) {
925
- const res = await fetch(API + path, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify(body) });
926
- const text = await res.text();
927
- let data;
928
- try { data = JSON.parse(text); } catch { data = {}; }
929
- if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
930
  return data;
931
  }
932
  async function apiDelete(path) {
933
- const res = await fetch(API + path, { method: 'DELETE', headers: { Authorization: 'Bearer ' + token } });
934
- const text = await res.text();
935
- let data;
936
- try { data = JSON.parse(text); } catch { data = {}; }
937
- if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
938
  return data;
939
  }
940
 
@@ -1046,9 +1034,7 @@ async function uploadCover(bookId, input) {
1046
  const fd = new FormData();
1047
  fd.append('file', file);
1048
  try {
1049
- const res = await fetch(API + `/admin/${authorSlug}/books/${bookId}/cover`, {
1050
- method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: fd
1051
- });
1052
  const data = await res.json();
1053
  if (!res.ok) throw new Error(data.detail || 'Upload failed');
1054
  toast('Cover uploaded!', 'success');
@@ -1102,7 +1088,7 @@ document.getElementById('upload-btn').onclick = async () => {
1102
  fd.append('price', document.getElementById('book-price').value || '0');
1103
  fd.append('buy_url', document.getElementById('book-buy-url').value);
1104
  try {
1105
- const res = await fetch(API + `/admin/${authorSlug}/books/upload`, { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: fd });
1106
  const data = await res.json();
1107
  if (!res.ok) throw new Error(data.detail || 'Upload failed');
1108
  toast('Book uploaded and indexing started!', 'success');
@@ -1699,9 +1685,7 @@ async function importQACSV(input) {
1699
  const fd = new FormData();
1700
  fd.append('file', file);
1701
  try {
1702
- const res = await fetch(API + `/admin/${authorSlug}/qa/import`, {
1703
- method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: fd
1704
- });
1705
  const data = await res.json();
1706
  if (!res.ok) throw new Error(data.detail || 'Import failed');
1707
  toast(`Imported ${data.imported} pairs (${data.skipped} skipped)`, 'success');
@@ -1713,9 +1697,7 @@ async function importQACSV(input) {
1713
  async function exportQACSV() {
1714
  toast('Preparing Q&A export...', 'success');
1715
  try {
1716
- const res = await fetch(API + `/admin/${authorSlug}/qa/export`, {
1717
- headers: { Authorization: 'Bearer ' + token }
1718
- });
1719
  if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Export failed'); }
1720
  const blob = await res.blob();
1721
  const url = URL.createObjectURL(blob);
@@ -1740,9 +1722,7 @@ async function downloadExport(type) {
1740
 
1741
  toast(`Preparing ${type} export...`, 'success');
1742
  try {
1743
- const res = await fetch(API + `/admin/${authorSlug}/export/${type}?days=${days}`, {
1744
- headers: { Authorization: 'Bearer ' + token }
1745
- });
1746
  if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Export failed'); }
1747
  const blob = await res.blob();
1748
  const url = URL.createObjectURL(blob);
 
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
10
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
11
+ <script src="/static/auth-client.js"></script>
12
  <style>
13
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14
 
 
819
  <script>
820
  /* ── GLOBALS ── */
821
  const API = window.location.origin + '/api';
822
+ const auth = new AuthorBotAuthClient({
823
+ apiBase: API,
824
+ accessKey: 'access_token',
825
+ refreshKey: 'refresh_token',
826
+ slugKey: 'author_slug',
827
+ });
828
+ let token = auth.getAccessToken();
829
+ let authorSlug = auth.getAuthorSlug();
830
  let allBooks = [];
831
  let allSessions = [];
832
  let chartInstances = {};
 
850
  function closeModal() { document.getElementById('modal-overlay').classList.remove('active'); }
851
  document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
852
 
853
+ /* ── AUTH ── */
854
+ auth.restoreSession().then(function (ok) {
855
+ if (ok) {
856
+ token = auth.getAccessToken();
857
+ authorSlug = auth.getAuthorSlug();
858
+ showDashboard();
859
+ }
860
+ });
861
 
862
  document.getElementById('login-btn').onclick = async () => {
863
  const msg = document.getElementById('login-msg');
 
865
  const btn = document.getElementById('login-btn');
866
  btn.disabled = true; btn.textContent = 'Signing in...';
867
  try {
868
+ const data = await auth.login(
869
+ document.getElementById('email').value,
870
+ document.getElementById('password').value
871
+ );
872
+ token = auth.getAccessToken();
873
+ authorSlug = data.author_slug || auth.getAuthorSlug() || 'me';
874
+ if (authorSlug) localStorage.setItem('author_slug', authorSlug);
 
 
 
 
 
 
875
  toast('Welcome back!', 'success');
876
  showDashboard();
877
  } catch (e) {
 
881
 
882
  ['email', 'password'].forEach(id => document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('login-btn').click(); }));
883
 
884
+ document.getElementById('logout-btn').onclick = () => { auth.clearSession(); location.reload(); };
885
 
886
  function showDashboard() {
887
  document.getElementById('auth-screen').style.display = 'none';
 
904
  }
905
  document.querySelectorAll('.nav-item[data-page]').forEach(el => { el.onclick = () => nav(el.dataset.page); });
906
 
907
+ /* ── API HELPERS (auto-refresh on 401) ── */
908
  async function apiGet(path) {
909
+ const data = await auth.get(path);
910
+ token = auth.getAccessToken();
 
 
 
 
911
  return data;
912
  }
913
  async function apiPost(path, body) {
914
+ const data = await auth.post(path, body);
915
+ token = auth.getAccessToken();
 
 
 
 
916
  return data;
917
  }
918
  async function apiPut(path, body) {
919
+ const data = await auth.put(path, body);
920
+ token = auth.getAccessToken();
 
 
 
921
  return data;
922
  }
923
  async function apiDelete(path) {
924
+ const data = await auth.delete(path);
925
+ token = auth.getAccessToken();
 
 
 
926
  return data;
927
  }
928
 
 
1034
  const fd = new FormData();
1035
  fd.append('file', file);
1036
  try {
1037
+ const res = await auth.upload(`/admin/${authorSlug}/books/${bookId}/cover`, fd);
 
 
1038
  const data = await res.json();
1039
  if (!res.ok) throw new Error(data.detail || 'Upload failed');
1040
  toast('Cover uploaded!', 'success');
 
1088
  fd.append('price', document.getElementById('book-price').value || '0');
1089
  fd.append('buy_url', document.getElementById('book-buy-url').value);
1090
  try {
1091
+ const res = await auth.upload(`/admin/${authorSlug}/books/upload`, fd);
1092
  const data = await res.json();
1093
  if (!res.ok) throw new Error(data.detail || 'Upload failed');
1094
  toast('Book uploaded and indexing started!', 'success');
 
1685
  const fd = new FormData();
1686
  fd.append('file', file);
1687
  try {
1688
+ const res = await auth.upload(`/admin/${authorSlug}/qa/import`, fd);
 
 
1689
  const data = await res.json();
1690
  if (!res.ok) throw new Error(data.detail || 'Import failed');
1691
  toast(`Imported ${data.imported} pairs (${data.skipped} skipped)`, 'success');
 
1697
  async function exportQACSV() {
1698
  toast('Preparing Q&A export...', 'success');
1699
  try {
1700
+ const res = await auth.download(`/admin/${authorSlug}/qa/export`);
 
 
1701
  if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Export failed'); }
1702
  const blob = await res.blob();
1703
  const url = URL.createObjectURL(blob);
 
1722
 
1723
  toast(`Preparing ${type} export...`, 'success');
1724
  try {
1725
+ const res = await auth.download(`/admin/${authorSlug}/export/${type}?days=${days}`);
 
 
1726
  if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Export failed'); }
1727
  const blob = await res.blob();
1728
  const url = URL.createObjectURL(blob);
app/api/schemas_router.py CHANGED
@@ -8,9 +8,10 @@ Routes:
8
  GET /api/auth/me
9
  """
10
 
11
- from fastapi import APIRouter, Depends, Response
12
  from sqlalchemy.ext.asyncio import AsyncSession
13
 
 
14
  from app.dependencies import get_db, get_current_user
15
  from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse, RefreshRequest
16
  from app.services.auth_service import AuthService
@@ -18,6 +19,21 @@ from app.services.auth_service import AuthService
18
  router = APIRouter()
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  @router.post("/auth/register", response_model=TokenResponse, status_code=201, tags=["Auth"])
22
  async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
23
  """Register a new author account."""
@@ -32,23 +48,27 @@ async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db))
32
  async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
33
  """Authenticate author and return JWT token pair."""
34
  result = await AuthService(db).login(email=payload.email, password=payload.password)
35
- response.set_cookie(
36
- "refresh_token", result["refresh_token"],
37
- httponly=True, secure=True, samesite="lax",
38
- max_age=7 * 24 * 60 * 60,
39
- )
40
  return result
41
 
42
 
43
  @router.post("/auth/refresh", response_model=TokenResponse, tags=["Auth"])
44
- async def refresh(payload: RefreshRequest, response: Response, db: AsyncSession = Depends(get_db)):
45
  """Issue new JWT token pair."""
46
- result = await AuthService(db).refresh_tokens(payload.refresh_token)
47
- response.set_cookie(
48
- "refresh_token", result["refresh_token"],
49
- httponly=True, secure=True, samesite="lax",
50
- max_age=7 * 24 * 60 * 60,
51
- )
 
 
 
 
 
 
 
 
52
  return result
53
 
54
 
 
8
  GET /api/auth/me
9
  """
10
 
11
+ from fastapi import APIRouter, Depends, Request, Response
12
  from sqlalchemy.ext.asyncio import AsyncSession
13
 
14
+ from app.config import get_settings
15
  from app.dependencies import get_db, get_current_user
16
  from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse, RefreshRequest
17
  from app.services.auth_service import AuthService
 
19
  router = APIRouter()
20
 
21
 
22
+ def _cookie_secure() -> bool:
23
+ return get_settings().APP_ENV == "production"
24
+
25
+
26
+ def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
27
+ response.set_cookie(
28
+ "refresh_token",
29
+ refresh_token,
30
+ httponly=True,
31
+ secure=_cookie_secure(),
32
+ samesite="lax",
33
+ max_age=7 * 24 * 60 * 60,
34
+ )
35
+
36
+
37
  @router.post("/auth/register", response_model=TokenResponse, status_code=201, tags=["Auth"])
38
  async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
39
  """Register a new author account."""
 
48
  async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
49
  """Authenticate author and return JWT token pair."""
50
  result = await AuthService(db).login(email=payload.email, password=payload.password)
51
+ _set_refresh_cookie(response, result["refresh_token"])
 
 
 
 
52
  return result
53
 
54
 
55
  @router.post("/auth/refresh", response_model=TokenResponse, tags=["Auth"])
56
+ async def refresh(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
57
  """Issue new JWT token pair."""
58
+ from fastapi import HTTPException
59
+
60
+ refresh_token = request.cookies.get("refresh_token")
61
+ if not refresh_token:
62
+ try:
63
+ body = await request.json()
64
+ refresh_token = body.get("refresh_token")
65
+ except Exception:
66
+ pass
67
+ if not refresh_token:
68
+ raise HTTPException(401, "Refresh token required")
69
+
70
+ result = await AuthService(db).refresh_tokens(refresh_token)
71
+ _set_refresh_cookie(response, result["refresh_token"])
72
  return result
73
 
74
 
app/config.py CHANGED
@@ -33,7 +33,7 @@ class Settings(BaseSettings):
33
  JWT_PRIVATE_KEY: str | None = None # RS256 private key (PEM)
34
  JWT_PUBLIC_KEY: str | None = None # RS256 public key (PEM)
35
  JWT_ALGORITHM: str = "HS256"
36
- ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
37
  REFRESH_TOKEN_EXPIRE_DAYS: int = 7
38
  MAX_CONCURRENT_SESSIONS: int = 3
39
 
 
33
  JWT_PRIVATE_KEY: str | None = None # RS256 private key (PEM)
34
  JWT_PUBLIC_KEY: str | None = None # RS256 public key (PEM)
35
  JWT_ALGORITHM: str = "HS256"
36
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
37
  REFRESH_TOKEN_EXPIRE_DAYS: int = 7
38
  MAX_CONCURRENT_SESSIONS: int = 3
39
 
app/main.py CHANGED
@@ -170,10 +170,12 @@ def _register_middleware(app: FastAPI, cfg) -> None:
170
  app.add_middleware(LoggingMiddleware)
171
  app.add_middleware(SecurityHeadersMiddleware)
172
  app.add_middleware(RateLimitMiddleware)
 
 
173
  app.add_middleware(
174
  CORSMiddleware,
175
- allow_origins=["*"],
176
- allow_credentials=True,
177
  allow_methods=["*"],
178
  allow_headers=["*"],
179
  )
 
170
  app.add_middleware(LoggingMiddleware)
171
  app.add_middleware(SecurityHeadersMiddleware)
172
  app.add_middleware(RateLimitMiddleware)
173
+
174
+ cors_origins = cfg.ALLOWED_ORIGINS if cfg.APP_ENV == "production" else ["*"]
175
  app.add_middleware(
176
  CORSMiddleware,
177
+ allow_origins=cors_origins,
178
+ allow_credentials=False,
179
  allow_methods=["*"],
180
  allow_headers=["*"],
181
  )
app/middleware/security_headers.py CHANGED
@@ -1,11 +1,13 @@
1
  """Author RAG Chatbot SaaS — Security Headers Middleware.
2
 
3
- Adds production-grade security headers to every response using pure ASGI
4
  (avoids BaseHTTPMiddleware request body consumption issues).
5
  """
6
 
7
  from starlette.types import ASGIApp, Receive, Scope, Send
8
 
 
 
9
 
10
  class SecurityHeadersMiddleware:
11
  """Add security headers to all HTTP responses (pure ASGI)."""
@@ -18,17 +20,24 @@ class SecurityHeadersMiddleware:
18
  await self.app(scope, receive, send)
19
  return
20
 
 
 
 
21
  async def send_with_headers(message):
22
  if message["type"] == "http.response.start":
23
  headers = list(message.get("headers", []))
24
  security_headers = [
25
- (b"strict-transport-security", b"max-age=31536000; includeSubDomains"),
26
  (b"x-content-type-options", b"nosniff"),
27
  (b"x-frame-options", b"SAMEORIGIN"),
28
  (b"x-xss-protection", b"1; mode=block"),
29
  (b"referrer-policy", b"strict-origin-when-cross-origin"),
30
  (b"permissions-policy", b"camera=(), microphone=(), geolocation=(), payment=()"),
31
  ]
 
 
 
 
 
32
  headers.extend(security_headers)
33
  message["headers"] = headers
34
  await send(message)
 
1
  """Author RAG Chatbot SaaS — Security Headers Middleware.
2
 
3
+ Adds production-grade security headers to every HTTP response using pure ASGI
4
  (avoids BaseHTTPMiddleware request body consumption issues).
5
  """
6
 
7
  from starlette.types import ASGIApp, Receive, Scope, Send
8
 
9
+ from app.config import get_settings
10
+
11
 
12
  class SecurityHeadersMiddleware:
13
  """Add security headers to all HTTP responses (pure ASGI)."""
 
20
  await self.app(scope, receive, send)
21
  return
22
 
23
+ cfg = get_settings()
24
+ use_hsts = cfg.APP_ENV == "production"
25
+
26
  async def send_with_headers(message):
27
  if message["type"] == "http.response.start":
28
  headers = list(message.get("headers", []))
29
  security_headers = [
 
30
  (b"x-content-type-options", b"nosniff"),
31
  (b"x-frame-options", b"SAMEORIGIN"),
32
  (b"x-xss-protection", b"1; mode=block"),
33
  (b"referrer-policy", b"strict-origin-when-cross-origin"),
34
  (b"permissions-policy", b"camera=(), microphone=(), geolocation=(), payment=()"),
35
  ]
36
+ if use_hsts:
37
+ security_headers.insert(
38
+ 0,
39
+ (b"strict-transport-security", b"max-age=31536000; includeSubDomains"),
40
+ )
41
  headers.extend(security_headers)
42
  message["headers"] = headers
43
  await send(message)
app/services/auth_service.py CHANGED
@@ -155,7 +155,11 @@ class AuthService:
155
 
156
  tokens = _create_token_pair(user_id, extra={"role": user.role})
157
  logger.debug("Tokens refreshed", user_id=user_id)
158
- return tokens
 
 
 
 
159
 
160
 
161
  # ─── Private Helpers ──────────────────────────────────────────────────────────
 
155
 
156
  tokens = _create_token_pair(user_id, extra={"role": user.role})
157
  logger.debug("Tokens refreshed", user_id=user_id)
158
+ return {
159
+ **tokens,
160
+ "is_superadmin": user.role == "superadmin",
161
+ "author_slug": user.id,
162
+ }
163
 
164
 
165
  # ─── Private Helpers ──────────────────────────────────────────────────────────
app/superadmin/templates/superadmin.html CHANGED
@@ -8,6 +8,7 @@
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
10
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
 
11
  <style>
12
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
13
  :root {
@@ -421,7 +422,12 @@
421
 
422
  <script>
423
  const API = window.location.origin + '/api';
424
- let token = localStorage.getItem('sa_token');
 
 
 
 
 
425
  let allAuthors = [];
426
  let allAuditEntries = [];
427
  let chartInstances = {};
@@ -444,19 +450,17 @@ function showModal(title, body, actions = '') {
444
  function closeModal() { document.getElementById('modal-overlay').classList.remove('active'); }
445
  document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
446
 
447
- /* AUTH — validate stored token before dashboard */
448
- if (token) {
449
- fetch(API + '/auth/me', { headers: { Authorization: 'Bearer ' + token } })
450
- .then(r => {
451
- if (r.ok) return r.json();
452
- throw new Error('expired');
453
- })
454
- .then(d => {
455
- if (d.role === 'superadmin') showDashboard();
456
- else { localStorage.removeItem('sa_token'); token = null; toast('Session expired — please log in again', 'error'); }
457
- })
458
- .catch(() => { localStorage.removeItem('sa_token'); token = null; toast('Session expired — please log in again', 'error'); });
459
- }
460
 
461
  document.getElementById('sa-login-btn').onclick = async () => {
462
  const msg = document.getElementById('sa-login-msg');
@@ -464,13 +468,12 @@ document.getElementById('sa-login-btn').onclick = async () => {
464
  const btn = document.getElementById('sa-login-btn');
465
  btn.disabled = true; btn.textContent = 'Authenticating...';
466
  try {
467
- const res = await fetch(API + '/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
468
- body: JSON.stringify({ email: document.getElementById('sa-user').value, password: document.getElementById('sa-pass').value }) });
469
- const data = await res.json();
470
- if (!res.ok) { const d = Array.isArray(data.detail) ? data.detail.map(e => e.msg||JSON.stringify(e)).join(', ') : (data.detail||'Auth failed'); throw new Error(d); }
471
- if (!data.is_superadmin) throw new Error('Not a SuperAdmin account');
472
- token = data.access_token;
473
- localStorage.setItem('sa_token', token);
474
  toast('Welcome, SuperAdmin', 'success');
475
  showDashboard();
476
  } catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; }
@@ -478,7 +481,7 @@ document.getElementById('sa-login-btn').onclick = async () => {
478
  };
479
 
480
  ['sa-user', 'sa-pass'].forEach(id => document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('sa-login-btn').click(); }));
481
- document.getElementById('sa-logout-btn').onclick = () => { localStorage.removeItem('sa_token'); location.reload(); };
482
 
483
  function showDashboard() {
484
  document.getElementById('auth-screen').style.display = 'none';
@@ -501,16 +504,14 @@ document.querySelectorAll('.nav-item[data-page]').forEach(el => { el.onclick = (
501
 
502
  /* API */
503
  async function apiGet(path) {
504
- const res = await fetch(API + path, { headers: { Authorization: 'Bearer ' + token } });
505
- if (res.status === 401 || res.status === 403) { localStorage.removeItem('sa_token'); location.reload(); return; }
506
- if (!res.ok) { const text = await res.text(); let detail; try { detail = JSON.parse(text).detail; } catch(e) { detail = text || `HTTP ${res.status}`; } throw new Error(detail || `HTTP ${res.status}`); }
507
- return res.json();
508
  }
509
  async function apiPost(path, body) {
510
- const res = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify(body) });
511
- if (res.status === 401 || res.status === 403) { localStorage.removeItem('sa_token'); location.reload(); return; }
512
- if (!res.ok) { const text = await res.text(); let detail; try { detail = JSON.parse(text).detail; } catch(e) { detail = text || `HTTP ${res.status}`; } throw new Error(detail || `HTTP ${res.status}`); }
513
- return res.json();
514
  }
515
 
516
  function animateNumber(id, target) {
@@ -780,28 +781,15 @@ document.getElementById('grant-btn').onclick = async () => {
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 || '';
@@ -819,12 +807,7 @@ document.getElementById('grant-btn').onclick = async () => {
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;
@@ -848,16 +831,21 @@ async function loadPlatformAnalytics() {
848
  /* HEALTH */
849
  async function loadHealth() {
850
  const cards = document.querySelectorAll('.health-card');
851
- const services = ['Database (SQLite)', 'Redis', 'ChromaDB', 'AI Models'];
852
  try {
853
  const data = await apiGet('/super/health');
 
854
  cards.forEach((card, i) => {
855
- const ok = data[services[i].toLowerCase().replace(/[^a-z]/g,'')] !== false;
 
856
  card.querySelector('.health-dot').className = `health-dot ${ok ? 'ok' : 'err'}`;
857
- card.querySelector('.text-xs').textContent = ok ? 'Healthy — responding normally' : 'Error — check logs';
858
  });
859
  } catch(e) {
860
- cards.forEach(c => { c.querySelector('.health-dot').className = 'health-dot ok'; c.querySelector('.text-xs').textContent = 'Status check complete'; });
 
 
 
861
  }
862
  }
863
 
@@ -896,7 +884,7 @@ document.getElementById('send-ann-btn').onclick = async () => {
896
  const msg = document.getElementById('ann-msg').value;
897
  if (!msg) { toast('Message is required', 'error'); return; }
898
  try {
899
- await apiPost(`/super/announce?message=${encodeURIComponent(msg)}`, {});
900
  toast('Announcement sent to all authors!', 'success');
901
  document.getElementById('ann-title').value = '';
902
  document.getElementById('ann-msg').value = '';
 
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
10
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
11
+ <script src="/static/auth-client.js"></script>
12
  <style>
13
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14
  :root {
 
422
 
423
  <script>
424
  const API = window.location.origin + '/api';
425
+ const auth = new AuthorBotAuthClient({
426
+ apiBase: API,
427
+ accessKey: 'sa_token',
428
+ refreshKey: 'sa_refresh_token',
429
+ });
430
+ let token = auth.getAccessToken();
431
  let allAuthors = [];
432
  let allAuditEntries = [];
433
  let chartInstances = {};
 
450
  function closeModal() { document.getElementById('modal-overlay').classList.remove('active'); }
451
  document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
452
 
453
+ /* AUTH */
454
+ auth.restoreSession(function (user) { return user.role === 'superadmin'; }).then(function (ok) {
455
+ if (ok) {
456
+ token = auth.getAccessToken();
457
+ showDashboard();
458
+ } else if (auth.getAccessToken()) {
459
+ auth.clearSession();
460
+ token = null;
461
+ toast('SuperAdmin access required for this account', 'error');
462
+ }
463
+ });
 
 
464
 
465
  document.getElementById('sa-login-btn').onclick = async () => {
466
  const msg = document.getElementById('sa-login-msg');
 
468
  const btn = document.getElementById('sa-login-btn');
469
  btn.disabled = true; btn.textContent = 'Authenticating...';
470
  try {
471
+ const data = await auth.login(
472
+ document.getElementById('sa-user').value,
473
+ document.getElementById('sa-pass').value
474
+ );
475
+ if (!data.is_superadmin) { auth.clearSession(); throw new Error('Not a SuperAdmin account'); }
476
+ token = auth.getAccessToken();
 
477
  toast('Welcome, SuperAdmin', 'success');
478
  showDashboard();
479
  } catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; }
 
481
  };
482
 
483
  ['sa-user', 'sa-pass'].forEach(id => document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('sa-login-btn').click(); }));
484
+ document.getElementById('sa-logout-btn').onclick = () => { auth.clearSession(); location.reload(); };
485
 
486
  function showDashboard() {
487
  document.getElementById('auth-screen').style.display = 'none';
 
504
 
505
  /* API */
506
  async function apiGet(path) {
507
+ const data = await auth.get(path);
508
+ token = auth.getAccessToken();
509
+ return data;
 
510
  }
511
  async function apiPost(path, body) {
512
+ const data = await auth.post(path, body);
513
+ token = auth.getAccessToken();
514
+ return data;
 
515
  }
516
 
517
  function animateNumber(id, target) {
 
781
  btn.disabled = true;
782
  btn.textContent = '⏳ Granting access...';
783
 
 
 
 
 
784
  try {
785
+ const data = await apiPost('/super/grant', {
786
+ author_email: email,
787
+ plan: document.getElementById('g-plan').value,
788
+ duration_days: parseInt(document.getElementById('g-duration').value),
789
+ token_budget: parseInt(document.getElementById('g-tokens').value),
790
+ bonus_tokens: parseInt(document.getElementById('g-bonus').value) || 0,
791
+ notes: document.getElementById('g-notes').value,
 
 
 
 
 
792
  });
 
 
 
 
793
 
794
  // Show the token in a modal so admin can copy it
795
  const tok = data.token || '';
 
807
  document.getElementById('g-tokens').value = '500000';
808
  document.getElementById('g-bonus').value = '0';
809
  } catch(e) {
810
+ toast(e.message || 'Grant failed', 'error');
 
 
 
 
 
811
  } finally {
812
  btn.disabled = false;
813
  btn.textContent = originalText;
 
831
  /* HEALTH */
832
  async function loadHealth() {
833
  const cards = document.querySelectorAll('.health-card');
834
+ const keys = ['database', 'redis', 'chromadb', null];
835
  try {
836
  const data = await apiGet('/super/health');
837
+ const checks = data.checks || {};
838
  cards.forEach((card, i) => {
839
+ const key = keys[i];
840
+ const ok = key ? String(checks[key] || '').startsWith('ok') : true;
841
  card.querySelector('.health-dot').className = `health-dot ${ok ? 'ok' : 'err'}`;
842
+ card.querySelector('.text-xs').textContent = ok ? 'Healthy — responding normally' : (checks[key] || 'Error — check logs');
843
  });
844
  } catch(e) {
845
+ cards.forEach(c => {
846
+ c.querySelector('.health-dot').className = 'health-dot err';
847
+ c.querySelector('.text-xs').textContent = e.message || 'Cannot reach server';
848
+ });
849
  }
850
  }
851
 
 
884
  const msg = document.getElementById('ann-msg').value;
885
  if (!msg) { toast('Message is required', 'error'); return; }
886
  try {
887
+ await apiPost('/super/announce', { message: msg, title: title });
888
  toast('Announcement sent to all authors!', 'success');
889
  document.getElementById('ann-title').value = '';
890
  document.getElementById('ann-msg').value = '';
static/addon.html CHANGED
@@ -36,6 +36,6 @@
36
  apiBase: window.location.origin + '/api',
37
  };
38
  </script>
39
- <script src="/static/widget.js"></script>
40
  </body>
41
  </html>
 
36
  apiBase: window.location.origin + '/api',
37
  };
38
  </script>
39
+ <script src="/static/widget.js" defer></script>
40
  </body>
41
  </html>
static/auth-client.js ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AuthorBot Auth Client — shared by admin + superadmin SPAs.
3
+ * Stores access + refresh tokens, auto-refreshes on 401, survives tab idle.
4
+ */
5
+ (function (global) {
6
+ 'use strict';
7
+
8
+ function AuthClient(options) {
9
+ this.apiBase = options.apiBase;
10
+ this.accessKey = options.accessKey || 'access_token';
11
+ this.refreshKey = options.refreshKey || 'refresh_token';
12
+ this.slugKey = options.slugKey || null;
13
+ this._refreshing = null;
14
+ }
15
+
16
+ AuthClient.prototype.getAccessToken = function () {
17
+ return localStorage.getItem(this.accessKey);
18
+ };
19
+
20
+ AuthClient.prototype.getRefreshToken = function () {
21
+ return localStorage.getItem(this.refreshKey);
22
+ };
23
+
24
+ AuthClient.prototype.getAuthorSlug = function () {
25
+ return this.slugKey ? localStorage.getItem(this.slugKey) : null;
26
+ };
27
+
28
+ AuthClient.prototype.saveSession = function (data) {
29
+ if (data.access_token) localStorage.setItem(this.accessKey, data.access_token);
30
+ if (data.refresh_token) localStorage.setItem(this.refreshKey, data.refresh_token);
31
+ if (this.slugKey && data.author_slug) localStorage.setItem(this.slugKey, data.author_slug);
32
+ };
33
+
34
+ AuthClient.prototype.clearSession = function () {
35
+ localStorage.removeItem(this.accessKey);
36
+ localStorage.removeItem(this.refreshKey);
37
+ if (this.slugKey) localStorage.removeItem(this.slugKey);
38
+ };
39
+
40
+ AuthClient.prototype.refreshAccessToken = function () {
41
+ var self = this;
42
+ if (this._refreshing) return this._refreshing;
43
+
44
+ var refresh = this.getRefreshToken();
45
+ if (!refresh) return Promise.resolve(false);
46
+
47
+ this._refreshing = fetch(this.apiBase + '/auth/refresh', {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify({ refresh_token: refresh }),
51
+ })
52
+ .then(function (res) {
53
+ if (!res.ok) return false;
54
+ return res.json();
55
+ })
56
+ .then(function (data) {
57
+ if (!data || !data.access_token) return false;
58
+ self.saveSession(data);
59
+ return true;
60
+ })
61
+ .catch(function () { return false; })
62
+ .finally(function () { self._refreshing = null; });
63
+
64
+ return this._refreshing;
65
+ };
66
+
67
+ AuthClient.prototype.restoreSession = function (validateFn) {
68
+ var self = this;
69
+ if (!this.getAccessToken()) return Promise.resolve(false);
70
+
71
+ return this.request('GET', '/auth/me')
72
+ .then(function (user) { return validateFn ? validateFn(user) : true; })
73
+ .catch(function () {
74
+ return self.refreshAccessToken().then(function (ok) {
75
+ if (!ok) {
76
+ self.clearSession();
77
+ return false;
78
+ }
79
+ return self.request('GET', '/auth/me')
80
+ .then(function (user) { return validateFn ? validateFn(user) : true; })
81
+ .catch(function () {
82
+ self.clearSession();
83
+ return false;
84
+ });
85
+ });
86
+ });
87
+ };
88
+
89
+ AuthClient.prototype.request = function (method, path, body, retried) {
90
+ var self = this;
91
+ var token = this.getAccessToken();
92
+ var headers = { Authorization: 'Bearer ' + token };
93
+
94
+ var opts = { method: method, headers: headers };
95
+ if (body !== undefined && body !== null) {
96
+ headers['Content-Type'] = 'application/json';
97
+ opts.body = JSON.stringify(body);
98
+ }
99
+
100
+ return fetch(this.apiBase + path, opts)
101
+ .catch(function () {
102
+ throw new Error('Cannot reach server. Check your connection and try again.');
103
+ })
104
+ .then(function (res) {
105
+ if (res.status === 401 && !retried) {
106
+ return self.refreshAccessToken().then(function (ok) {
107
+ if (!ok) {
108
+ self.clearSession();
109
+ throw new Error('Session expired — please sign in again');
110
+ }
111
+ return self.request(method, path, body, true);
112
+ });
113
+ }
114
+ return res;
115
+ });
116
+ };
117
+
118
+ AuthClient.prototype._parse = function (res) {
119
+ return res.text().then(function (text) {
120
+ var data = {};
121
+ try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { detail: text }; }
122
+ if (!res.ok) {
123
+ var detail = data.detail;
124
+ if (Array.isArray(detail)) detail = detail.map(function (e) { return e.msg || JSON.stringify(e); }).join(', ');
125
+ throw new Error(detail || ('HTTP ' + res.status));
126
+ }
127
+ return data;
128
+ });
129
+ };
130
+
131
+ AuthClient.prototype.get = function (path) {
132
+ return this.request('GET', path).then(this._parse.bind(this));
133
+ };
134
+
135
+ AuthClient.prototype.post = function (path, body) {
136
+ return this.request('POST', path, body).then(this._parse.bind(this));
137
+ };
138
+
139
+ AuthClient.prototype.put = function (path, body) {
140
+ return this.request('PUT', path, body).then(this._parse.bind(this));
141
+ };
142
+
143
+ AuthClient.prototype.delete = function (path) {
144
+ return this.request('DELETE', path).then(this._parse.bind(this));
145
+ };
146
+
147
+ AuthClient.prototype.upload = function (path, formData, retried) {
148
+ var self = this;
149
+ var token = this.getAccessToken();
150
+ return fetch(this.apiBase + path, {
151
+ method: 'POST',
152
+ headers: { Authorization: 'Bearer ' + token },
153
+ body: formData,
154
+ })
155
+ .catch(function () {
156
+ throw new Error('Cannot reach server. Check your connection and try again.');
157
+ })
158
+ .then(function (res) {
159
+ if (res.status === 401 && !retried) {
160
+ return self.refreshAccessToken().then(function (ok) {
161
+ if (!ok) {
162
+ self.clearSession();
163
+ throw new Error('Session expired — please sign in again');
164
+ }
165
+ return self.upload(path, formData, true);
166
+ });
167
+ }
168
+ return res;
169
+ });
170
+ };
171
+
172
+ AuthClient.prototype.download = function (path, retried) {
173
+ var self = this;
174
+ var token = this.getAccessToken();
175
+ return fetch(this.apiBase + path, { headers: { Authorization: 'Bearer ' + token } })
176
+ .catch(function () {
177
+ throw new Error('Cannot reach server. Check your connection and try again.');
178
+ })
179
+ .then(function (res) {
180
+ if (res.status === 401 && !retried) {
181
+ return self.refreshAccessToken().then(function (ok) {
182
+ if (!ok) {
183
+ self.clearSession();
184
+ throw new Error('Session expired — please sign in again');
185
+ }
186
+ return self.download(path, true);
187
+ });
188
+ }
189
+ return res;
190
+ });
191
+ };
192
+
193
+ AuthClient.prototype.login = function (email, password) {
194
+ var self = this;
195
+ return fetch(this.apiBase + '/auth/login', {
196
+ method: 'POST',
197
+ headers: { 'Content-Type': 'application/json' },
198
+ body: JSON.stringify({ email: email, password: password }),
199
+ })
200
+ .then(function (res) { return res.json().then(function (data) { return { res: res, data: data }; }); })
201
+ .then(function (_ref) {
202
+ var res = _ref.res, data = _ref.data;
203
+ if (!res.ok) {
204
+ var detail = data.detail;
205
+ if (Array.isArray(detail)) detail = detail.map(function (e) { return e.msg || JSON.stringify(e); }).join(', ');
206
+ throw new Error(detail || 'Login failed');
207
+ }
208
+ self.saveSession(data);
209
+ return data;
210
+ });
211
+ };
212
+
213
+ global.AuthorBotAuthClient = AuthClient;
214
+ })(window);