Spaces:
Running
Running
AuthorBot commited on
Commit ·
e70f2c6
1
Parent(s): 2b2591e
Fix auth session expiry with shared auto-refresh client for admin panels.
Browse filesSessions 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 +1 -1
- app/admin/templates/admin.html +39 -59
- app/api/schemas_router.py +33 -13
- app/config.py +1 -1
- app/main.py +4 -2
- app/middleware/security_headers.py +11 -2
- app/services/auth_service.py +5 -1
- app/superadmin/templates/superadmin.html +49 -61
- static/addon.html +1 -1
- static/auth-client.js +214 -0
.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=
|
| 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 |
-
|
| 822 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 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
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 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 = () => {
|
| 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
|
| 908 |
-
|
| 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
|
| 917 |
-
|
| 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
|
| 926 |
-
|
| 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
|
| 934 |
-
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
| 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(
|
| 45 |
"""Issue new JWT token pair."""
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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 =
|
| 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=
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 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
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
if (!data.is_superadmin) throw new Error('Not a SuperAdmin account');
|
| 472 |
-
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 = () => {
|
| 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
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
return res.json();
|
| 508 |
}
|
| 509 |
async function apiPost(path, body) {
|
| 510 |
-
const
|
| 511 |
-
|
| 512 |
-
|
| 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
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 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 |
-
|
| 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
|
| 852 |
try {
|
| 853 |
const data = await apiGet('/super/health');
|
|
|
|
| 854 |
cards.forEach((card, i) => {
|
| 855 |
-
const
|
|
|
|
| 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 => {
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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);
|